diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dad65f69..8f0951af 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -72,7 +72,7 @@ reconciling #281's 43 test failures. lit statics are unchanged. The session's log corroborates it — 9 completed world reveals, 58 reveal events all `failures=0`, zero unhandled exceptions, and a graceful exit. -- **#283 — OPEN — Runtime's world frame and App's render origin rebase at +- **#283 — DONE (2026-08-03, proven unreachable) — Runtime's world frame and App's render origin rebase at different moments during a teleport.** `670f307c` gave Runtime its own world frame (`RuntimePhysicsState.ObserveLocalWorldFrame`), which rebases the instant an accepted Position carries `TeleportAdvanced`. App's @@ -87,6 +87,25 @@ reconciling #281's 43 test failures. gate may or may not exclude Create during that window, and #280 says other work does continue arriving through it. Two owners of one fact; the campaign answer is that Runtime owns the frame and App projects it. Route-3 adjacent. + **Resolved 2026-08-03 as UNREACHABLE, not restructured.** The plan's first + step was to prove or disprove reachability before moving ownership. + `ACDREAM_PROBE_WORLD_FRAME=1` (`PhysicsDiagnostics.ProbeWorldFrameEnabled`) + compared both owners at + `DatLiveEntityProjectionMaterializer`'s landblock→world conversion — the + App-side counterpart of `TryGetWorldFrameOffset`. A connected Release + session recorded **zero** disagreements across 11 completed reveals and six + destination landblocks (`0x0904`, `0x1134`, `0x3032`, `0x8763`, `0xA9B4`, + `0xF682`) spanning ~45 km — a gap of even one frame would have printed an + offset in the tens of thousands of metres. Cause: `BeginOriginRecenter` + detaches EVERY resident landblock before the new origin is adopted, which + serializes the two rebases so no conversion can observe the gap. + Ownership is therefore left alone (restructuring on a disproven hypothesis + would have been churn). Instead + `LiveWorldOriginState.EnsureAgreesWithRuntimeFrame` is a permanent terminal + invariant at that conversion, turning a silent 192 m-multiple misplacement + into a loud failure if a future change ever reopens the window; six focused + tests pin it, including the cross-world portal case. The probe flag now + emits a verbose per-conversion agreement trace for future investigation. - **#284 — DONE (2026-08-03) — a placement that cannot resolve parks forever with no diagnostic.** A first-entry placement whose world frame is absent returns `RetrySetupUnavailable` (`RuntimeSetPositionState.PrepareMover:1535-1543`) diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index e5f1abe3..8887903f 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -163,6 +163,12 @@ internal sealed class DatLiveEntityProjectionMaterializer CreateObject.ServerPosition position = canonicalSpawn.Position.Value; int lbX = (int)((position.LandblockId >> 24) & 0xFFu); int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + // #283: permanent invariant - the two world-frame owners must agree + // before their conversions can be mixed. Proven unreachable by the + // 2026-08-03 probe run; this keeps it that way. + _origin.EnsureAgreesWithRuntimeFrame( + _runtime.Physics.WorldFrameCenterLandblockId, + position.LandblockId); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeWorldFrameEnabled) ProbeWorldFrameAgreement(position.LandblockId); var worldOrigin = new Vector3( @@ -1195,12 +1201,12 @@ internal sealed class DatLiveEntityProjectionMaterializer /// must use the SAME centre landblock or the entity lands a multiple of /// 192 m from the geometry around it. /// - /// Runtime rebases on the accepted teleport Position; App's - /// LiveWorldOriginState rebases only after old-window retirement - /// completes. Emits one line per DISAGREEMENT — silence across a portal - /// run is the evidence that the window is unreachable in practice, and - /// that #283 can be closed as a permanent invariant rather than an - /// ownership restructure. Measurement only; it never gates placement. + /// Disagreement is now a terminal invariant + /// (LiveWorldOriginState.EnsureAgreesWithRuntimeFrame, checked + /// immediately before this), so anything reaching here has already + /// agreed. This verbose trace records the centres both owners used for + /// each conversion, which is what you want when investigating a + /// placement that looks displaced but is not a frame disagreement. /// private void ProbeWorldFrameAgreement(uint landblockId) { @@ -1210,15 +1216,7 @@ internal sealed class DatLiveEntityProjectionMaterializer int runtimeCenterX = (int)((runtimeCenter >> 24) & 0xFFu); int runtimeCenterY = (int)((runtimeCenter >> 16) & 0xFFu); - if (runtimeCenterX == _origin.CenterX - && runtimeCenterY == _origin.CenterY) - { - return; - } - - float deltaX = (runtimeCenterX - _origin.CenterX) * 192f; - float deltaY = (runtimeCenterY - _origin.CenterY) * 192f; Console.WriteLine(System.FormattableString.Invariant( - $"[world-frame] DISAGREE runtime=({runtimeCenterX},{runtimeCenterY}) app=({_origin.CenterX},{_origin.CenterY}) offsetDelta=({deltaX:F0}m,{deltaY:F0}m) projecting=0x{landblockId:X8}")); + $"[world-frame] agree centre=({runtimeCenterX},{runtimeCenterY}) projecting=0x{landblockId:X8}")); } } diff --git a/src/AcDream.App/World/LiveWorldOriginState.cs b/src/AcDream.App/World/LiveWorldOriginState.cs index 551bb45c..e74290e7 100644 --- a/src/AcDream.App/World/LiveWorldOriginState.cs +++ b/src/AcDream.App/World/LiveWorldOriginState.cs @@ -44,6 +44,48 @@ internal sealed class LiveWorldOriginState public (int X, int Y) GetCenter() => (CenterX, CenterY); + /// + /// #283: asserts that this owner and Runtime's world frame agree on which + /// landblock is (0,0). Both convert landblock-local network origins into + /// the streamed world frame — this one for presentation and streaming, + /// RuntimePhysicsState.TryGetWorldFrameOffset for placement — so a + /// disagreement of one landblock places an entity 192 m from the geometry + /// around it. + /// + /// They rebase on different edges: Runtime the instant an accepted + /// Position carries TeleportAdvanced, this owner only once + /// StreamingOriginRecenterCoordinator observes old-window + /// retirement completion. A 2026-08-03 connected run + /// (ACDREAM_PROBE_WORLD_FRAME=1, 11 reveals across six landblocks + /// spanning ~45 km) recorded ZERO disagreements: detaching every resident + /// landblock before adopting the new origin serializes the two rebases, + /// so no conversion can observe the gap. This is the permanent guard that + /// keeps that true — it converts a silent 192 m-multiple misplacement into + /// a loud failure if a future change ever reopens the window. + /// + public void EnsureAgreesWithRuntimeFrame( + uint runtimeCenterLandblockId, + uint projectingLandblockId) + { + // Before either owner is established there is nothing to agree on; + // the placement itself is gated separately (#284). + if (!IsKnown || runtimeCenterLandblockId == 0u) + return; + + int runtimeCenterX = (int)((runtimeCenterLandblockId >> 24) & 0xFFu); + int runtimeCenterY = (int)((runtimeCenterLandblockId >> 16) & 0xFFu); + if (runtimeCenterX == CenterX && runtimeCenterY == CenterY) + return; + + throw new InvalidOperationException( + "World-frame owners disagree: Runtime centre " + + $"({runtimeCenterX},{runtimeCenterY}) vs streamed origin " + + $"({CenterX},{CenterY}) while projecting landblock " + + $"0x{projectingLandblockId:X8}. That offsets the entity by " + + $"({(runtimeCenterX - CenterX) * 192f:F0}m," + + $"{(runtimeCenterY - CenterY) * 192f:F0}m) from its geometry."); + } + /// /// Converts the streamed render frame back into retail's landblock-local /// physics frame at the one placement seed boundary. diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index c42d4ebf..950f2c8c 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -71,12 +71,19 @@ public static class PhysicsDiagnostics /// anything converted in that gap lands a multiple of 192 m from the /// geometry App is building. /// - /// When true, every projected placement compares Runtime's frame - /// centre against App's origin centre and emits one - /// [world-frame] line per DISAGREEMENT only — silence means the - /// window is never observed. This exists to prove or disprove - /// reachability before any ownership is restructured; it is a - /// measurement, never a gate. Initial state from + /// A 2026-08-03 connected run answered that question: 11 reveals + /// across six landblocks spanning ~45 km recorded ZERO disagreements, + /// because the recenter detaches every resident landblock before adopting + /// the new origin and so serializes the two rebases. Disagreement is now + /// a terminal invariant + /// (LiveWorldOriginState.EnsureAgreesWithRuntimeFrame) rather than + /// something to observe. + /// + /// When true, this now emits one verbose [world-frame] agree + /// line per projected conversion recording the centre both owners used — + /// what you want when investigating a placement that looks displaced but + /// is NOT a frame disagreement. Measurement only; it never gates + /// placement. Initial state from /// ACDREAM_PROBE_WORLD_FRAME=1. /// public static bool ProbeWorldFrameEnabled { get; set; } = diff --git a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs index b2298e78..722c0a36 100644 --- a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs +++ b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs @@ -73,4 +73,99 @@ public sealed class LiveWorldOriginStateTests Assert.Equal(new Vector3(8f, 84f, 5f), local); } + + // #283: acdream has TWO owners that convert a landblock-local network + // origin into the streamed world frame - this one for presentation and + // streaming, RuntimePhysicsState.TryGetWorldFrameOffset for placement. + // They rebase on different edges (Runtime on the accepted teleport + // Position; this owner only once StreamingOriginRecenterCoordinator + // observes old-window retirement), so a one-landblock disagreement places + // an entity 192 m from the geometry around it - the same failure family + // as the zero-offset bug 670f307c fixed, with a wrong origin rather than + // a missing one. + // + // A 2026-08-03 connected run with ACDREAM_PROBE_WORLD_FRAME=1 (11 reveals + // across six landblocks spanning ~45 km) recorded ZERO disagreements: + // detaching every resident landblock before adopting the new origin + // serializes the two rebases. These pin the permanent guard. + + [Fact] + public void AgreeingWorldFrameOwners_Pass() + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(0xA9, 0xB6)); + + state.EnsureAgreesWithRuntimeFrame(0xA9B6FFFFu, 0xA9B60001u); + } + + [Fact] + public void BeforeEitherWorldFrameOwnerIsEstablished_ThereIsNothingToAgreeOn() + { + var state = new LiveWorldOriginState(); + + // No accepted origin yet; placement is gated separately (#284). + state.EnsureAgreesWithRuntimeFrame(0xA9B6FFFFu, 0xA9B60001u); + + // An accepted origin with no Runtime frame yet is equally not a + // disagreement. + Assert.True(state.TryInitialize(0xA9, 0xB6)); + state.EnsureAgreesWithRuntimeFrame(0u, 0xA9B60001u); + } + + [Theory] + [InlineData(0xAAB6FFFFu, "192m")] + [InlineData(0xA8B6FFFFu, "-192m")] + [InlineData(0xA9B7FFFFu, "192m")] + [InlineData(0xA9B5FFFFu, "-192m")] + public void DisagreeingWorldFrameOwners_FailLoudlyWithTheOffsetInMetres( + uint runtimeCenter, + string expectedOffset) + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(0xA9, 0xB6)); + + InvalidOperationException error = + Assert.Throws(() => + state.EnsureAgreesWithRuntimeFrame( + runtimeCenter, + 0xA9B60001u)); + + Assert.Contains("World-frame owners disagree", error.Message); + Assert.Contains(expectedOffset, error.Message); + Assert.Contains("0xA9B60001", error.Message); + } + + /// + /// The exact case the guard exists for: Runtime has rebased on a portal + /// destination while the streamed origin still holds the departure + /// landblock. A cross-world portal makes the offset enormous rather than + /// subtle - 0xF6 - 0x09 = 237 landblocks east = 45,504 m. + /// + [Fact] + public void ARuntimeRebaseAheadOfTheStreamedOrigin_IsCaught() + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(0x09, 0x04)); + + InvalidOperationException error = + Assert.Throws(() => + state.EnsureAgreesWithRuntimeFrame( + 0xF682FFFFu, + 0xF6820033u)); + + Assert.Contains("45504m", error.Message); + } + + [Fact] + public void AfterBothRebaseToTheSameDestination_TheyAgreeAgain() + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(0x09, 0x04)); + + // The streamed origin adopts the destination once old-window + // retirement completes; Runtime is already there. + state.Recenter(0xF6, 0x82); + + state.EnsureAgreesWithRuntimeFrame(0xF682FFFFu, 0xF6820033u); + } }