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; /// /// 场景物品(路灯 / 街边道具 / 可破坏物 / 念动力目标)的服务端流式下发 —— AnantaWorldMod /// 的等价实现,不需要 frida 注入器。 /// /// 原理与 GameRouter.GadgetGrid.cs 完全同构:客户端自己有烘焙好的 SceneItemBlob,服务端 /// 只要告诉它「哪几个 40m 网格在视野内」,客户端就从 blob 里把物品铺出来。 /// 注入器每 700ms 调 SceneItemMgr.SyncSceneItemGridAOIAddByIndex(cx, cz, exceptIds, sectorIdList, /// playerStandardIndex);服务端等价物 = 推 SyncDestructibleGridAOIDecrease(68969593), /// 接收端 GameSceneToClientImpl 三连展开正是 remove → increase → AddDecrease(→AddByIndex)。 /// /// ⛑️⛑️ 必须用 68969593,不是 68934309:68934309 的 args 只装得下 aoiInfo 一个字段 /// (dump.cs TypeDefIndex 35754),它的接收端是记账用的;网格下发只能走 Decrease 孪生 /// (与 gadget 的 68956797 同律)。用错 mid 的表现是「推了、日志干净、一个都不出」。 /// /// 网格→sector 表直接复用 GadgetCellsCatalog4229938(Configs/GadgetCells.json): /// 那份文件本身就是 AnantaWorldMod 运行时导出的 cell→sector(5116 格,meta 写明 /// "runtime export from 4229938 client"),两个家族用的是同一张烘焙表。 /// internal sealed partial class GameRouter { private const string SceneItemGridKey = "client4229938-scene-item-grid"; /// 40 m 世界网格边长 —— 与客户端 SceneItemMgr 的 CELL_SIZE 一致。 private const int SceneItemCellSize = 40; private const byte SceneItemReasonLoaded = 1; private const byte SceneItemReasonAoiMove = 2; internal sealed class SceneItemGridSessionState { internal object SyncRoot { get; } = new(); internal CancellationTokenSource? Cts; internal Task? Loop; /// 已经下发过的网格;只在刷新 / 心跳 / 跨格重声明时重发。 internal HashSet<(int, int)> Sent { get; } = new(); internal int LastCellX; internal int LastCellZ; internal bool HasCell; internal bool LoadedSent; /// 可破坏物总开关 SyncDestructibleSceneAOIActive(68418115) 本会话是否已推。 internal bool ActiveSwitchSent; /// 念动力测试盒本会话是否已摆过(只摆一次,见 SpawnTestBox)。 internal bool TestBoxSpawned; /// /// ★★★ R93(自 donor v2.1「打气球」移植)—— 本会话「脚本自建、但不在客户端烘焙 /// SceneItemBlob 里」的可破坏物保护表(气球的 12/16 颗球就属于这一类)。 /// /// 病因(donor 真机实测):心跳 / 刷新每 5 s 重发一次 SyncDestructibleGridAOIDecrease /// 声明 AOI 窗口;该报文 aoiInfo.addInfos 为空 + addGridInfo.ExceptIds 为空 ⇒ /// 客户端语义是「这些格子改为从你自己的 SceneItemBlob 重新解析」⇒ 服务端合成的球 /// (blob 里没有)每次心跳都被整片抹掉。 /// /// 修法:把这类 id 记在这里,心跳时 ① 塞进 addGridInfo.ExceptIds(明确告诉客户端 /// 「别删这些」),② 并把其 DestructibleInfo 重新塞回 aoiInfo.addInfos(保证还在)。 /// ⛑ Info 是 object:登记方可能持有 BowlingWire 那份 DestructibleInfo(真机验证过 /// 能建出 hold 的标注),由 归一。 /// /// 🆕 2026-09-30:本通道改**严格增量**后,这张表实际上**退化为空转** —— 因为「重推整窗 /// ⇒ addInfos 空 ⇒ 客户端按 blob 重建 ⇒ 抹掉服务端合成物」这条链路的**前半段不再发生** /// (已推过的格子根本不会再发)。气球本体本来就是 GameRouter.Balloon.cs 用 /// SyncDestructibleObjectAddList(68963429) + 自带的 68969593 报文建的,不依赖这里。 /// 表**保留不删**:一旦把 heartbeatMs / refreshMs / resendWindowOnMove 回滚成整窗重推, /// 它立刻重新生效(否则气球会被抹掉,R93 的病会原样复发)。 /// internal Dictionary Protected { get; } = new(); internal long LastPushMs; internal long ReadyAtMs; internal int Pushes; internal int CellsSent; } private static SceneItemGridSessionState SceneItemGridStateOf(TcpSession session) { if (session.Items.TryGetValue(SceneItemGridKey, out var existing) && existing is SceneItemGridSessionState typed) return typed; var created = new SceneItemGridSessionState(); session.Items[SceneItemGridKey] = created; return created; } /// /// ★★★ R93(自 donor v2.1「打气球」移植)—— 把「脚本自建的可破坏物」登记为受保护, /// 直到显式撤下。心跳 / 刷新重声明 AOI 窗口时,这些 id 会进 ExceptIds 并随 /// addInfos 一起重发,从而不被客户端按烘焙 blob 覆盖掉。 /// internal static void ProtectDestructibles(TcpSession session, IEnumerable<(ulong Id, float X, float Z, object Info)> items) { var owner = SceneItemGridStateOf(session); lock (owner.SyncRoot) foreach (var it in items) owner.Protected[it.Id] = (it.X, it.Z, it.Info); } /// ★ R93 — 撤下保护(离开气球区 / 小游戏结束时用)。 internal static void UnprotectDestructibles(TcpSession session, IEnumerable ids) { var owner = SceneItemGridStateOf(session); lock (owner.SyncRoot) foreach (var id in ids) owner.Protected.Remove(id); } /// 世界 ready 钩子:启动场景物品流。幂等;配置关闭时 no-op。 internal static void EnsureSceneItemGrid(TcpSession session) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; if (!cfg.Enabled) return; var owner = SceneItemGridStateOf(session); lock (owner.SyncRoot) { owner.ReadyAtMs = Environment.TickCount64; owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; owner.ActiveSwitchSent = false; owner.TestBoxSpawned = false; owner.LastPushMs = 0; if (owner.Loop is { IsCompleted: false }) return; owner.Cts?.Dispose(); owner.Cts = new CancellationTokenSource(); owner.Loop = Task.Run(() => RunSceneItemGridLoopAsync(session, owner, owner.Cts.Token)); } } /// 断线 / 切场景:停循环。已经铺出来的物品留在原地。 internal static void StopSceneItemGrid(TcpSession session) { if (!session.Items.TryGetValue(SceneItemGridKey, out var existing) || existing is not SceneItemGridSessionState owner) return; lock (owner.SyncRoot) { owner.Cts?.Cancel(); owner.Cts?.Dispose(); owner.Cts = null; owner.Sent.Clear(); owner.HasCell = false; owner.LoadedSent = false; } } /// 已下发网格数(面板 / 调试用)。 internal static int SceneItemGridSentCount(TcpSession session) { var owner = SceneItemGridStateOf(session); lock (owner.SyncRoot) return owner.Sent.Count; } private static async Task RunSceneItemGridLoopAsync( TcpSession session, SceneItemGridSessionState owner, CancellationToken token) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; var tick = Math.Max(250, cfg.TickMs); // 🆕 2026-09-30:把「本会话跑的是哪种推送模式」打进日志 —— 排 5 秒闪烁时第一眼就要看这个。 // STRICT-INCREMENTAL = 每格只 Add 一次(等价 AnantaWorldMod),站桩零推送; // WINDOW-RESEND = 心跳 / 刷新 / 跨格整窗重推(旧行为,会让物件每 5 s 闪一次)。 var mode = (cfg.HeartbeatMs > 0 || cfg.RefreshMs > 0 || cfg.ResendWindowOnMove) ? "WINDOW-RESEND" : "STRICT-INCREMENTAL"; session.Log.Info($"[SCENE-ITEM-GRID] loop started mode={mode} radius={cfg.RadiusCells} " + $"radiusInterior={cfg.RadiusInteriorCells} firstDelayMs={cfg.FirstDelayMs} " + $"heartbeatMs={cfg.HeartbeatMs} refreshMs={cfg.RefreshMs} " + $"resendWindowOnMove={cfg.ResendWindowOnMove} " + $"maxCellsPerPush={cfg.MaxCellsPerPush} " + $"armActive={cfg.ArmActiveSwitch} cells={GadgetCellsCatalog4229938.CellCount}"); while (!token.IsCancellationRequested) { try { // 可破坏物家族的总开关:SyncDestructibleSceneAOIActive(68418115) = 一个裸 bool 字节。 // ⛑️ 它属于 DESTRUCTIBLE 家族,不是 gadget 网格的开关:硬规则 31 记的「68418115 会 // 破坏室内」是当年把它当 gadget 场景 AOI 开关来推踩出来的坑。这里默认关 // (armActiveSwitch=false);若首轮实测一个物品都不出,这是第一顺位要开的开关。 if (cfg.ArmActiveSwitch && !owner.ActiveSwitchSent) { owner.ActiveSwitchSent = true; await session.NotifyAsync(MethodId.SyncDestructibleSceneAOIActive, new UxWriter().Bool(true).ToArray(), token); session.Log.Info("[SCENE-ITEM-GRID] active switch (68418115) = true"); } // ⛑️ 客户端挂起期间只睡不推(硬规则 37),否则 TCP 灌满 ⇒ 回前台判定断线。 if (!IsBackgroundPushPaused(session)) { await TickSceneItemGridAsync(session, owner); } else { // 挂起期间把心跳计时器往前推,否则恢复瞬间 now - LastPushMs 早就越过 // heartbeat/refresh ⇒ 立刻 fullResend,把一整个 burst 砸向刚恢复的客户端。 // ⛑ 2026-09-28 订正:这里原来写「retainVisitedCells=true 时会是玩家走过的全部格子」 // —— 错的。PushSceneItemGridAsync 只遍历 cx/cz ± radius 那个窗口, // 跟 owner.Sent(走过的格子)无关;Sent 只在 fullResend=false 时当去重表用。 // 实测(客户端会话 13:57–14:11)整局每轮恒为 130 格(sceneItems 49 + gadgets 81), // 玩家走了 29 个 center 格也没让窗口变大过。 lock (owner.SyncRoot) owner.LastPushMs = Environment.TickCount64; } } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { session.Log.Info("[SCENE-ITEM-GRID] loop stopped: session disposed"); break; } catch (Exception ex) { session.Log.Info($"[SCENE-ITEM-GRID] tick failed: {ex.Message}"); } try { await Task.Delay(tick, token); } catch (OperationCanceledException) { break; } } } private static async Task TickSceneItemGridAsync(TcpSession session, SceneItemGridSessionState owner) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; var state = GetStateStatic(session); float px; float py; float pz; lock (state.SyncRoot) { if (!state.Ready || !state.HasLastReportedPlayerTransform) return; px = state.LastReportedPlayerPosition.X; py = state.LastReportedPlayerPosition.Y; pz = state.LastReportedPlayerPosition.Z; } if (!float.IsFinite(px) || !float.IsFinite(pz)) return; var cx = (int)MathF.Floor(px / SceneItemCellSize); var cz = (int)MathF.Floor(pz / SceneItemCellSize); var now = Environment.TickCount64; // ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳后新旧窗口不相交 // ⇒ 清空声明过的格子按「首次进窗口」重铺(旧窗口留着既占客户端内存,diff 也删不掉)。 // 必须在下面的 firstPush 判定**之前**执行,reason 才会回到 Loaded。 // TeleportPlayerInstantAsync / TeleportPlayerWithLoadingAsync 都会乐观更新 // LastReportedPlayerTransform,所以这里自动覆盖 GM 传送与 /api/teleport。 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($"[SCENE-ITEM-GRID] far-jump reseed -> ({cx},{cz})"); } bool firstPush; bool cellMoved; bool refresh; bool heartbeat; lock (owner.SyncRoot) { firstPush = !owner.LoadedSent; cellMoved = owner.HasCell && (owner.LastCellX != cx || owner.LastCellZ != cz); // ⛑⛑ RefreshMs <= 0 = **关闭**(与 HeartbeatMs 同律)。 // 2026-09-30 修:原来这一行没有 `> 0` 守卫 ⇒ 配成 0 时变成 // `now - LastPushMs >= 0`(恒真)⇒ **每一轮都 fullResend**,正好是「整窗重推」 // 最隐蔽的一个入口(比 heartbeat 还狠:tick 多少毫秒就重推多少次)。 refresh = cfg.RefreshMs > 0 && owner.LastPushMs != 0 && now - owner.LastPushMs >= cfg.RefreshMs; // ⛔⛔ 2026-09-30 用户定性 + 实证推翻(原注释「这条心跳是必需的,不要砍掉」**作废**): // ① 用户实机症状:**可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位**。 // 机制 = 客户端对已加载的格子再次 AddByIndex = **重新实例化** ⇒ 位移 / 破坏 // 状态 / 念力 hold 全部被重置。站桩不动时整窗每 5 s 重推一次 ⇒ 每 5 s 闪一次。 // ② 参考实现 AnantaWorldMod(new-worldmod/world_mod.js:3525-3550)**从不重推**: // 每 700ms 遍历 LOAD_R 窗口,`if (loaded.has(key)) continue;` ⇒ **每格只 Add 一次**; // 只有超出 UNLOAD_R(6) 的格子才 RemoveByIndex + 从 loaded 摘除。 // ③ 原先那条「heartbeatMs=60000 ⇒ 道具闪没一瞬间又回来」的实证,现在解释为 // **反过来的因果**:重推周期被拉长,反而把「重推 = 重新实例化」这件事暴露成 // 肉眼可见的一次闪;并不是「不推就消失」。 // ⇒ 本通道改**严格增量**(等价 AnantaWorldMod):格子进 owner.Sent 后永不重推。 // ⛑ 回滚:sceneItems.heartbeatMs=5000 / refreshMs=120000 / resendWindowOnMove=true。 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 ? SceneItemReasonLoaded : SceneItemReasonAoiMove; // ⚠ 旧注释「addGridInfo 是『当前 AOI 快照』而不是增量:只带新进入的格子会让先前铺出来的 // 物品整片消失」—— 这条**假设已被 AnantaWorldMod 反证**(它只带新格子,物品不消失), // 真正的因果是反的:**重推已加载的格子 = 客户端重新实例化 = 每 5 秒闪一次、状态被重置**。 // 2026-09-30 起本通道走**严格增量**(等价 AnantaWorldMod): // heartbeatMs=0 / refreshMs=0 / resendWindowOnMove=false ⇒ resendWindow 恒 false // ⇒ PushSceneItemGridAsync 里 `owner.Sent.Contains` 永久生效 ⇒ 每格只 Add 一次。 // ⛑ 想回到「整窗重推」:把上面三个键改回 5000 / 120000 / true 即可,无需改代码。 var resendWindow = refresh || heartbeat || (cellMoved && cfg.ResendWindowOnMove); var spawns = BuildTestBoxSpawns(session, owner, px, py, pz); var sent = await PushSceneItemGridAsync(session, owner, cx, cz, reason, fullResend: resendWindow, spawns: spawns); lock (owner.SyncRoot) { owner.LoadedSent = true; owner.LastPushMs = Environment.TickCount64; owner.Pushes++; owner.CellsSent += sent; } } /// /// 把 (cx,cz) 周围还没发过的网格按 sector 列表分组下发,一组一条消息。返回发出的格子数。 /// private static async Task PushSceneItemGridAsync( TcpSession session, SceneItemGridSessionState owner, int cx, int cz, byte reason, bool fullResend, List? spawns = null) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; var radius = Math.Max(0, cfg.RadiusCells); var radiusInterior = Math.Max(0, cfg.RadiusInteriorCells); var budget = Math.Max(1, cfg.MaxCellsPerPush); // ① 本轮窗口(正方形;室内格按 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; // 室内格收窄:注入器在室内也只铺一两格深,宽窗纯属浪费预算。 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); 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($"[SCENE-ITEM-GRID] unload LOG-ONLY center=({cx},{cz}) " + $"wouldRemove={toRemove.Count} declared={owner.Sent.Count}"); } else { await PushSceneItemGridRemovalAsync(session, toRemove, cx, cz, reason); } } // sector 列表 -> 格子。一条消息只能带一个 sector 列表。 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); } if (groups.Count == 0) return 0; // 室外优先:走路时看得见的比没进过的房间重要,顺带让日志顺序稳定。 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; // ★★★ R93 —— 受保护物(服务端合成、不在客户端烘焙 blob 里的可破坏物,如气球) // 每窗都要重新塞回 aoiInfo.addInfos,否则「addInfos 空 = 从你自己的 blob 重建」 // 这条快照语义会把它们从客户端撤掉(球生成后一秒内就消失)。 // 只挑落在本 slice 格子里的那几件。 var sliceSet = slice.ToHashSet(); List? protectedInfos = null; List? protectedIds = null; lock (owner.SyncRoot) { if (owner.Protected.Count > 0) { foreach (var kv in owner.Protected) { var pcell = ((int)MathF.Floor(kv.Value.X / SceneItemCellSize), (int)MathF.Floor(kv.Value.Z / SceneItemCellSize)); if (!sliceSet.Contains(pcell)) continue; (protectedIds ??= []).Add(kv.Key); if (ToGridDestructibleInfo(kv.Value.Info) is { } di) (protectedInfos ??= []).Add(di); } } } // 显式摆放(念动力测试盒)只挂在本轮的第一条消息上:一次推送可能拆成好几条 // (每 64 格一条),每条都带一遍就是同一批箱子摆好几遍。 var addInfos = protectedInfos ?? []; var addUniqueIds = protectedIds ?? new List(); if (groupIndex == 0 && spawns is { Count: > 0 }) { addInfos = [.. addInfos, .. spawns]; addUniqueIds = [.. addUniqueIds, .. spawns.ConvertAll(s => s.InstanceId)]; } var args = new SceneMethods.SyncDestructibleGridAOIDecreaseArgs4229938 { aoiInfo = new SceneMethods.DestructibleGridAOIIncrease { PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz }, addInfos = addInfos, addUniqueIds = addUniqueIds, 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(), // ★ R93:受保护物的 id 也进 ExceptIds —— 明确告诉客户端「别删这些」。 ExceptIds = protectedIds ?? [], }, // ★ 2026-10-01:不再恒空 —— 真·卸载改由 PushSceneItemGridRemovalAsync // 单独一条报文下发(先卸后加载)。硬规则 212("从不发真移除")就此作废, // unloadRadiusCells 也不再是空头支票。 removeGridInfo = new SceneMethods.GridAOIDecrease(), }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None); total += slice.Count; groupIndex++; int sentTotal; lock (owner.SyncRoot) sentTotal = owner.Sent.Count; session.Log.Info($"[SCENE-ITEM-GRID] push group {groupIndex}/{ordered.Count} " + $"center=({cx},{cz}) reason={reason} fullResend={fullResend} " + $"sectors=[{string.Join(",", group.Sectors)}] " + $"cells={slice.Count} bytes={body.Length} sentTotal={sentTotal}"); } } return total; } /// /// ★ donor《content-streaming-implementation》§4 —— 显式的「请卸载这批格子」。 /// 单独一条 68969593:addGridInfo 空表、removeGridInfo 装出界的格子。 /// ⛑ donor §4 提醒 family 差异:68969593 没有 extraInfo / activeIds / inactiveIds /// 三个字段(那是 gadget 侧才有的),别照 GadgetGrid 那份填。 /// ⛑ BuildingId 必须写 int32(值 0),漏写会让整条 payload 位移。 /// private static async Task PushSceneItemGridRemovalAsync( TcpSession session, HashSet<(int, int)> removed, int cx, int cz, byte reason) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; 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.SyncDestructibleGridAOIDecreaseArgs4229938 { aoiInfo = new SceneMethods.DestructibleGridAOIIncrease { PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz }, reason = reason, }, addGridInfo = new SceneMethods.GridAOIDecrease(), removeGridInfo = new SceneMethods.GridAOIDecrease { SectorIdList = group.Sectors.ToList(), BuildingId = 0, StandardIndexList = slice .Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 }) .ToList(), }, }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None); session.Log.Info($"[SCENE-ITEM-GRID] unload center=({cx},{cz}) reason={reason} " + $"sectors=[{string.Join(",", group.Sectors)}] " + $"cells={slice.Count} bytes={body.Length}"); } } } /// 测试盒 InstanceId 的自增源。高位起跳,避免撞上客户端烘焙出来的真实 id。 private static long _testBoxSeq = 0x4000000000000000; /// /// 念动力探针(硬规则 28):DestructibleGridAOIIncrease.addInfos 可以凭空造一个可抓的 /// SceneItem —— 给 CfgId(SceneitemConfig 里 CanQInteraction=true 的条目)+ /// Position 就够,客户端会实例化成真物件并被 MindPowerMgr 认成 Q 目标。 /// /// 这是 T1-22 里唯一能直接验证「服务端显式摆放」这条路的手段:箱子出现 ⇒ 路通,家具不出 /// 只是缺坐标;箱子都不出 ⇒ 先修通道。 /// /// ⛑️ 只在本会话第一次推送时摆:心跳 / 刷新 / 跨格重声明都会重发整窗,摆第二次会叠箱子。 /// ⛑️ 序列化的坑:DestructibleInfo 的客户端 reader 是多态的 /// (ReadStaticDestructibleInfo 跳转表:0=null / 1=BASE / 2=Dynamic / 3=Static / 4=Task)。 /// 这里先走 UxSerializer 的常规写法;若客户端日志出现 /// deserialize failed ... parameter=DestructibleInfo,就说明缺那个 1=BASE 的标签字节, /// 需要改成手写裸字节(见 SceneItemGridMethods4229938.cs 里 DestructibleInfo4229938 的注释)。 /// 失败的包只会被客户端丢弃,不会污染场景,所以这个探针可以放心试。 /// /// /// ★ R93 —— 把登记进 Protected 的可破坏物 DTO 归一到本管线使用的 /// SceneMethods.DestructibleInfo4229938。 /// /// 气球(GameRouter.Balloon.cs)持有的是 BowlingWire 那份 DestructibleInfo /// —— 它是**真机验证过能建出 hold** 的标注(Marker=1 / BASE),而网格通道这份是同构双胞胎 /// (SceneMethods,字段一致但 UXVector3 用的是 Auto 版)。两份绝不能混用(会静默串位), /// 所以登记时存 object、心跳时在这里逐字段映射。认不出的类型返回 null(该件不摆)。 /// private static SceneMethods.DestructibleInfo4229938? ToGridDestructibleInfo(object? info) { switch (info) { case SceneMethods.DestructibleInfo4229938 same: return same; case DestructibleInfo bw: return new SceneMethods.DestructibleInfo4229938 { InstanceId = bw.InstanceId, UniqueId = bw.UniqueId, CfgId = bw.CfgId, PathId = bw.PathId, Position = new global::Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3 { X = bw.Position.X, Y = bw.Position.Y, Z = bw.Position.Z, }, Facing = new global::Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3 { X = bw.Facing.X, Y = bw.Facing.Y, Z = bw.Facing.Z, }, iScale = bw.IScale, NavId = bw.NavId, ForceLod0 = bw.ForceLod0, BindPid = bw.BindPid, Hp = bw.Hp, State = bw.State, BreakStage = bw.BreakStage, // ⛔ 两套 SceneDeviceOccupantInfo 是**不同类型**(BowlingWire 版 SourceType 是 byte, // SceneMethods 版是 int),必须逐项转,不能直接赋值。 OccupantInfos = bw.OccupantInfos is { Count: > 0 } ? bw.OccupantInfos.ConvertAll(o => new SceneMethods.SceneDeviceOccupantInfo { Pid = o.Pid, FightSpiritId = o.FightSpiritId, AttractNpcPid = o.AttractNpcPid, Index = o.Index, IsState = o.IsState, SourceType = o.SourceType, }) : null, DropWeaponId = bw.DropWeaponId, StackCount = bw.StackCount, EffectIds = bw.EffectIds, NoSleep = bw.NoSleep, ReleaserId = bw.ReleaserId, // SkillInfo 不为 null 时逐字段转(气球目前不带,保留通路) SkillInfo = bw.SkillInfo is null ? null : new SceneMethods.SkillDestructibleInfo4229938 { CreateSkillInstanceId = bw.SkillInfo.CreateSkillInstanceId, CreateIndex = bw.SkillInfo.CreateIndex, }, }; default: return null; } } private static List BuildTestBoxSpawns( TcpSession session, SceneItemGridSessionState owner, float px, float py, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems; if (!cfg.SpawnTestBox) return []; var cfgIds = cfg.SpawnTestBoxCfgIds ?? []; if (cfgIds.Length == 0) return []; lock (owner.SyncRoot) { if (owner.TestBoxSpawned) return []; owner.TestBoxSpawned = true; } var spawns = new List(cfgIds.Length); foreach (var cfgId in cfgIds) { var uid = (ulong)System.Threading.Interlocked.Increment(ref _testBoxSeq); spawns.Add(new SceneMethods.DestructibleInfo4229938 { InstanceId = uid, UniqueId = uid, CfgId = (uint)cfgId, PathId = 0, Position = new() { X = px + 3.5f, Y = py + 0.5f, Z = pz }, Facing = new(), iScale = 1000, NavId = 0, ForceLod0 = false, BindPid = 0, Hp = 100f, State = 0, BreakStage = 0, OccupantInfos = [], DropWeaponId = 0, StackCount = 0, EffectIds = [], NoSleep = false, ReleaserId = 0, SkillInfo = new SceneMethods.SkillDestructibleInfo4229938 { CreateSkillInstanceId = 0, CreateIndex = 0, }, }); } session.Log.Info($"[SCENE-ITEM-GRID] test-box spawn cfgIds=[{string.Join(",", cfgIds)}] " + $"first=0x{spawns[0].InstanceId:X} @({px:0.##},{py:0.##},{pz:0.##})"); return spawns; } }