using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// World props WITHOUT the AnantaGadgetLoader exe — the server-side half of what that tool does /// by frida injection. /// /// The loader calls GadgetMgr.SyncGadgetGridAOIAddByIndex(cx, cz, exceptIds, sectorIds, /// gridIndex) inside the client process; the props themselves come from the client's own /// GadgetBlob, so nothing has to be authored server-side. The wire equivalent is /// SyncGadgetGridAOIDecrease(68956797) — the only grid-AOI method id that exists, and it /// carries both directions (add cells in addGridInfo, remove in removeGridInfo). /// ⛔ CORRECTED 2026-09-27 (user live-tested; the previous wording here was wrong twice): /// · The old line said "Donor server proved this end to end (HANDOFF round 12)". NO donor /// ever said that. HANDOFF-2026-09-19.md:2598 "ROUND 12 VERDICT" is OUR OWN round, /// user-tested 2026-09-20. The HANDOFF docs mix donor material with our own round log — /// round 12/13/15D/15F/15G/15H/16E/16I are all ours (they carry "user-tested" / "TEST NOW"). /// · "appeared AND stayed interactive" is also wrong as written. Appearing and being /// interactable are TWO INDEPENDENT things on this channel: /// - 68956797 only materializes props out of the client's own GadgetBlob, and only for /// cells+sectors we actually push => only a PART of the world ever appears. /// - interactability is decided SOLELY by how the server answers the Ask* device RPCs /// (see GameRouter.SceneDevices.cs: an Ask* INVOKE is only a REQUEST; the client's /// device-state manager waits for the server to echo the transition before it runs any /// presentation). No reply shape => no interaction, injector or not. /// HANDOFF-2026-09-18.md:341 (ROUND 15H) pinned the ONE session where vending + outfits + /// signs + elevators ALL worked at once — Sep 20 01:32 (packets-20260920-012232.log) — and /// that session's wire was an 8-byte struct {int CheckIndex; uint Error}, silent, ALL /// devices. Cross-era census (user-verified): 012232 = 8b struct silent -> all families OK; /// 021225 = 6b blob silent -> vending STUCK (retry storm); r13-15F = 10b + echoes -> /// vending OK but menu prompts DISARM. /// · So "no injection needed" means "no frida injector needed to MATERIALIZE", NOT /// "the server can sit idle and still get interactions". /// /// Two rules the loader's author established and we keep: /// · sector pairing matters — one message carries ONE sector list, so cells are grouped /// by identical sector list (sectorIdList=[0] is outdoor only; interior furniture lives /// in hashed sectors from GadgetCells.json); /// · never spam — a cell is pushed once and re-pushed only on a cell crossing or the /// periodic refresh. The loader's own note warns that hammering the grid channel kills it. /// internal sealed partial class GameRouter { private const string GadgetGridKey = "client4229938-gadget-grid"; /// World grid cell edge in metres — the loader's CELL_SIZE and the client's own grid. private const int GadgetCellSize = 40; private const byte AoiReasonLoaded = 1; private const byte AoiReasonAoiMove = 2; /// /// ★ donor《content-streaming-implementation》§3 的窗口差分(2026-10-01 落地)。 /// prevLoaded = 客户端当前持有的格子;window = 本轮要展示的正方形窗口。 /// /// added = window − prevLoaded // 新格子 → 见到才加载 /// keep = prevLoaded 里仍落在卸载边界内(Chebyshev ≤ unloadRadius)的那些(保留环) /// removed = prevLoaded − keep // 真·出界 → 显式下发卸载 /// next = window ∪ keep // 成为新的 prevLoaded /// /// ⛑ retainVisitedCells=false ⇒ 没有保留环,出窗口即卸载(等价于 unload = radius)。 /// ⛑ 窗口与卸载边界都用 Chebyshev(正方形),与 §2 的 (2r+1)² 定义一致,别改用曼哈顿。 /// internal static void ComputeGridWindowDiff( HashSet<(int, int)> prevLoaded, HashSet<(int, int)> window, int cx, int cz, int unloadRadius, bool retainVisited, out HashSet<(int, int)> added, out HashSet<(int, int)> removed, out HashSet<(int, int)> next) { var keepUnload = Math.Max(0, unloadRadius); var keep = new HashSet<(int, int)>(); foreach (var c in prevLoaded) { if (window.Contains(c)) { keep.Add(c); continue; } if (!retainVisited) continue; if (Math.Max(Math.Abs(c.Item1 - cx), Math.Abs(c.Item2 - cz)) <= keepUnload) keep.Add(c); } added = new HashSet<(int, int)>(window); added.ExceptWith(prevLoaded); removed = new HashSet<(int, int)>(prevLoaded); removed.ExceptWith(keep); next = new HashSet<(int, int)>(keep); next.UnionWith(window); } /// /// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳时两个窗口根本不相交 /// ⇒ 必须 Init 全 re-seed(清空声明过的格子),不能只补差集。 /// 2026-10-01 起用它解决「owner.Sent 只增不减」这一条:以前每次远跳都会留下一份 /// 玩家已经离开的格子,永远不会消失,把卸载这件事全推给客户端自己去猜。 /// ⛑ 判据 = 格子 Chebyshev 距离 > unloadRadius(窗口不相交,donor §3 原文)。 /// private static bool IsFarGridJump(int fromX, int fromZ, int toX, int toZ, int unloadRadius) => Math.Max(Math.Abs(toX - fromX), Math.Abs(toZ - fromZ)) > Math.Max(0, unloadRadius); internal sealed class GadgetGridSessionState { internal object SyncRoot { get; } = new(); internal CancellationTokenSource? Cts; internal Task? Loop; /// Cells already handed to the client. A cell is re-sent only on refresh. internal HashSet<(int, int)> Sent { get; } = new(); internal int LastCellX; internal int LastCellZ; internal bool HasCell; internal bool LoadedSent; /// /// Scene AOI switch (68418115) already pushed for this session. Research lever only — /// gated by SceneAoiSwitchEnabled, which defaults to false. /// internal bool SceneAoiSwitchSent; internal long LastPushMs; internal long ReadyAtMs; internal int Pushes; internal int CellsSent; /// 首次成功整窗推送的时刻(Environment.TickCount64)。入世界守卫的计时基准。 internal long FirstPushMs; /// /// ★ 2026-10-04「入世界守卫」是否已经做过。每会话只做一次; /// 远跳 reseed(ReseedOnFarJump)已经替我们清过窗时也算完成,避免重复砸卸载报文。 /// internal bool SettleDone; } private static GadgetGridSessionState GadgetGridStateOf(TcpSession session) { if (session.Items.TryGetValue(GadgetGridKey, out var existing) && existing is GadgetGridSessionState typed) return typed; var created = new GadgetGridSessionState(); session.Items[GadgetGridKey] = created; return created; } /// World-ready hook: start the grid stream. Idempotent; no-op when disabled. internal static void EnsureGadgetGrid(TcpSession session) { var cfg = PrivateServerConfigStore.Current.World.Content.Gadgets; if (!cfg.Enabled) return; var owner = GadgetGridStateOf(session); lock (owner.SyncRoot) { owner.ReadyAtMs = Environment.TickCount64; owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; owner.SceneAoiSwitchSent = false; owner.LastPushMs = 0; // 入世界守卫:重进世界要重新计时、重新做一次(客户端的实例已随会话没了)。 owner.FirstPushMs = 0; owner.SettleDone = false; // 重进世界:健身房器材与补货物件也要重摆(客户端那边的实例已经随会话没了)。 ResetGymEquipment(session); ResetPropRestock(session); // 露营场地家具 / 龙栖村摊位布景:客户端那边的实例已随会话没了,重进世界要重摆。 ResetPartyCamp(session); ResetMiniGameStall(session); ResetIndoorSector(session); if (owner.Loop is { IsCompleted: false }) return; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); owner.Loop = Task.Run(() => RunGadgetGridLoopAsync(session, owner, owner.Cts.Token)); } } /// Disconnect / scene switch: stop the loop. Props already out stay out. internal static void StopGadgetGrid(TcpSession session) { if (!session.Items.TryGetValue(GadgetGridKey, out var existing) || existing is not GadgetGridSessionState owner) return; lock (owner.SyncRoot) { owner.Cts?.Cancel(); owner.Cts?.Dispose(); owner.Cts = null; owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; } ResetGymEquipment(session); ResetPropRestock(session); ResetPartyCamp(session); ResetMiniGameStall(session); ResetIndoorSector(session); } /// Cells handed to the client so far. Panel/debug display only. internal static int GadgetGridSentCount(TcpSession session) { var owner = GadgetGridStateOf(session); lock (owner.SyncRoot) return owner.Sent.Count; } private static async Task RunGadgetGridLoopAsync( TcpSession session, GadgetGridSessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.World.Content.Gadgets; var tick = Math.Max(250, cfg.TickMs); session.Log.Info($"[GADGET-GRID] loop started radius={cfg.RadiusCells} " + $"radiusInterior={cfg.RadiusInteriorCells} firstDelayMs={cfg.FirstDelayMs} " + $"refreshMs={cfg.RefreshMs} maxCellsPerPush={cfg.MaxCellsPerPush} " + $"settleReseedMs={cfg.SettleReseedMs} " + $"cells={GadgetCellsCatalog4229938.CellCount}"); while (!token.IsCancellationRequested) { try { // RESEARCH LEVER (default OFF): SyncDestructibleSceneAOIActive(68418115). // An unverified external note claimed this is the master AOI switch for 68956797. // The donor who actually shipped the feature disproved it: 68418115 is the // DESTRUCTIBLE family, there is no SceneItem grid method on the wire in 4229938, // and streaming destructible windows corrupts interiors (vanishing doors, missing // outfit NPC) — round 14C/14E. Kept behind a config flag for testing only. if (cfg.SceneAoiSwitchEnabled && !owner.SceneAoiSwitchSent) { owner.SceneAoiSwitchSent = true; await session.NotifyAsync(MethodId.SyncDestructibleSceneAOIActive, new UxWriter().Bool(true).ToArray(), token); session.Log.Info("[GADGET-GRID] scene AOI switch (68418115) = true"); } // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (!IsBackgroundPushPaused(session)) { await TickGadgetGridAsync(session, owner); } else { // 挂起期间把心跳计时器往前推,否则恢复后 now - LastPushMs 早就超过 // heartbeatMs/refreshMs ⇒ 立刻触发一次 fullResend。retainVisitedCells=true // 时那会是玩家走过的**全部**格子(几千个),等于把一整个 burst 砸向刚恢复、 // 还在清积压的客户端 —— 又是一次断线。 lock (owner.SyncRoot) owner.LastPushMs = Environment.TickCount64; } } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[GADGET-GRID] loop stopped: session disposed"); break; } catch (Exception ex) { session.Log.Info($"[GADGET-GRID] tick failed: {ex.Message}"); } try { await Task.Delay(tick, token); } catch (OperationCanceledException) { break; } } } private static async Task TickGadgetGridAsync(TcpSession session, GadgetGridSessionState owner) { var cfg = PrivateServerConfigStore.Current.World.Content.Gadgets; var state = GetStateStatic(session); float px; float pz; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform) return; px = state.LastReportedPlayerPosition.X; pz = state.LastReportedPlayerPosition.Z; } if (!float.IsFinite(px) || !float.IsFinite(pz)) return; var cx = (int)MathF.Floor(px / GadgetCellSize); var cz = (int)MathF.Floor(pz / GadgetCellSize); var now = Environment.TickCount64; // ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳后新旧窗口不相交 // ⇒ 清空声明过的格子,按「首次进窗口」重新铺一遍(旧的那份窗口留着只会让客户端 // 继续持有玩家根本不在附近的一片格子,diff 也永远删不掉它们)。 // 必须在下面的 firstPush 判定**之前**执行,这样 reason 才会回到 Loaded。 if (cfg.ReseedOnFarJump) { bool reseeded = false; lock (owner.SyncRoot) { if (owner.HasCell && IsFarGridJump(owner.LastCellX, owner.LastCellZ, cx, cz, cfg.UnloadRadiusCells)) { owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; owner.LastPushMs = 0; reseeded = true; } } if (reseeded) session.Log.Info($"[GADGET-GRID] far-jump reseed -> ({cx},{cz})"); } // ★★ 2026-10-04「入世界守卫」(world.content.gadgets.settleReseedMs,0 = 关): // 首次整窗推送后等 SettleReseedMs,强制做一次「整窗 remove → add」跃变。 // // 病因(console-20261003-235851.log 坐实):客户端 SyncGadgetGridAOIAddDecrease 只在 // 「格子从不在 AOI → 在 AOI」这一次跃变上实例化物件。首推时客户端 GadgetBlob 还没常驻 // ⇒ SyncGadgetGridAOIAddByBlob 静默 return;之后 heartbeatMs=5000 的整窗重声明 // (fullResend ⇒ toAdd = 整个窗口)补不回来 —— 实测 4.5 分钟约 50 次整窗重推,洞不愈合。 // 玩家「跑远再跑回来」唯一的用处就是走出 unloadRadiusCells 触发一次真·卸载, // 回来时格子变新格 ⇒ 重新实例化。这里在服务端自己造同一次跃变,玩家不用跑。 // // ⛑ 每会话只做一次(SettleDone);卸载报文走既有的 PushGadgetGridRemovalAsync, // reason 用 AoiReasonAoiMove,与「跑远」产生的卸载报文完全一致。 if (cfg.SettleReseedMs > 0 && !owner.SettleDone && owner.FirstPushMs != 0 && now - owner.FirstPushMs >= cfg.SettleReseedMs) { HashSet<(int, int)>? settleRemove = null; lock (owner.SyncRoot) { owner.SettleDone = true; if (owner.Sent.Count > 0) settleRemove = new HashSet<(int, int)>(owner.Sent); owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; owner.LastPushMs = 0; } if (settleRemove is { Count: > 0 }) { if (cfg.RealUnload && !cfg.RealUnloadLogOnly) { await PushGadgetGridRemovalAsync(session, settleRemove, cx, cz, AoiReasonAoiMove); session.Log.Info($"[GADGET-GRID] settle-reseed center=({cx},{cz}) removed={settleRemove.Count}"); } else { session.Log.Info($"[GADGET-GRID] settle-reseed center=({cx},{cz}) " + $"removed={settleRemove.Count} SKIPPED " + $"(realUnload={cfg.RealUnload} logOnly={cfg.RealUnloadLogOnly})"); } } } bool firstPush; bool cellMoved; bool refresh; bool heartbeat; lock (owner.SyncRoot) { firstPush = !owner.LoadedSent; cellMoved = owner.HasCell && (owner.LastCellX != cx || owner.LastCellZ != cz); // ⛑ 2026-09-30 补 `> 0` 守卫(与 HeartbeatMs 同律):没有守卫时把 refreshMs 配成 0 // ⇒ `now - LastPushMs >= 0` 恒真 ⇒ **每个 tick 都整窗重推**(比心跳还频繁)。 // 当前值 120000 ⇒ 本行**行为零变化**,只是拆掉这颗雷(SceneItemGrid 那边已先修同一处)。 refresh = cfg.RefreshMs > 0 && owner.LastPushMs != 0 && now - owner.LastPushMs >= cfg.RefreshMs; // Standing still must not silence the channel: the client drops the window if we stop // declaring it, which is what made the props survive only a few seconds after a // teleport. 0 disables the heartbeat (legacy event-only behaviour). // ⚠ 2026-09-30:上面这句「client drops the window if we stop declaring it」在 // sceneItems 通道已被参考实现反证(见 GameRouter.SceneItemGrid.cs 同处注释); // 本通道(gadgets)**未改**,因为它的心跳是解决「可交互物品加载不全」的,用户明确保留。 heartbeat = cfg.HeartbeatMs > 0 && !firstPush && owner.LastPushMs != 0 && now - owner.LastPushMs >= cfg.HeartbeatMs; if (firstPush && owner.ReadyAtMs != 0 && now - owner.ReadyAtMs < cfg.FirstDelayMs) return; if (!firstPush && !cellMoved && !refresh && !heartbeat) return; owner.LastCellX = cx; owner.LastCellZ = cz; owner.HasCell = true; } var reason = firstPush ? AoiReasonLoaded : AoiReasonAoiMove; // The client treats addGridInfo as its CURRENT AOI snapshot, not as an additive delta: an // AOIMove that only carried the newly entered cells made every previously streamed prop // disappear. Re-send the whole window on a crossing so the props the player is standing // among stay declared. The periodic refresh already did this. var resendWindow = refresh || heartbeat || (cellMoved && cfg.ResendWindowOnMove); var sent = await PushGadgetGridAsync(session, owner, cx, cz, reason, fullResend: resendWindow); if (sent > 0 && firstPush) { // 入世界守卫的计时基准:每次「首次进窗口」推送都重置(远跳 reseed 后会有新的首推)。 lock (owner.SyncRoot) owner.FirstPushMs = now; } // 健身房器材:官方 blob 管线在私服下是空闲的,这四个 pathId 永远不会被网格流带出来, // 所以由服务端按抓下来的坐标显式摆一次。见 GameRouter.GymEquipment.cs。 await TickGymEquipmentAsync(session, state, px, pz); // 场景物件补货:贩卖机 / 室内桌椅 / 抓娃娃机 / 小游戏机 / 篮球场 / 蹦床 / 摊位, // 同样只在客户端 blob 里,网格流带不出来,由服务端按格子显式摆一次。 // 见 GameRouter.PropRestock.cs。 await TickPropRestockAsync(session, state, px, pz); // 派对场地家具(露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车):同一病因 —— blob 里的 // 它们不会被 grid-AOI 下发实例化。这是**常驻**世界装点(烘焙在 raid 23300888), // 不是「开派对才摆」。见 GameRouter.PartyCamp.cs。 // 回滚:world.content.partyCamp.enabled = false。 await TickPartyCampAsync(session, state, px, pz); // 龙栖村(raid 23300999)庙会摊位布景(打气球 / 套圈 / 刮刮乐 / 五子棋 / 炒栗 / 炒茶…): // 同一病因 —— 摊位机关只在关卡 blob 里,grid-AOI 带不出来 ⇒ 服务端按官方坐标显式摆。 // ⛑ 没有它就没有「按 F 进打气球」的交互点(气球玩法依赖这个摊位机关的位姿)。 // 见 GameRouter.MiniGameStall.cs。回滚:world.content.miniGameStalls.enabled = false。 await TickMiniGameStallAsync(session, state, px, pz); // 家具摆放探针(默认全关,见 world.content.debugPlace)。gen 没变就直接返回。 await TickDebugPlaceAsync(session, state, px, pz); // 室内扇区(sector_high_indoor_):第五条通道,之前服务端完全没推过。 // 玩家走近某个 IndoorConfig 条目就推 SyncLoadIndoorSector + SyncIndoorSectorControl。 // 见 GameRouter.IndoorSector.cs。默认 enabled=false + logOnly=true,先观察再开。 await TickIndoorSectorAsync(session, state, px, pz); lock (owner.SyncRoot) { owner.LoadedSent = true; owner.LastPushMs = Environment.TickCount64; owner.Pushes++; owner.CellsSent += sent; } } /// /// Push every not-yet-sent cell around (cx,cz), grouped by sector list, one message per group. /// Returns how many cells went out. /// /// ★ 2026-10-01 重写为 donor《content-streaming-implementation》§3 的 add/keep/remove 三元组: /// 以前只算 added(差额补发),removeGridInfo 恒为空 ⇒ 走过的格子永远被声明、 /// 客户端永不卸载(这正是 2026-09-27 §10.9.3 内存项第 3 条)。现在走出 /// unloadRadiusCells 边界的格子会被显式下发卸载。 /// private static async Task PushGadgetGridAsync( TcpSession session, GadgetGridSessionState owner, int cx, int cz, byte reason, bool fullResend) { var cfg = PrivateServerConfigStore.Current.World.Content.Gadgets; var radius = Math.Max(0, cfg.RadiusCells); var radiusInterior = Math.Max(0, cfg.RadiusInteriorCells); var budget = Math.Max(1, cfg.MaxCellsPerPush); // 硬规则 25:200448 必须排除,否则客户端 LoadDynamicItem(200448,0) 触发 // StateMachineContainer 无限递归(表现为卡死然后断线)。同一个 id 要在三个位置 // 同时声明才稳:addGridInfo.ExceptIds(这一批格子别实例化它)+ aoiInfo.removeIds // (已实例化的立刻移除)+ aoiInfo.inactiveIds(别激活它)。 var exceptIds = (cfg.ExcludedGadgetIds ?? []).ToList(); // ① 本轮窗口(正方形;室内格按 radiusInterior 收窄) var window = new HashSet<(int, int)>(); for (var dx = -radius; dx <= radius; dx++) { for (var dz = -radius; dz <= radius; dz++) { var gx = cx + dx; var gz = cz + dz; // Interior cells get a much tighter ring: the loader only ever streamed a couple // of cells deep indoors and a wide ring there just wastes the budget. var interior = GadgetCellsCatalog4229938.IsInteriorCell(gx, gz); if (interior && (Math.Abs(dx) > radiusInterior || Math.Abs(dz) > radiusInterior)) continue; window.Add((gx, gz)); } } // ② donor §3 差分:added / keep(保留环) / removed HashSet<(int, int)> toAdd; HashSet<(int, int)> toRemove; HashSet<(int, int)> nextDeclared; lock (owner.SyncRoot) { var prev = new HashSet<(int, int)>(owner.Sent); ComputeGridWindowDiff(prev, window, cx, cz, cfg.UnloadRadiusCells, cfg.RetainVisitedCells, out toAdd, out toRemove, out nextDeclared); // 心跳 / 刷新 = 重新声明整窗(旧语义保留给 gadgets:它是「可交互物品加载不全」的解药)。 if (fullResend) toAdd = new HashSet<(int, int)>(window); // ⛑ owner.Sent 是只读属性 ⇒ 只能原地改。 owner.Sent.Clear(); if (cfg.RealUnload && !cfg.RealUnloadLogOnly) owner.Sent.UnionWith(nextDeclared); else owner.Sent.UnionWith(prev.Union(window)); } // ③ 真·卸载(donor §4:removeGridInfo 只放格子,不放 per-object 数据) if (cfg.RealUnload && toRemove.Count > 0) { if (cfg.RealUnloadLogOnly) { session.Log.Info($"[GADGET-GRID] unload LOG-ONLY center=({cx},{cz}) " + $"wouldRemove={toRemove.Count} declared={owner.Sent.Count}"); } else { await PushGadgetGridRemovalAsync(session, toRemove, cx, cz, reason); } } if (toAdd.Count == 0) return 0; // ④ 加载。sector-list key -> cells. One message per key: SectorIdList is per-message. var groups = new Dictionary Cells)>(); foreach (var cell in toAdd) { var sectors = cfg.LoadAllSectors ? [0] : GadgetCellsCatalog4229938.SectorsOf(cell.Item1, cell.Item2); var key = string.Join(',', sectors); if (!groups.TryGetValue(key, out var group)) { group = (sectors, new List<(int, int)>()); groups[key] = group; } group.Cells.Add(cell); } // Outdoor first: the props you see while walking matter more than a room you have not // entered, and the same ordering keeps the logs stable. var ordered = groups.Values .OrderBy(g => g.Sectors.Any(s => s != 0) ? 1 : 0) .ThenByDescending(g => g.Cells.Count) .ToList(); var total = 0; var groupIndex = 0; foreach (var group in ordered) { for (var offset = 0; offset < group.Cells.Count; offset += budget) { var slice = group.Cells.Skip(offset).Take(budget).ToList(); if (slice.Count == 0) continue; var args = new SceneMethods.SyncGadgetGridAOIDecreaseArgs4229938 { aoiInfo = new SceneMethods.GadgetGridAOIIncrease { PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz }, // ★★ 2026-10-04:`indexList` 以前恒留空,那是「进世界有的格子不出物件」的直接原因。 // 客户端 GameSceneToClientImpl.SyncGadgetGridAOIDecrease (RVA 0x1276E0E0) 在调 // SyncGadgetGridAOIAddDecrease **之前**先跑 // if (aoiInfo.indexList != null) CheckBlobConfigIsLoaded(aoiInfo.indexList); // 0x1276E6EE // 这是「为本批格子预加载 GadgetBlob」的入口。留空 ⇒ 预加载不发生 ⇒ 后面的 // SyncGadgetGridAOIAddByBlob 在 chunkMap 里取不到 chunk ⇒ 静默 return(洞)。 // 参照 PrivateServerPor/GameRouter.SceneContent.cs:109 —— 它填的就是整窗, // 实测那边进世界没有加载不出来的情况。本通道按分组切片填本批格子(等价语义)。 indexList = slice .Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 }) .ToList(), removeIds = exceptIds, inactiveIds = exceptIds, reason = reason, }, addGridInfo = new SceneMethods.GridAOIDecrease { SectorIdList = group.Sectors.ToList(), BuildingId = 0, StandardIndexList = slice .Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 }) .ToList(), ExceptIds = exceptIds, }, // ★ 2026-10-01:不再恒空。真·卸载改由 PushGadgetGridRemovalAsync // 单独一条报文下发(先卸后加载),硬规则 212 就此作废。 removeGridInfo = new SceneMethods.GridAOIDecrease(), extraInfo = new SceneMethods.GadgetExtraSyncInfo { DropLimit = null }, }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.SyncGadgetGridAOIDecrease, body, CancellationToken.None); total += slice.Count; groupIndex++; int declared; lock (owner.SyncRoot) declared = owner.Sent.Count; session.Log.Info($"[GADGET-GRID] push group {groupIndex}/{ordered.Count} " + $"center=({cx},{cz}) reason={reason} sectors=[{string.Join(",", group.Sectors)}] " + $"cells={slice.Count} bytes={body.Length} declared={declared}"); } } return total; } /// /// ★ donor《content-streaming-implementation》§4 —— 显式的「请卸载这批格子」。 /// 单独一条 68956797:addGridInfo 空表、removeGridInfo 装出界的格子。 /// ⛑ BuildingId 必须写 int32(值 0)—— donor §6 第 2 条:漏写会让整条 payload 错位。 /// ⛑ sector 仍然要带:一条报文一个 sector 列表,和加载侧同律分组。 /// private static async Task PushGadgetGridRemovalAsync( TcpSession session, HashSet<(int, int)> removed, int cx, int cz, byte reason) { var cfg = PrivateServerConfigStore.Current.World.Content.Gadgets; var budget = Math.Max(1, cfg.MaxCellsPerPush); var groups = new Dictionary Cells)>(); foreach (var cell in removed) { var sectors = cfg.LoadAllSectors ? [0] : GadgetCellsCatalog4229938.SectorsOf(cell.Item1, cell.Item2); var key = string.Join(',', sectors); if (!groups.TryGetValue(key, out var group)) { group = (sectors, new List<(int, int)>()); groups[key] = group; } group.Cells.Add(cell); } foreach (var group in groups.Values) { for (var offset = 0; offset < group.Cells.Count; offset += budget) { var slice = group.Cells.Skip(offset).Take(budget).ToList(); if (slice.Count == 0) continue; var args = new SceneMethods.SyncGadgetGridAOIDecreaseArgs4229938 { aoiInfo = new SceneMethods.GadgetGridAOIIncrease { PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz }, reason = reason, }, addGridInfo = new SceneMethods.GridAOIDecrease(), removeGridInfo = new SceneMethods.GridAOIDecrease { SectorIdList = group.Sectors.ToList(), // donor §6 第 2 条:永远是 int32,值写 0;不写会让后续字段位移。 BuildingId = 0, StandardIndexList = slice .Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 }) .ToList(), }, extraInfo = new SceneMethods.GadgetExtraSyncInfo { DropLimit = null }, }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.SyncGadgetGridAOIDecrease, body, CancellationToken.None); session.Log.Info($"[GADGET-GRID] unload center=({cx},{cz}) reason={reason} " + $"sectors=[{string.Join(",", group.Sectors)}] " + $"cells={slice.Count} bytes={body.Length}"); } } } }