691 lines
34 KiB
C#
691 lines
34 KiB
C#
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 距离 > 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}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|