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

739 lines
36 KiB
C#
Raw Normal View History

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;
}
}