Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.SceneItemGrid.cs

740 lines
36 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 场景物品(路灯 / 街边道具 / <b>可破坏物</b> / 念动力目标)的服务端流式下发 —— AnantaWorldMod
/// 的等价实现,不需要 frida 注入器。
///
/// 原理与 <c>GameRouter.GadgetGrid.cs</c> 完全同构:客户端自己有烘焙好的 SceneItemBlob,服务端
/// 只要告诉它「哪几个 40m 网格在视野内」,客户端就从 blob 里把物品铺出来。
/// 注入器每 700ms 调 <c>SceneItemMgr.SyncSceneItemGridAOIAddByIndex(cx, cz, exceptIds, sectorIdList,
/// playerStandardIndex)</c>;服务端等价物 = 推 <b>SyncDestructibleGridAOIDecrease(68969593)</b>,
/// 接收端 <c>GameSceneToClientImpl</c> 三连展开正是 remove → increase → AddDecrease(→AddByIndex)。
///
/// ⛑️⛑️ 必须用 68969593,不是 68934309:68934309 的 args 只装得下 <c>aoiInfo</c> 一个字段
/// (dump.cs TypeDefIndex 35754),它的接收端是记账用的;网格下发只能走 Decrease 孪生
/// (与 gadget 的 68956797 同律)。用错 mid 的表现是「推了、日志干净、一个都不出」。
///
/// 网格→sector 表直接复用 <c>GadgetCellsCatalog4229938</c>(Configs/GadgetCells.json):
/// 那份文件本身就是 AnantaWorldMod 运行时导出的 cell→sector(5116 格,meta 写明
/// "runtime export from 4229938 client"),两个家族用的是同一张烘焙表。
/// </summary>
internal sealed partial class GameRouter
{
private const string SceneItemGridKey = "client4229938-scene-item-grid";
/// <summary>40 m 世界网格边长 —— 与客户端 SceneItemMgr 的 CELL_SIZE 一致。</summary>
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;
/// <summary>已经下发过的网格;只在刷新 / 心跳 / 跨格重声明时重发。</summary>
internal HashSet<(int, int)> Sent { get; } = new();
internal int LastCellX;
internal int LastCellZ;
internal bool HasCell;
internal bool LoadedSent;
/// <summary>可破坏物总开关 SyncDestructibleSceneAOIActive(68418115) 本会话是否已推。</summary>
internal bool ActiveSwitchSent;
/// <summary>念动力测试盒本会话是否已摆过(只摆一次,见 SpawnTestBox)。</summary>
internal bool TestBoxSpawned;
/// <summary>
/// ★★★ 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 的标注),由 <see cref="ToGridDestructibleInfo"/> 归一。
///
/// 🆕 2026-09-30:本通道改**严格增量**后,这张表实际上**退化为空转** —— 因为「重推整窗
/// ⇒ addInfos 空 ⇒ 客户端按 blob 重建 ⇒ 抹掉服务端合成物」这条链路的**前半段不再发生**
/// (已推过的格子根本不会再发)。气球本体本来就是 GameRouter.Balloon.cs 用
/// SyncDestructibleObjectAddList(68963429) + 自带的 68969593 报文建的,不依赖这里。
/// 表**保留不删**:一旦把 heartbeatMs / refreshMs / resendWindowOnMove 回滚成整窗重推,
/// 它立刻重新生效(否则气球会被抹掉,R93 的病会原样复发)。
/// </summary>
internal Dictionary<ulong, (float X, float Z, object Info)> 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;
}
/// <summary>
/// ★★★ R93(自 donor v2.1「打气球」移植)—— 把「脚本自建的可破坏物」登记为受保护,
/// 直到显式撤下。心跳 / 刷新重声明 AOI 窗口时,这些 id 会进 <c>ExceptIds</c> 并随
/// <c>addInfos</c> 一起重发,从而不被客户端按烘焙 blob 覆盖掉。
/// </summary>
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);
}
/// <summary>★ R93 — 撤下保护(离开气球区 / 小游戏结束时用)。</summary>
internal static void UnprotectDestructibles(TcpSession session, IEnumerable<ulong> ids)
{
var owner = SceneItemGridStateOf(session);
lock (owner.SyncRoot)
foreach (var id in ids)
owner.Protected.Remove(id);
}
/// <summary>世界 ready 钩子:启动场景物品流。幂等;配置关闭时 no-op。</summary>
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));
}
}
/// <summary>断线 / 切场景:停循环。已经铺出来的物品留在原地。</summary>
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;
}
}
/// <summary>已下发网格数(面板 / 调试用)。</summary>
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 时会是玩家走过的全部格子」
// —— <b>错的</b>。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;
}
}
/// <summary>
/// 把 (cx,cz) 周围还没发过的网格按 sector 列表分组下发,一组一条消息。返回发出的格子数。
/// </summary>
private static async Task<int> PushSceneItemGridAsync(
TcpSession session, SceneItemGridSessionState owner, int cx, int cz, byte reason, bool fullResend,
List<SceneMethods.DestructibleInfo4229938>? 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<string, (int[] Sectors, List<(int, int)> 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<SceneMethods.DestructibleInfo4229938>? protectedInfos = null;
List<ulong>? 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<ulong>();
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:不再恒空 —— 真·卸载改由 <c>PushSceneItemGridRemovalAsync</c>
// 单独一条报文下发(先卸后加载)。硬规则 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;
}
/// <summary>
/// ★ donor《content-streaming-implementation》§4 —— 显式的「请卸载这批格子」。
/// 单独一条 68969593:<c>addGridInfo</c> 空表、<c>removeGridInfo</c> 装出界的格子。
/// ⛑ donor §4 提醒 family 差异:68969593 <b>没有</b> extraInfo / activeIds / inactiveIds
/// 三个字段(那是 gadget 侧才有的),别照 GadgetGrid 那份填。
/// ⛑ <c>BuildingId</c> 必须写 int32(值 0),漏写会让整条 payload 位移。
/// </summary>
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<string, (int[] Sectors, List<(int, int)> 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}");
}
}
}
/// <summary>测试盒 InstanceId 的自增源。高位起跳,避免撞上客户端烘焙出来的真实 id。</summary>
private static long _testBoxSeq = 0x4000000000000000;
/// <summary>
/// 念动力探针(硬规则 28):<c>DestructibleGridAOIIncrease.addInfos</c> 可以凭空造一个可抓的
/// SceneItem —— 给 <c>CfgId</c>(SceneitemConfig 里 <c>CanQInteraction=true</c> 的条目)+
/// <c>Position</c> 就够,客户端会实例化成真物件并被 MindPowerMgr 认成 Q 目标。
///
/// 这是 T1-22 里唯一能直接验证「服务端显式摆放」这条路的手段:箱子出现 ⇒ 路通,家具不出
/// 只是缺坐标;箱子都不出 ⇒ 先修通道。
///
/// ⛑️ 只在本会话第一次推送时摆:心跳 / 刷新 / 跨格重声明都会重发整窗,摆第二次会叠箱子。
/// ⛑️ 序列化的坑:<c>DestructibleInfo</c> 的客户端 reader 是多态的
/// (<c>ReadStaticDestructibleInfo</c> 跳转表:0=null / 1=BASE / 2=Dynamic / 3=Static / 4=Task)。
/// 这里先走 <c>UxSerializer</c> 的常规写法;若客户端日志出现
/// <c>deserialize failed ... parameter=DestructibleInfo</c>,就说明缺那个 1=BASE 的标签字节,
/// 需要改成手写裸字节(见 SceneItemGridMethods4229938.cs 里 DestructibleInfo4229938 的注释)。
/// 失败的包只会被客户端丢弃,不会污染场景,所以这个探针可以放心试。
/// </summary>
/// <summary>
/// ★ R93 —— 把登记进 <c>Protected</c> 的可破坏物 DTO 归一到本管线使用的
/// <c>SceneMethods.DestructibleInfo4229938</c>。
///
/// 气球(GameRouter.Balloon.cs)持有的是 <c>BowlingWire</c> 那份 <c>DestructibleInfo</c>
/// —— 它是**真机验证过能建出 hold** 的标注(Marker=1 / BASE),而网格通道这份是同构双胞胎
/// (<c>SceneMethods</c>,字段一致但 UXVector3 用的是 Auto 版)。两份绝不能混用(会静默串位),
/// 所以登记时存 object、心跳时在这里逐字段映射。认不出的类型返回 null(该件不摆)。
/// </summary>
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<SceneMethods.DestructibleInfo4229938> 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<SceneMethods.DestructibleInfo4229938>(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;
}
}