diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ec5f0fe8..784f2d7e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -66,6 +66,20 @@ geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame northbound return. Pseudocode + decomp-artifact notes: `docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`. +**Gate attempt 1 (2026-06-09):** outside-looking-in correct + running distortion gone, +but the walk was sabotaged by a PRE-EXISTING bug the corrective ACE teleport exposed: +the teleport-arrival snap (`GameWindow.cs:4869`) committed a BARE indoor cell id +(`0x0000013F`) from the legacy `PhysicsEngine.Resolve`, which returned low-word-only +cell ids on every computed exit (the L.2e bare-low-byte finding from 2026-05-12). +A bare indoor id wedges the chain: no wall BSP (`GetCellStruct` miss), the #98 +`b3ce505` gate reads "indoor primary" and skips the outdoor object sweep (NO object +collision anywhere), and the pick can never re-resolve a malformed id (probe log: +2 `[cell-transit]` lines all session, both teleports). Follow-up fix: `Resolve` now +returns the matched landblock's full prefixed id on every computed exit; the +unmasked `CellId < 0x100` test assertion that codified the bare behavior fixed. +Prefix survival before this was a race artifact — `Resolve` only preserved the full +id when the landblock wasn't streamed in yet (passthrough exit). + **Description:** Walking outdoors across a landblock boundary does NOT update the player's outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock, indefinitely. Every downstream consumer degrades: entering any building in the new landblock diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 4cc5005a..95553b6c 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -463,7 +463,14 @@ public sealed class PhysicsEngine var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f); // Find the landblock this candidate position falls in. + // #106 follow-up (2026-06-09): capture its high-16 prefix — every + // computed cell id below is returned FULL (lbPrefix | low). The old + // bare-low-word returns wedged the membership chain whenever a caller + // committed them (the teleport-arrival snap wrote 0x0000013F: an + // unresolvable indoor id → no wall BSP, #98 gate reads "indoor + // primary" and kills the outdoor object sweep → no collision at all). LandblockPhysics? physics = null; + uint lbPrefix = 0u; foreach (var kvp in _landblocks) { var lb = kvp.Value; @@ -472,6 +479,7 @@ public sealed class PhysicsEngine if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f) { physics = lb; + lbPrefix = kvp.Key & 0xFFFF0000u; break; } } @@ -637,7 +645,7 @@ public sealed class PhysicsEngine return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, targetZ), - targetCellId, + lbPrefix | (targetCellId & 0xFFFFu), IsOnGround: true); } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 9ee570c7..f6850e6e 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -365,12 +365,65 @@ public class PhysicsEngineTests new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f), stepUpHeight: 10f); - // Should transition back to outdoor. - Assert.True(result.CellId < 0x0100u); + // Should transition back to outdoor. (#106 follow-up: masked compare — + // Resolve now returns FULL prefixed cell ids; the old unmasked + // `CellId < 0x0100` assertion codified the bare-low-word bug.) + Assert.True((result.CellId & 0xFFFFu) < 0x0100u); + Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } + /// + /// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by + /// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor + /// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole + /// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads + /// "indoor primary" and skips the outdoor object sweep (no collision with + /// anything), and FindCellSet can never re-resolve a malformed id. Resolve + /// MUST return the matched landblock's full 32-bit cell id on every + /// computed exit — the same convention its own inputs use. + /// + [Fact] + public void Resolve_IndoorStay_ReturnsFullPrefixedCellId() + { + var engine = new PhysicsEngine(); + var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + + var cellVerts = new Dictionary + { + [0] = new(40f, 40f, 55f), + [1] = new(60f, 40f, 55f), + [2] = new(60f, 60f, 55f), + [3] = new(40f, 60f, 55f), + }; + var cellPolys = new List> { new() { 0, 1, 2, 3 } }; + var cell = new CellSurface(0x0100, cellVerts, cellPolys); + + engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + // The teleport shape: full indoor cell id in, zero delta (pure snap). + var result = engine.Resolve( + new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero, + stepUpHeight: 5f); + + Assert.Equal(0xA9B40100u, result.CellId); + } + + [Fact] + public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId() + { + var engine = MakeFlatEngine(terrainZ: 50f); + + var result = engine.Resolve( + new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f), + stepUpHeight: 2f); + + // (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed. + Assert.Equal(0xA9B40025u, result.CellId); + } + [Fact] public void Resolve_NoSurfaceUnderEntity_NotOnGround() {