Files

691 lines
34 KiB
C#
Raw Permalink 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>
/// World props WITHOUT the AnantaGadgetLoader exe — the server-side half of what that tool does
/// by frida injection.
///
/// The loader calls <c>GadgetMgr.SyncGadgetGridAOIAddByIndex(cx, cz, exceptIds, sectorIds,
/// gridIndex)</c> 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
/// <b>SyncGadgetGridAOIDecrease(68956797)</b> — the only grid-AOI method id that exists, and it
/// carries both directions (add cells in <c>addGridInfo</c>, remove in <c>removeGridInfo</c>).
/// ⛔ 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:
/// · <b>sector pairing matters</b> — one message carries ONE sector list, so cells are grouped
/// by identical sector list (<c>sectorIdList=[0]</c> is outdoor only; interior furniture lives
/// in hashed sectors from GadgetCells.json);
/// · <b>never spam</b> — 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.
/// </summary>
internal sealed partial class GameRouter
{
private const string GadgetGridKey = "client4229938-gadget-grid";
/// <summary>World grid cell edge in metres — the loader's CELL_SIZE and the client's own grid.</summary>
private const int GadgetCellSize = 40;
private const byte AoiReasonLoaded = 1;
private const byte AoiReasonAoiMove = 2;
/// <summary>
/// ★ donor《content-streaming-implementation》§3 的窗口差分(2026-10-01 落地)。
/// <c>prevLoaded</c> = 客户端当前持有的格子;<c>window</c> = 本轮要展示的正方形窗口。
/// <code>
/// added = window − prevLoaded // 新格子 → 见到才加载
/// keep = prevLoaded 里仍落在卸载边界内(Chebyshev ≤ unloadRadius)的那些(保留环)
/// removed = prevLoaded − keep // 真·出界 → 显式下发卸载
/// next = window ∪ keep // 成为新的 prevLoaded
/// </code>
/// ⛑ <c>retainVisitedCells=false</c> ⇒ 没有保留环,出窗口即卸载(等价于 unload = radius)。
/// ⛑ 窗口与卸载边界都用 <b>Chebyshev</b>(正方形),与 §2 的 (2r+1)² 定义一致,别改用曼哈顿。
/// </summary>
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);
}
/// <summary>
/// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳时两个窗口根本不相交
/// ⇒ 必须 Init 全 re-seed(清空声明过的格子),不能只补差集。
/// 2026-10-01 起用它解决「owner.Sent 只增不减」这一条:以前每次远跳都会留下一份
/// 玩家已经离开的格子,永远不会消失,把卸载这件事全推给客户端自己去猜。
/// ⛑ 判据 = 格子 Chebyshev 距离 &gt; unloadRadius(窗口不相交,donor §3 原文)。
/// </summary>
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;
/// <summary>Cells already handed to the client. A cell is re-sent only on refresh.</summary>
internal HashSet<(int, int)> Sent { get; } = new();
internal int LastCellX;
internal int LastCellZ;
internal bool HasCell;
internal bool LoadedSent;
/// <summary>
/// Scene AOI switch (68418115) already pushed for this session. Research lever only —
/// gated by <c>SceneAoiSwitchEnabled</c>, which defaults to false.
/// </summary>
internal bool SceneAoiSwitchSent;
internal long LastPushMs;
internal long ReadyAtMs;
internal int Pushes;
internal int CellsSent;
/// <summary>首次成功整窗推送的时刻(Environment.TickCount64)。入世界守卫的计时基准。</summary>
internal long FirstPushMs;
/// <summary>
/// ★ 2026-10-04「入世界守卫」是否已经做过。每会话只做一次;
/// 远跳 reseed(ReseedOnFarJump)已经替我们清过窗时也算完成,避免重复砸卸载报文。
/// </summary>
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;
}
/// <summary>World-ready hook: start the grid stream. Idempotent; no-op when disabled.</summary>
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));
}
}
/// <summary>Disconnect / scene switch: stop the loop. Props already out stay out.</summary>
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);
}
/// <summary>Cells handed to the client so far. Panel/debug display only.</summary>
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_<IndoorId>):第五条通道,之前服务端完全没推过。
// 玩家走近某个 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;
}
}
/// <summary>
/// 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(差额补发),<c>removeGridInfo</c> 恒为空 ⇒ 走过的格子永远被声明、
/// 客户端永不卸载(这正是 2026-09-27 §10.9.3 内存项第 3 条)。现在走出
/// <c>unloadRadiusCells</c> 边界的格子会被显式下发卸载。
/// </summary>
private static async Task<int> 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<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);
}
// 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:不再恒空。真·卸载改由 <c>PushGadgetGridRemovalAsync</c>
// 单独一条报文下发(先卸后加载),硬规则 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;
}
/// <summary>
/// ★ donor《content-streaming-implementation》§4 —— 显式的「请卸载这批格子」。
/// 单独一条 68956797:<c>addGridInfo</c> 空表、<c>removeGridInfo</c> 装出界的格子。
/// ⛑ <c>BuildingId</c> 必须写 int32(值 0)—— donor §6 第 2 条:漏写会让整条 payload 错位。
/// ⛑ sector 仍然要带:一条报文一个 sector 列表,和加载侧同律分组。
/// </summary>
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<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.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}");
}
}
}
}