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

692 lines
34 KiB
C#
Raw 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>
/// 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}");
}
}
}
}