diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5a7b4a4f..11c6965d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -969,6 +969,15 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Net.WorldSession? _liveSession;
private int _liveCenterX;
private int _liveCenterY;
+ // #192 (2026-07-09): true once the player's OWN spawn CreateObject has been
+ // received and processed — i.e. _liveCenterX/Y reflects a real,
+ // server-confirmed position, not the Holtburg startup placeholder. Gates
+ // streaming via StreamingReadinessGate.ShouldStream so no landblock is ever
+ // built around a still-guessed center in live mode. See
+ // AcDream.Core.World.StreamingReadinessGate for the full mechanism this
+ // closes (stale-centered geometry built during the InWorld-but-not-yet-
+ // located window, applied late once its build finished).
+ private bool _liveCenterKnown;
// 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
@@ -3306,6 +3315,14 @@ public sealed class GameWindow : IDisposable
// object spawns never move the center. Idempotent + thrash-free: a
// re-sent CreateObject for the same spawn landblock leaves the center
// already-equal, so the guard is false on every repeat.
+ //
+ // #192: the player's own spawn is the first point we can trust
+ // _liveCenterX/Y as a REAL position rather than the Holtburg startup
+ // placeholder — set this unconditionally (even when no recenter is
+ // needed, e.g. a genuine Holtburg login) so StreamingReadinessGate can
+ // stop gating streaming on session state alone.
+ if (spawn.Guid == _playerServerGuid)
+ _liveCenterKnown = true;
if (spawn.Guid == _playerServerGuid
&& (lbX != _liveCenterX || lbY != _liveCenterY))
{
@@ -8281,8 +8298,16 @@ public sealed class GameWindow : IDisposable
// only.
bool liveInWorld = _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld;
+ // #192: liveInWorld alone used to be sufficient here — but InWorld fires
+ // right after the login handshake, before the player's own spawn
+ // CreateObject (and hence the real center) has arrived. Streaming that
+ // ran in that gap baked landblock offsets from the still-placeholder
+ // _liveCenterX/Y (Holtburg), producing stale-positioned geometry applied
+ // late once its build finished. Require _liveCenterKnown too — see
+ // AcDream.Core.World.StreamingReadinessGate for the full mechanism.
if (_streamingController is not null && _cameraController is not null
- && (!IsLiveModeWaitingForLogin || liveInWorld))
+ && AcDream.Core.World.StreamingReadinessGate.ShouldStream(
+ LiveModeEnabled, _chaseModeEverEntered, liveInWorld, _liveCenterKnown))
{
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
diff --git a/src/AcDream.Core/World/StreamingReadinessGate.cs b/src/AcDream.Core/World/StreamingReadinessGate.cs
new file mode 100644
index 00000000..9030734e
--- /dev/null
+++ b/src/AcDream.Core/World/StreamingReadinessGate.cs
@@ -0,0 +1,51 @@
+namespace AcDream.Core.World;
+
+///
+/// Should the streaming/render pipeline be allowed to run this frame?
+///
+///
+/// Why this exists (#192, 2026-07-09). Login to a non-Holtburg position
+/// sometimes showed stabs/scenery floating in the wrong place. Root cause: the
+/// old gate opened as soon as WorldSession reached InWorld — but
+/// InWorld fires immediately after the login handshake completes
+/// (WorldSession.cs:608), BEFORE the player's own spawn CreateObject
+/// (which carries their real position) has even arrived over the network. Any
+/// landblock streamed in that window baked its world offset from
+/// GameWindow._liveCenterX/Y's startup placeholder (Holtburg, 0xA9B4) — a
+/// real position, not a "not known yet" sentinel — because the streaming worker
+/// reads that field fresh at build time with no way to know it's still a guess.
+/// That stale-baked geometry still got applied once its build finished, landing
+/// wherever the guess put it relative to whatever streamed in afterward using the
+/// corrected center.
+///
+///
+///
+/// The fix is not "pick a better placeholder" — any placeholder racing against
+/// the real answer reproduces the same bug. It's gating on whether the real
+/// position is actually known yet (, set true
+/// exactly when the player's own spawn is processed), independent of session
+/// state. alone is deliberately no longer
+/// sufficient in live mode.
+///
+///
+public static class StreamingReadinessGate
+{
+ /// Whether a live ACE session is configured at all.
+ /// False (offline / fly-camera) always streams — there is no real position to
+ /// wait for, so no race exists.
+ /// Latches true the first time chase mode
+ /// engages and stays true for the rest of the session — well past the login
+ /// race window.
+ /// The live session has completed the login handshake
+ /// (WorldSession.State.InWorld). Necessary but NOT sufficient on its
+ /// own — see class remarks.
+ /// The player's own spawn CreateObject has
+ /// been received and processed, so _liveCenterX/Y reflects a real,
+ /// server-confirmed position rather than the startup placeholder.
+ public static bool ShouldStream(
+ bool liveModeEnabled, bool chaseModeEverEntered, bool liveInWorld, bool liveCenterKnown)
+ {
+ bool isWaitingForLogin = liveModeEnabled && !chaseModeEverEntered;
+ return !isWaitingForLogin || (liveInWorld && liveCenterKnown);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/World/StreamingReadinessGateTests.cs b/tests/AcDream.Core.Tests/World/StreamingReadinessGateTests.cs
new file mode 100644
index 00000000..e3792da0
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/StreamingReadinessGateTests.cs
@@ -0,0 +1,72 @@
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.World;
+
+///
+/// #192 (2026-07-09) — login into a non-Holtburg position sometimes showed
+/// stabs/scenery floating in the wrong place. Root cause: the streaming/render
+/// gate opened as soon as WorldSession reached InWorld
+/// (GameWindow.cs's old !IsLiveModeWaitingForLogin || liveInWorld
+/// condition) — but InWorld fires immediately after the login handshake
+/// (WorldSession.cs:608), BEFORE the player's own spawn CreateObject
+/// (which carries their real position) has even arrived over the network. Any
+/// landblock streamed in that window baked its world offset from
+/// _liveCenterX/Y's STARTUP placeholder (Holtburg, 0xA9B4) — a real
+/// position, not a "we don't know yet" sentinel — and that stale-baked geometry
+/// still got applied once it finished building, landing wherever the guess put
+/// it relative to whatever streamed in afterward with the corrected center.
+///
+/// The fix isn't "pick a different placeholder" (any placeholder racing against
+/// the real answer reproduces the same bug) — it's gating on whether the real
+/// position is actually known yet, independent of session state.
+///
+public class StreamingReadinessGateTests
+{
+ [Fact]
+ public void ShouldStream_OfflineMode_AlwaysTrue()
+ {
+ // Not live mode at all — no race is possible, no real position to wait for.
+ Assert.True(StreamingReadinessGate.ShouldStream(
+ liveModeEnabled: false, chaseModeEverEntered: false, liveInWorld: false, liveCenterKnown: false));
+ }
+
+ [Fact]
+ public void ShouldStream_ChaseModeEverEntered_AlwaysTrueRegardlessOfCenterKnown()
+ {
+ // Once chase mode has engaged even once, the gate latches open for the
+ // rest of the session (matches the pre-existing IsLiveModeWaitingForLogin
+ // latch semantics) — this is well past the login race window.
+ Assert.True(StreamingReadinessGate.ShouldStream(
+ liveModeEnabled: true, chaseModeEverEntered: true, liveInWorld: true, liveCenterKnown: false));
+ }
+
+ [Fact]
+ public void ShouldStream_LiveModeNotYetInWorld_False()
+ {
+ Assert.False(StreamingReadinessGate.ShouldStream(
+ liveModeEnabled: true, chaseModeEverEntered: false, liveInWorld: false, liveCenterKnown: false));
+ }
+
+ [Fact]
+ public void ShouldStream_LiveModeInWorldButCenterUnknown_False()
+ {
+ // THE REGRESSION TEST. Under the old code, liveInWorld alone opened the
+ // gate here — this is exactly the window where InWorld has fired but the
+ // player's own spawn CreateObject (and hence the real center) hasn't
+ // arrived yet. Must stay closed.
+ Assert.False(StreamingReadinessGate.ShouldStream(
+ liveModeEnabled: true, chaseModeEverEntered: false, liveInWorld: true, liveCenterKnown: false));
+ }
+
+ [Fact]
+ public void ShouldStream_LiveModeInWorldAndCenterKnown_True()
+ {
+ // Once the real position is confirmed, stream even though chase mode
+ // hasn't engaged yet — this preserves the #106 gate-3 fix (auto-entry
+ // waits for terrain under the spawn; terrain streaming must not wait for
+ // auto-entry in turn, or the two deadlock each other).
+ Assert.True(StreamingReadinessGate.ShouldStream(
+ liveModeEnabled: true, chaseModeEverEntered: false, liveInWorld: true, liveCenterKnown: true));
+ }
+}