fix(streaming): rebuild the streaming window on a far login-spawn — the cold-spawn "hole"

Root cause (confirmed live via the landblock-load probe): the streaming window
marks every landblock "resident" at bootstrap (MarkResidentFromBootstrap) BEFORE
their async loads land. When a character spawns far from the startup center
(0xA9B4 Holtburg), the login-spawn recenter runs RecenterTo against that stale,
half-loaded window — and RecenterTo trusts _tierResidence, so it never
re-enqueues the old/new window overlap. The overlap band is marked resident but
its loads never completed, leaving a permanent HOLE of landblocks that are never
requested (zero BUILD-NULL — they're simply skipped). The player spawns in the
hole: its landblock never loads, so terrain/NPCs/player never draw — the
"world not loading / invisible / scenery behind" symptom.

Probe evidence (spawn at 0xADAF): the player's column 0xAD loads Y=0xA3-0xAA and
0xB0-0xC0 but is MISSING Y=0xAB-0xAF — and the player is at 0xADAF (Y=0xAF), dead
in the gap. 0xADAFFFFF: never [lb] ADD'd, never BUILD-NULL'd.

Fix: a far login-spawn moves the render origin exactly like an outdoor teleport
(which already calls StreamingController.ForceReloadWindow to drop the stale
window + re-bootstrap fresh around the new origin — GameWindow.cs ~5725). The
login-spawn recenter now flags the same rebuild; because the spawn handler runs
on the network thread and ForceReloadWindow is render-thread-only, the flag is
set there and consumed in OnUpdateFrame's streaming block before the Tick.

Verified live: 0xADAFFFFF now `[lb] ADD entities=216`, full world draws
(flatCount=12807), and the player transitions PENDING(hidden) -> DRAWSET PRESENT
(composes with the RelocateEntity pending-recovery fix, 315af02f). No-op for a
normal Holtburg login (spawn == startup center → guard false). Full suite 4007
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 23:42:59 +02:00
parent 315af02f8a
commit 9b06a9b831

View file

@ -880,6 +880,17 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Net.WorldSession? _liveSession; private AcDream.Core.Net.WorldSession? _liveSession;
private int _liveCenterX; private int _liveCenterX;
private int _liveCenterY; private int _liveCenterY;
// Set by the login-spawn recenter (network thread) when the spawn landblock
// differs from the startup streaming center; consumed on the render thread
// in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far
// login-spawn moves the render origin just like an outdoor teleport, so the
// streaming region — bootstrapped around the stale startup center — must be
// rebuilt fresh around the spawn. Without this, RecenterTo treats the
// old/new window overlap as already-resident (MarkResidentFromBootstrap marks
// residence before the async loads land), leaving a permanent hole of
// resident-but-never-loaded landblocks where the player spawns — the
// cold-spawn "invisible player / world not loading" bug (2026-07-03).
private volatile bool _pendingForceReloadWindow;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
// K-fix1 (2026-04-26): cached at startup so per-frame branches are // K-fix1 (2026-04-26): cached at startup so per-frame branches are
@ -3197,6 +3208,13 @@ public sealed class GameWindow : IDisposable
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX; _liveCenterX = lbX;
_liveCenterY = lbY; _liveCenterY = lbY;
// The origin jumped — the streaming region is still bootstrapped
// around the stale startup center with residence marked before its
// async loads landed. Flag a full window rebuild (consumed on the
// render thread in OnUpdateFrame) so the region re-bootstraps fresh
// around the spawn, re-loading the landblocks RecenterTo would
// otherwise skip as already-resident — the cold-spawn streaming hole.
_pendingForceReloadWindow = true;
} }
// #135: the instant we know the player spawned into a SEALED dungeon, // #135: the instant we know the player spawned into a SEALED dungeon,
@ -7826,6 +7844,17 @@ public sealed class GameWindow : IDisposable
observerCx = (int)((cellLb >> 8) & 0xFFu); observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 0xFFu); observerCy = (int)(cellLb & 0xFFu);
} }
// Consume the login-spawn far-recenter flag (network thread → render
// thread): drop the stale startup window + null the region so this
// Tick re-bootstraps the whole window fresh around the spawn origin.
// Same mechanism the outdoor-teleport path uses (line ~5725). Fixes
// the cold-spawn streaming hole (resident-but-never-loaded landblocks
// the RecenterTo diff skipped as already-resident).
if (_pendingForceReloadWindow)
{
_pendingForceReloadWindow = false;
_streamingController.ForceReloadWindow();
}
_streamingController.Tick(observerCx, observerCy, insideDungeon); _streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks // Re-inject persistent entities rescued from unloaded landblocks