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)); + } +}