diff --git a/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md b/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md new file mode 100644 index 0000000..ed74b83 --- /dev/null +++ b/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md @@ -0,0 +1,215 @@ +# Door collision — apparatus replay shipped, root cause identified +2026-05-24 (continuation of the door-collision investigation) + +> **SUPERSEDED 2026-05-25** by +> [`docs/research/2026-05-25-door-bug-partial-fix-shipped.md`](2026-05-25-door-bug-partial-fix-shipped.md). +> The root-cause analysis here was correct in direction +> (cell-portal traversal is upstream of BSP query) but missed the +> specific bug: `CellTransit.AddAllOutsideCells` silently failed for +> landblock-local sphere coords (production's convention) because it +> subtracted an absolute-world `lbXf` offset. Diagnosis + fix in the +> 2026-05-25 doc. + +## TL;DR + +The trajectory-replay apparatus is **wired and useful**. Run the diagnostic +test for the failing tick and the engine's full `[step-walk]` trace +prints, naming the divergence per-field. + +**The bug: `CellTransit.FindCellSet` does not surface outdoor cell +`0xA9B40029` (where the door is registered) from indoor primary cell +`0xA9B40150`.** With issue #98's indoor-cell gate on the outdoor radial +sweep, the door is therefore invisible to `GetNearbyObjects` and the +BSP slab is never tested. The player walks through unimpeded. + +Cn=(0,−1,0) from the harness is **not the door** — it's the seeded +walkable polygon's south edge being treated as a wall when the sphere +falls off it. The harness reproduces production's "door not queried" +behavior, just with an apparatus artifact in place of clean walkthrough. + +## What was shipped + +1. **Live capture** (`door-walkthrough.jsonl`, 24,310 records ≈ 45 MB). + The capture was driven via `ACDREAM_CAPTURE_RESOLVE` + the existing + `[entity-source]` + `[bsp-test]` probes. **One record per + `PhysicsEngine.ResolveWithTransition` call** with full + `PhysicsBody` snapshots before/after. + +2. **Fixture extraction** + ([tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl](../../tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl), 4 KB). + Two representative ticks pulled from the JSONL: + - **Tick 13558** — the walkthrough. Player at (132.36, 16.81, 94) in + **indoor cell 0xA9B40150**, target (132.43, 17.20, 94). Live + result.Position = target with `collisionNormalValid = false`. Door + centered at world XY (132.57, 16.99), BSP radius 1.975, state + `0x00010008` = `PERSISTENT_PS | 0x8` (NO `ETHEREAL_PS = 0x4` → + **CLOSED**). + - **Tick 22760** — the working block. Player at (133.14, 18.02, 94) + in **outdoor cell 0xA9B40029**, target (133.10, 17.60, 94). Live + blocks at Y=18.018 with cn=(0, +1, 0). Same door, different + primary cell type. + +3. **Replay harness** + ([DoorBugTrajectoryReplayTests.cs](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs)): + loads tick fixtures, hydrates door GfxObj `0x010044B5` from real dat + (`DatCollection.Get`), registers a synthetic door via + `ShadowObjectRegistry.RegisterMultiPart` at the captured BSP world + center (`(132.57, 16.99, 95.36)`) with `cellScope=0u` (mirrors + production registration at + [GameWindow.cs:3158-3167](../../src/AcDream.App/Rendering/GameWindow.cs#L3158)). + `AssertCallMatchesCapture` replays the call and prints the first + per-field divergence. Diagnostic variant enables every + `PhysicsDiagnostics.Probe*Enabled` and dumps the full engine trace. + +## Chronology (from `door-walkthrough.launch.log`) + +Confirmed the door state at the time of every walkthrough: + +| Log line | Event | +|---|---| +| 10796 | `[setstate]` door state → `0x0001000C` (PERSISTENT + ETHEREAL = OPEN) | +| 10993 | `[setstate]` door state → `0x00010008` (PERSISTENT, NOT ethereal = CLOSED) | +| 10995–11071 | First and last `[bsp-test]` line on door 0x000F4246. All `state=0x00010008` | + +So every `[bsp-test]` hit on the door, and every walkthrough event in +the JSONL, is against the **closed** door. The bug is real, not an +ETHEREAL pass-through. + +## What the diagnostic test prints (tick 13558) + +``` +=== Replay tick 13558 (the walkthrough) === +[step-walk] site=find-start cur=(132.36,16.81,94) ... walkPoly=True +[step-walk-adjust] branch=into-plane input=(0.07,0.39,0.00) output=(0.07,0.39,0.00) zGain=0 +[step-walk] site=before-insert ... delta=(0.0744,0.3928,0) cell=0xA9B40150 ... walkPoly=True +[step-walk] site=stepdown-enter ... delta=(0.0744,0.3928,0) stepDown=True walkableZ=0.6642 +[step-walk] site=stepdown-after-offset ... delta=(0.0744,0.3928,-0.75) ... walkPoly=True +... (probes down by 0.75, then 1.5; all OK; walkPoly=True) +[step-walk] site=stepdown-enter ... delta=(0.0744,0.0000,0) ... hit=(0,-1,0) walkPoly=False +... (probes down again; hit stays (0,-1,0); walkPoly=False throughout) +[step-walk] site=after-insert state=Collided ... hit=(0,-1,0) walkPoly=False +[step-walk] site=after-validate state=OK ... position back to input +[resolve] in=(132.360,16.811,94) cell=0xA9B40150 tgt=(132.435,17.204,94) + out=(132.360,16.811,94) cell=0xA9B40150 ok=True + hit=yes n=(0,-1,0) walkable=True +=== Harness: pos=(132.36,16.81,94) cn=(0,-1,0) cnValid=True onGround=True cell=0xA9B40150 +=== Live: pos=(132.43,17.20,94) cn=(0,0,0) cnValid=False onGround=True cell=0xA9B40150 +``` + +**No `[bsp-test]` line fires.** The door's BSP is never queried. The +hit `(0, -1, 0)` is the engine's "sliding off the south edge of the +seeded walkable polygon" response — not a door collision. + +This matches production: at indoor primary cell `0xA9B40150`, +`GetNearbyObjects` returns ZERO shadows because: + +1. The captured `cellId` low-nibble `0x150 >= 0x100` → indoor → + issue #98's gate at + [ShadowObjectRegistry.cs:480](../../src/AcDream.Core/Physics/ShadowObjectRegistry.cs#L480) + skips the outdoor radial sweep. +2. `portalReachableCells` (built by `CellTransit.FindCellSet`) lacks + outdoor cell `0xA9B40029`. In the harness, this is because we + register no cell fixture for `0xA9B40150` and the indoor branch at + [CellTransit.cs:403-407](../../src/AcDream.Core/Physics/CellTransit.cs#L403) + early-returns with empty candidates. **In production**, the cell + IS in cache but the traversal still doesn't produce `0xA9B40029` — + the cell's exit portal (`OtherCellId=0xFFFF`) either doesn't fire + `exitOutside=true` at the sphere's position, or `AddAllOutsideCells` + isn't computing the right outdoor cell. + +## Next investigation move + +**Dump cell `0xA9B40150` from the dat and inspect its portal list.** +Two ways: + +a) **Dat-direct read in a test** (preferred — no live launch). Pattern + from + [DoorSetupGfxObjInspectionTests](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs): + `dats.Get(0xA9B40150u)`, then iterate + `envCell.CellPortals` and print each portal's `OtherCellId`, + `PolygonId`, `Flags`. If no portal with `OtherCellId == 0xFFFF`, + `exitOutside` can never be true → bug is in the cell's portal-graph + loading (or the cottage doesn't connect via 0xFFFF exit portals; + it might use the building-shell path via + `BuildingPhysics.CheckBuildingTransit` instead). + +b) **Live `ACDREAM_DUMP_CELLS=0xA9B40150,0xA9B4013F,0xA9B40154`** — + another launch cycle. Less preferred; we already have what we need + from the dat read. + +The dat-direct read can be a new test method in +`DoorSetupGfxObjInspectionTests` (it's the natural home for this +class of dat-introspection checks). + +## What NOT to do next + +1. **Don't speculate on the fix.** We have the right replay apparatus + now; the next move is **read the dat** to determine the cell's actual + portal structure. Then we'll know whether the bug is in the dat + data, the portal loading, the exit-portal detection in + `FindTransitCellsSphere`, or `AddAllOutsideCells`'s grid math. + +2. **Don't modify the replay test to mask the walkable-polygon edge + artifact.** The artifact is harmless (it documents that, given a + single isolated walkable poly, the engine treats its boundary as a + wall — true regardless of the door bug). The interesting finding is + "no `[bsp-test]` line"; the edge artifact just happens to fill the + collision slot. + +3. **Don't re-do the registration shape.** Multi-part registration + + dedup fix + Task 7 wiring are correct. Verified by the harness's + ability to query the door registration (it just isn't reached at + indoor primary cells). + +## Files touched this session + +**Committed:** none yet — pending commit at session end. + +**Uncommitted:** +- `tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl` — + 2 captured ResolveWithTransition records (tick 13558 walkthrough + + tick 22760 outdoor block) +- `tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs` — + apparatus: 2 LiveCompare tests + 1 Diagnostic dump +- `docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md` — + this doc + +## Pickup prompt for the next session + +``` +A6.P4 door bug — apparatus replay shipped. DoorBugTrajectoryReplayTests +loads tick 13558 (walkthrough) and 22760 (block) from a captured fixture +and replays through the engine. Door 0x000F4246 (closed, state=0x00010008, +BSP world (132.57, 16.99, 95.36) radius 1.975) IS registered correctly +in the harness, BUT the engine never queries it from indoor primary cell +0xA9B40150 — no [bsp-test] line fires. Root cause located: +CellTransit.FindCellSet's portal traversal does not surface outdoor cell +0xA9B40029 from indoor cell 0xA9B40150. + + Read docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md + + State both altitudes: + Currently working toward: M1.5 — Indoor world feels right + Current phase: A6.P4 door bug — cell-portal investigation. + Apparatus shipped; next step is to dump cell + 0xA9B40150's portal list (from the dat) and + determine why FindTransitCellsSphere doesn't + add outdoor cell 0xA9B40029 to candidates. + + First move: add a test to DoorSetupGfxObjInspectionTests (or a new + CellPortalDatInspectionTests file) that reads EnvCell 0xA9B40150 from + the real dat and prints every portal's OtherCellId, PolygonId, Flags. + Then read 0xA9B4013F (player's other indoor cell from JSONL) and + 0xA9B40029 (door's outdoor cell) for cross-comparison. The portal + structure will reveal whether cottages use 0xFFFF exit portals + (FindTransitCellsSphere path) or building-shell portals + (CheckBuildingTransit path). If 0xFFFF exit portals exist but + exitOutside isn't firing, the bug is in the sphere-vs-plane test + at CellTransit.cs:99-112. If they don't exist, the building-shell + path is misconfigured for indoor-primary calls. + + DO NOT: + - Modify the replay test to mask the walkable-polygon-edge artifact + - Re-do the registration shape (correct) + - Speculate on the fix without dat evidence +``` diff --git a/docs/research/2026-05-25-door-bug-partial-fix-shipped.md b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md new file mode 100644 index 0000000..9fe5310 --- /dev/null +++ b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md @@ -0,0 +1,171 @@ +# Door bug — partial fix shipped (cell visibility), inside-out asymmetric collision remains +2026-05-25 + +## TL;DR + +**Major root cause closed.** `CellTransit.AddAllOutsideCells` was +silently failing for every production caller because it assumed sphere +positions were in absolute world coordinates (subtracting the +landblock's "absolute" world origin `lbXf = 0xA9 * 192 = 32448`), while +production has used landblock-local coordinates since Phase A.1 +(streaming-center landblock at world origin → `lbOffset = (0, 0)`). +For outdoor primary cells the bug was masked by `GetNearbyObjects`'s +radial sweep. For indoor primary cells (where issue #98's gate skips +the outdoor sweep), it meant **outdoor cells were never added to +`portalReachableCells`** → cottage door's outdoor cell `0xA9B40029` +invisible from indoor cell `0xA9B40150` → door's BSP never queried +→ player walked through. + +**Outside→inside now blocks correctly. Inside→outside REMAINS BROKEN +asymmetrically.** Body partially intersects the door, slides through +visibly. Not retail-faithful. This is a SEPARATE bug in +BSP-collision-response for two-sided polygons — to investigate next +session. + +## Apparatus shipped + +Full trajectory-replay harness: + +1. **Live capture** (`door-walkthrough.jsonl` from previous session; not + committed): 24,310 records of `PhysicsEngine.ResolveWithTransition` + calls including PhysicsBody snapshots before/after. + +2. **Fixture extraction** + ([tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl](../../tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl), 4 KB): + tick 13558 (the walkthrough) + tick 22760 (the working outdoor block) + as representative records. + +3. **Replay harness** + ([DoorBugTrajectoryReplayTests.cs](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs)): + - `LiveCompare_*` tests load the failing tick + replay through the + harness + diff result fields vs captured live values. + - `FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos` + — direct unit test for cell-portal traversal at the captured + sphere position. PASSES (cell graph is correct). + - `AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell` + — direct unit test that pinpointed the root cause. **Initially + failed** (`AddAllOutsideCells` returned empty when given + landblock-local sphere coords). **Now passes after fix.** + +4. **Dat-direct cell-portal inspector** + ([DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs)): + reads `EnvCell` + `Environment.Cells` + portal `Polygon.Plane` from the + real dat for cells `0xA9B40150` (doorway alcove), `0xA9B4013F` + (cottage interior), `0xA9B40029` (outdoor — confirmed NOT EnvCell). + Output: cell `0xA9B40150` HAS a 0xFFFF exit portal at poly `0x0005` + with plane `n_local=(0, +1, 0), d_local=+5.6`. The sphere-vs-plane + math (sphere world `(132.36, 16.81, 94)` → local `(-1.86, -5.31, 0)` + via 180° Z rotation → `dist = +0.29` within `±rad=0.5` → straddles) + confirmed `exitOutside` SHOULD fire — but `AddAllOutsideCells` then + silently dropped the outdoor cell. + +## The fix + +[src/AcDream.Core/Physics/CellTransit.cs](../../src/AcDream.Core/Physics/CellTransit.cs) +— `AddAllOutsideCells` no longer subtracts the landblock's +"absolute" world origin from the sphere position. Treats +`worldSphereCenter` as landblock-local directly (matching retail's +`CLandCell::add_all_outside_cells` which uses the per-cell 6-byte +position struct, and matching production's universal convention since +Phase A.1). + +Existing tests in +[CellTransitAddAllOutsideCellsTests.cs](../../tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs) +and +[CellTransitFindCellSetTests.cs](../../tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs) +updated to use landblock-local sphere coords (they were the only +callers using the world-coord convention; production never did). + +## Visual verification + +User tested all four combinations at a closed Holtburg cottage door, +~50cm off-center: + +| Direction | Speed | Pre-fix | Post-fix | +|---|---|---|---| +| outside → inside | RUN | walks through | **BLOCKS** ✅ | +| outside → inside | WALK | walks through | (presumed BLOCKS — not retested) | +| inside → outside | RUN | walks through | **PARTIAL** ⚠️ body intersects door, sometimes through | +| inside → outside | WALK | walks through | **PARTIAL** ⚠️ same as run | + +User quote: *"We have partial blocking from inside out. Can get +through some times. However, char is blocked a bit through the door. +So for example if I'm running towards this from the inside, I can see +parts of the body getting blocked a bit in to the door. This is not +per retail behavior and this is not how it looks when its block from +the outside"*. + +The asymmetry is the new diagnostic: outside-in produces a clean block +(no body-into-door intersection visible); inside-out produces a partial +block with visible body intersection. This is the signature of an +**asymmetric collision response** to the door slab's two-sided +polygons (`SidesType=Landblock`), or a **BSP query that handles +sphere-already-overlapping-slab differently from sphere-approaching-slab**. + +The `[bsp-test]` probe fires 245 times for the door entity during the +post-fix inside-out attempts — door IS being queried. The +collision-detection mechanics produce the wrong response. + +## What's next (separate bug) + +**Investigate BSPQuery.FindCollisions's response for two-sided polygons +when the sphere is already overlapping the slab.** Retail's +`CBSPTree::find_collisions` family handles this specifically — the +sphere's path through the slab faces gets traced and the FIRST face +crossed in motion direction is the collision. With two-sided polygons, +both faces are collidable; the front-vs-back determination is by +sphere-velocity vs face-normal dot product. + +Likely files: +- `src/AcDream.Core/Physics/BSPQuery.cs` — the BSP traversal + + sphere-poly intersection logic. +- Retail decomp anchors: + `acclient_2013_pseudo_c.txt:BSPTREE::find_collisions` + + `SPHEREPATH::sphere_intersects_poly` family. + +Apparatus to write next: a focused test that registers the door at its +actual production world transform (entity origin + partFrame offset +from the dat, with correct rotation) and replays a sphere passing +through it from EACH side at various speeds. Compare collision normal ++ position-resolution per side. The asymmetric response will be +reproducible at unit-test speed. + +## Commits + +[List the commit SHAs of the apparatus + fix once landed.] + +## Pickup prompt for the next session + +``` +Door bug — major root cause closed (CellTransit.AddAllOutsideCells +landblock-local coord convention). Outside→inside now blocks. But +inside→outside has asymmetric BSP collision response: body partially +intersects the door slab, sphere slides through. Same behavior at run ++ walk speed. Bug is in BSP collision response for two-sided polygons +or sphere-already-overlapping-slab handling. + + Read docs/research/2026-05-25-door-bug-partial-fix-shipped.md + + State both altitudes: + Currently working toward: M1.5 — Indoor world feels right + Current phase: A6.P4 door bug — inside-out asymmetric BSP collision + response. Apparatus is shipped (DoorBugTrajectoryReplayTests). + First major root cause closed. Remaining bug is in + BSP-collision-response mechanics, not cell visibility. + + First move: extend the existing DoorBug apparatus with a more + faithful door registration (entity at the actual production world + pos + correct rotation; use the partFrame from the dat). Then write + TWO directional tests: sphere approaching the slab from the south + (outside-in) and sphere approaching from the north (inside-out). + Compare cn normal + resolution for each. The asymmetric response + will reproduce at unit-test speed. From there, inspect + BSPQuery.FindCollisions's handling of two-sided polygons and + sphere-already-overlapping cases. Retail oracle: + CBSPTree::find_collisions family at acclient_2013_pseudo_c.txt. + + DO NOT: + - Re-investigate cell visibility (closed by AddAllOutsideCells fix) + - Re-do the registration shape (multi-part registration is correct) + - Speculate on the BSP fix without apparatus +``` diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 620889f..900157b 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -168,6 +168,35 @@ public static class CellTransit /// adds the primary cell plus up to 3 neighbours when the radius /// reaches a cell boundary. /// + /// + /// + /// is in the landblock-local coord + /// space the rest of the engine uses (X/Y in [0, 192]; landblock + /// world origin is at the streaming center, so all landblock-local + /// positions are also world positions for the player's landblock). + /// + /// + /// + /// A6.P4 door fix (2026-05-24): pre-fix this function subtracted the + /// landblock's "absolute" world origin (lbX=0xA9*192=32448) from the + /// sphere position, which made sense only if sphere coords were the + /// absolute world position (32580). But production has used + /// landblock-local coords since Phase A.1 (streaming-center landblock + /// at world origin, so lbOffset for the center is (0,0); see + /// GameWindow.BuildInteriorEntitiesForStreaming's lbOffset + /// formula). With landblock-local sphere coords, the old subtraction + /// produced localX = 132.36 - 32448 = -32316gridX = -1346 + /// → out-of-range → early return → ZERO outdoor cells added. For + /// indoor primary cells (where issue #98 gates the GetNearbyObjects + /// outdoor radial sweep) this meant the cottage door's outdoor cell + /// 0xA9B40029 never reached portalReachableCells, the door's + /// BSP was never queried, and the player walked through unimpeded — + /// the user-reported Holtburg-door walkthrough bug. The fix: + /// treat worldSphereCenter as landblock-local directly, no + /// landblock-world-origin subtraction. This matches retail's + /// CLandCell::add_all_outside_cells which uses the per-cell + /// 6-byte position struct (landblock-relative). + /// /// public static void AddAllOutsideCells( Vector3 worldSphereCenter, @@ -179,10 +208,8 @@ public static class CellTransit uint lbPrefix = currentCellId & 0xFFFF0000u; - float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f; - float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f; - float localX = worldSphereCenter.X - lbXf; - float localY = worldSphereCenter.Y - lbYf; + float localX = worldSphereCenter.X; + float localY = worldSphereCenter.Y; float cellLocalX = localX % CellSize; float cellLocalY = localY % CellSize; diff --git a/tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl b/tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl new file mode 100644 index 0000000..9521ff5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl @@ -0,0 +1,2 @@ +{"tick":13558,"timestampMs":54680488,"input":{"currentPos":{"x":132.36032,"y":16.811314,"z":94},"targetPos":{"x":132.4347,"y":17.204084,"z":94},"cellId":2847146320,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":132.4347,"y":17.204084,"z":94},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.09343648,"w":0.99562526},"velocity":{"x":2.2044106,"y":11.641261,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"contactPlaneCellId":2847146320,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"walkableVertices":[{"x":131.6,"y":17.1,"z":94},{"x":131.6,"y":16.5,"z":94},{"x":133.5,"y":16.5,"z":94},{"x":133.5,"y":17.1,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":132.4347,"y":17.204084,"z":94},"cellId":2847146320,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":132.4347,"y":17.204084,"z":94},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.09343648,"w":0.99562526},"velocity":{"x":2.2044106,"y":11.641261,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"contactPlaneCellId":2847146320,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":0,"z":1},"d":-94},"walkableVertices":[{"x":131.6,"y":17.1,"z":94},{"x":131.6,"y":16.5,"z":94},{"x":133.5,"y":16.5,"z":94},{"x":133.5,"y":17.1,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":22760,"timestampMs":54721581,"input":{"currentPos":{"x":133.13905,"y":18.018276,"z":94},"targetPos":{"x":133.1034,"y":17.600323,"z":94},"cellId":2847146025,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":133.1034,"y":17.600323,"z":94},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.9990943,"w":-0.042551227},"velocity":{"x":-1.0073924,"y":-11.805234,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":-0,"z":1},"d":-94},"contactPlaneCellId":2847146025,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":-0,"z":1},"d":-94},"walkableVertices":[{"x":144,"y":0,"z":94},{"x":144,"y":24,"z":94},{"x":120,"y":24,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":133.1034,"y":18.018276,"z":94},"cellId":2847146025,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":0,"y":1,"z":0}},"bodyAfter":{"position":{"x":133.1034,"y":17.600323,"z":94},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.9990943,"w":-0.042551227},"velocity":{"x":-1.0073924,"y":-11.805234,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":1,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":0,"y":-0,"z":1},"d":-94},"contactPlaneCellId":2847146025,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":0,"y":-0,"z":1},"d":-94},"walkableVertices":[{"x":144,"y":0,"z":94},{"x":144,"y":24,"z":94},{"x":120,"y":24,"z":94}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":135,"lastUpdateTime":0}} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs index 5ef0b74..b310d7d 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs @@ -10,15 +10,14 @@ public class CellTransitAddAllOutsideCellsTests [Fact] public void SphereWellInsideCell_AddsOneCell() { - // Player at world (12, 12, 0) in landblock 0xA9B40000 → cell (0,0). - // Landblock origin: 0xA9 = 169 → world X = 169*192 = 32448. - // 0xB4 = 180 → world Y = 180*192 = 34560. - // Player needs to be in cell (0,0) RELATIVE to landblock origin: - // world X = 32448 + 12 = 32460 - // world Y = 34560 + 12 = 34572 + // A6.P4 (2026-05-24): coords are LANDBLOCK-LOCAL (X/Y in [0, 192]). + // Player at landblock-local (12, 12, 0) → cell (0,0) in landblock 0xA9B40000. + // Pre-fix this test passed world coords (32460, 34572) and the function + // subtracted lbXf=32448 to get local 12. Post-fix the function expects + // landblock-local directly. var candidates = new HashSet(); CellTransit.AddAllOutsideCells( - worldSphereCenter: new Vector3(32460f, 34572f, 0f), + worldSphereCenter: new Vector3(12f, 12f, 0f), sphereRadius: 0.5f, currentCellId: 0xA9B40001u, candidates); @@ -30,11 +29,12 @@ public class CellTransitAddAllOutsideCellsTests [Fact] public void SphereAtCellEastBoundary_AddsTwoCells() { - // Player at world (32448 + 23.6, 34560 + 12, 0) — near +X edge of cell (0,0). - // Sphere reach to localX = 23.6 + 0.5 = 24.1 → cell (1,0) added. + // A6.P4 (2026-05-24): landblock-local coords. Player at (23.6, 12, 0) + // — near +X edge of cell (0,0). Sphere reaches to local X = 23.6 + 0.5 + // = 24.1 → cell (1,0) added. var candidates = new HashSet(); CellTransit.AddAllOutsideCells( - worldSphereCenter: new Vector3(32448f + 23.6f, 34560f + 12f, 0f), + worldSphereCenter: new Vector3(23.6f, 12f, 0f), sphereRadius: 0.5f, currentCellId: 0xA9B40001u, candidates); diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index aa91a52..33adf01 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -110,21 +110,11 @@ public class CellTransitFindCellSetTests public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells() { var cache = new PhysicsDataCache(); - // Outdoor seed near a cell boundary — expand to neighbours via - // AddAllOutsideCells. Landcells have no CellPhysics in cache, so - // they appear in the set but the containing-cell loop falls back - // to currentCellId. The point of this test: the SET captures - // them even though FindCellList's single-uint return cannot. - // - // World coords for landblock 0xA9B4FFFF: origin at - // (0xA9*192, 0xB4*192) = (32448, 34560). Cell grid(0,0) covers - // world XY in [(32448,34560), (32472,34584)). Place the sphere - // center near the east boundary of grid(0,0) so AddAllOutsideCells + // A6.P4 (2026-05-24): sphere coords are LANDBLOCK-LOCAL (X/Y in + // [0, 192]). Place the sphere center near the east boundary of + // landcell grid(0,0) (i.e., near local X=24) so AddAllOutsideCells // adds the east neighbour grid(1,0). - uint lbPrefix = 0xA9B40000u; - float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f; - float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f; - var sphereCenter = new Vector3(lbX + 23.8f, lbY + 12f, 0f); + var sphereCenter = new Vector3(23.8f, 12f, 0f); uint containing = CellTransit.FindCellSet( cache, sphereCenter, sphereRadius: 0.5f, @@ -138,17 +128,12 @@ public class CellTransitFindCellSetTests [Fact] public void IndoorSeed_ExitPortalTouchedOnlyBySecondSphere_AddsOutdoorLandcell() { - // Retail CObjCell::find_cell_list passes every SPHEREPATH sphere into - // CEnvCell::find_transit_cells. When any sphere straddles an outdoor - // exit portal, CLandCell::add_all_outside_cells runs for the whole - // sphere array. - uint lbPrefix = 0xA9B40000u; - float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f; - float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f; - - var cellTransform = Matrix4x4.CreateTranslation(new Vector3(lbX, lbY, 0f)); + // A6.P4 (2026-05-24): landblock-local sphere coords. Cell sits at + // the landblock origin (identity transform), so cell-local == world. + // Sphere head at local (2, 12, 3.2) reaches the cell's exit portal + // plane at local X=2.5 → AddAllOutsideCells fires. var exitCell = MakeCellWithPortalAtRightWall( - cellTransform, + Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0); @@ -158,10 +143,10 @@ public class CellTransitFindCellSetTests var spheres = new[] { // Foot sphere is not near the exit portal plane at local x=2.5. - new Sphere { Origin = new Vector3(lbX + 0.0f, lbY + 12.0f, 2.5f), Radius = 0.5f }, + new Sphere { Origin = new Vector3(0f, 12f, 2.5f), Radius = 0.5f }, // Head sphere reaches the exit portal plane and should trigger // outdoor landcell expansion. - new Sphere { Origin = new Vector3(lbX + 2.0f, lbY + 12.0f, 3.2f), Radius = 0.5f }, + new Sphere { Origin = new Vector3(2f, 12f, 3.2f), Radius = 0.5f }, }; uint containing = CellTransit.FindCellSet( diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs new file mode 100644 index 0000000..b9fab9d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -0,0 +1,570 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using Xunit; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Physics; + +/// +/// A6.P4 door bug (2026-05-24) — trajectory replay harness for the +/// Holtburg cottage door walk-through bug. User-reported: approach a +/// CLOSED door OFF-CENTER (≈50 cm from the doorway centerline), the +/// player walks through unimpeded. Live capture +/// (door-walkthrough.jsonl) confirms: +/// +/// +/// Tick 13558 — player at (132.36, 16.81, 94) cell 0xA9B40150 +/// (indoor cottage cell), targets (132.43, 17.20, 94). Engine +/// returns result.Position = target with +/// collisionNormalValid = false. Clean walkthrough. +/// Tick 22760 — player at (133.14, 18.02, 94) cell 0xA9B40029 +/// (outdoor cell, 0.57 m EAST of door center), targets +/// (133.10, 17.60, 94). Engine BLOCKS at Y=18.018, cn=(0, +1, 0). +/// The door's BSP fires correctly for THIS approach. +/// +/// +/// The bug is positional: the door blocks SOME approaches but not the +/// indoor-cell approach. This harness replays both representative ticks +/// against a fresh engine seeded with the door alone (registered via +/// at the captured +/// BSP world transform, cellScope=0u to mirror production). The FIRST +/// per-field divergence between live and harness outputs names what +/// apparatus state production has that the harness lacks — short- +/// circuiting the speculative-fix loop that closed in handoff +/// docs/research/2026-05-24-door-collision-session-end-handoff.md. +/// +/// +/// SKIP if ACDREAM_DAT_DIR (or the default +/// %USERPROFILE%\Documents\Asheron's Call) is unavailable — keeps +/// CI green. Local developer runs always have it. +/// +/// +public class DoorBugTrajectoryReplayTests +{ + // ── Door geometry from live capture ─────────────────────────────── + // [entity-source] id=0x000F4246 src=0x020019FF gfxObj=0x020019FF + // lb=0xA9B40029 shapes=cyl1+bsp1 state=0x00010008 + // [bsp-test] obj=0x000F4246 gfx=0x010044B5 radius=1.975 + // pos=(132.57,16.99,95.36) + // [cyl-test] obj=0x000F4246 radius=0.100 height=0.200 + // pos=(132.56,17.11,94.10) + private const uint DoorEntityId = 0x000F4246u; + private const uint DoorGfxObjId = 0x010044B5u; + private const uint DoorClosedState = 0x00010008u; // PERSISTENT_PS | 0x8 (no ETHEREAL) + private const uint DoorLandblockId = 0xA9B40000u; + + private static readonly Vector3 BspWorldPos = new(132.57f, 16.99f, 95.36f); + private const float BspRadius = 1.975f; + + private static readonly Vector3 CylWorldPos = new(132.56f, 17.11f, 94.10f); + private const float CylRadius = 0.10f; + private const float CylHeight = 0.20f; + + // ── Tests ───────────────────────────────────────────────────────── + + /// + /// Replay tick 13558 — the walkthrough. Player at (132.36, 16.81, 94) + /// indoor cell 0xA9B40150, runs NE to (132.43, 17.20, 94) crossing + /// the door's BSP Y-range [16.86, 17.12]. Live engine reports + /// collisionNormalValid=false, result.Position == target — sphere + /// walks through. The harness should reproduce the same null collision + /// IF the bug is upstream of BSP query (door not returned by + /// GetNearbyObjects from the indoor primary cell), OR fire a BSP + /// collision IF the harness's portal-reachable cell set includes the + /// door's outdoor cell when production's doesn't. + /// + [Fact] + public void LiveCompare_DoorOffCenterWalkthrough_Tick13558() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + var (engine, _) = BuildEngineWithDoorFixture(datDir); + var captured = LoadCapturedRecord(r => r.Tick == 13558); + AssertCallMatchesCapture(engine, captured); + } + + /// + /// Replay tick 22760 — the door BLOCKS. Player at (133.14, 18.02, 94) + /// outdoor cell 0xA9B40029, walks SW to (133.10, 17.60, 94). Live engine + /// reports collision with cn=(0, +1, 0) (+Y wall facing north, blocks + /// south motion). Sphere stopped at Y=18.018. This is the WORKING + /// case — the door's BSP correctly blocks when queried from the + /// outdoor primary cell. If the harness diverges here, the door + /// registration itself is wrong. + /// + [Fact] + public void LiveCompare_DoorBlocksFromOutside_Tick22760() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + var (engine, _) = BuildEngineWithDoorFixture(datDir); + var captured = LoadCapturedRecord(r => r.Tick == 22760); + AssertCallMatchesCapture(engine, captured); + } + + /// + /// Diagnostic dump: every relevant probe ON, replay tick 13558, + /// captured stdout shows what GetNearbyObjects / CellTransit / + /// BSPQuery actually did. Use this when the LiveCompare test FAILS + /// to see the engine's internal decisions on the failing tick. + /// Always passes (diagnostic-only). + /// + [Fact] + public void Diagnostic_Tick13558_DumpEngineInternals() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + PhysicsDiagnostics.ProbeResolveEnabled = true; + PhysicsDiagnostics.ProbeBuildingEnabled = true; + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbePushBackEnabled = true; + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + try + { + var (engine, _) = BuildEngineWithDoorFixture(datDir); + var captured = LoadCapturedRecord(r => r.Tick == 13558); + var body = SeedBodyFromSnapshot(captured.BodyBefore!); + + Console.WriteLine("=== Replay tick 13558 (the walkthrough) ==="); + var result = engine.ResolveWithTransition( + currentPos: captured.Input.CurrentPos, + targetPos: captured.Input.TargetPos, + cellId: captured.Input.CellId, + sphereRadius: captured.Input.SphereRadius, + sphereHeight: captured.Input.SphereHeight, + stepUpHeight: captured.Input.StepUpHeight, + stepDownHeight: captured.Input.StepDownHeight, + isOnGround: captured.Input.IsOnGround, + body: body, + moverFlags: (ObjectInfoState)captured.Input.MoverFlags, + movingEntityId: captured.Input.MovingEntityId); + + Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "=== Harness: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}", + result.Position.X, result.Position.Y, result.Position.Z, + result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z, + result.CollisionNormalValid, result.IsOnGround, result.CellId)); + Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "=== Live: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}", + captured.Result.Position.X, captured.Result.Position.Y, captured.Result.Position.Z, + captured.Result.CollisionNormal.X, captured.Result.CollisionNormal.Y, captured.Result.CollisionNormal.Z, + captured.Result.CollisionNormalValid, captured.Result.IsOnGround, captured.Result.CellId)); + } + finally + { + PhysicsDiagnostics.ProbeResolveEnabled = false; + PhysicsDiagnostics.ProbeBuildingEnabled = false; + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + PhysicsDiagnostics.ProbePushBackEnabled = false; + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + } + } + + /// + /// Drive directly with + /// cell 0xA9B40150 hydrated from the real dat and the sphere position + /// captured at tick 13558. Asserts exitOutside fires for the + /// 0xFFFF exit portal. If this PASSES, the cell-portal code is correct + /// in isolation and the production bug is upstream (cell not loaded + /// in cache, primary cell mis-classified, etc.). If it FAILS, the + /// portal traversal IS the bug. + /// + [Fact] + public void FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + // ── 1. Hydrate cell 0xA9B40150 from the real dat ──────────── + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint CellId = 0xA9B40150u; + const uint EnvCellPrefix = 0x0D000000u; + + var envCell = dats.Get(CellId); + Assert.NotNull(envCell); + + var environment = dats.Get( + EnvCellPrefix | envCell!.EnvironmentId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)); + Assert.NotNull(cellStruct); + + // ── 2. Build the cell's worldTransform matching production + // (GameWindow.cs:5404-5406) ────────────────────────────── + var cellOriginWorld = envCell.Position.Origin; + var worldTransform = + Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + Matrix4x4.CreateTranslation(cellOriginWorld); + + // ── 3. Hydrate into CellPhysics via CacheCellStruct ───────── + var cache = new PhysicsDataCache(); + cache.CacheCellStruct(CellId, envCell, cellStruct!, worldTransform); + + var cellPhysics = cache.GetCellStruct(CellId); + Assert.NotNull(cellPhysics); + Assert.NotNull(cellPhysics!.Portals); + Assert.Contains(cellPhysics.Portals, p => p.OtherCellId == 0xFFFFu); + + // ── 4. Captured sphere position at tick 13558 ─────────────── + var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f); + const float sphereRadius = 0.48f; + + // Confirm sphere is INSIDE cell 0x0150's BSP (sanity). + Assert.NotNull(cellPhysics.CellBSP); + var localCenter = Vector3.Transform(sphereWorld, cellPhysics.InverseWorldTransform); + + // ── 5. Run FindTransitCellsSphere — does it fire exitOutside? ─ + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellPhysics, CellId, + sphereWorld, sphereRadius, + candidates, + out bool exitOutside); + + // Diagnostic — print the localCenter + each portal's + // sphere-vs-plane distance so we see what the test computed. + Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "localCenter=({0:F4},{1:F4},{2:F4}) radius={3:F4}", + localCenter.X, localCenter.Y, localCenter.Z, sphereRadius)); + foreach (var portal in cellPhysics.Portals) + { + if (cellPhysics.PortalPolygons is null + || !cellPhysics.PortalPolygons.TryGetValue(portal.PolygonId, out var poly)) + continue; + float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; + float rad = sphereRadius + 0.02f; + bool hit = dist > -rad && dist < rad; + Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, + " portal otherCellId=0x{0:X4} polyId=0x{1:X4} n=({2:F4},{3:F4},{4:F4}) d={5:F4} dist={6:F4} rad={7:F4} hit={8}", + portal.OtherCellId, portal.PolygonId, + poly.Plane.Normal.X, poly.Plane.Normal.Y, poly.Plane.Normal.Z, + poly.Plane.D, dist, rad, hit)); + } + + Assert.True(exitOutside, + "Captured sphere at tick 13558 is straddling cell 0xA9B40150's " + + "exit portal (0xFFFF) plane. FindTransitCellsSphere should fire " + + "exitOutside, but did not. Candidates returned: " + + string.Join(",", candidates.Select(c => $"0x{c:X8}"))); + } + + /// + /// Direct test of with + /// the captured sphere position (132.36, 16.81, 94) and currentCellId + /// 0xA9B40150. Expects outdoor cell 0xA9B40029 (the door's cell) in + /// the result. + /// + /// + /// This is the suspected root cause of the bug: AddAllOutsideCells + /// computes localX = worldSphereCenter.X - lbXf where + /// lbXf = ((cellId >> 24) & 0xFF) * 192. For cellId + /// 0xA9B40150, lbXf = 0xA9 * 192 = 32448. If sphere coords are + /// LANDBLOCK-LOCAL (as the JSONL capture shows: x=132.36, NOT + /// 32580.36), the subtraction produces localX = -32316 → gridX = -1346 + /// → early return → NO cells added → door invisible from indoor. + /// + /// + [Fact] + public void AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell() + { + var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f); + const float sphereRadius = 0.48f; + const uint currentCellId = 0xA9B40150u; + + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + sphereWorld, sphereRadius, currentCellId, candidates); + + const uint expectedDoorCell = 0xA9B40029u; + Assert.True(candidates.Contains(expectedDoorCell), + $"AddAllOutsideCells with landblock-local sphere ({sphereWorld.X:F2}, " + + $"{sphereWorld.Y:F2}, {sphereWorld.Z:F2}) and indoor primary cell " + + $"0x{currentCellId:X8} should add outdoor cell 0x{expectedDoorCell:X8} " + + $"(where the door lives). Got: " + + string.Join(",", candidates.Select(c => $"0x{c:X8}"))); + } + + // ── Engine + door fixture ───────────────────────────────────────── + + /// + /// Build a fresh with: + /// + /// Door GfxObj 0x010044B5 hydrated from the real dat + /// (mirrors DoorSetupGfxObjInspectionTests's read pattern + /// via ). + /// A landblock 0xA9B40000 stub (terrain far below) so + /// TryGetLandblockContext succeeds at the door's XY. + /// Door registered via + /// at the + /// captured BSP world center — entity world pos = BSP world pos, + /// entity rot = identity, one BSP shape at local position zero. + /// This bypasses ShadowShapeBuilder.FromSetup; the test's + /// pure goal is to put a slab in the right world location and + /// see whether the engine sees it from the captured tick's + /// primary cell. cellScope=0u (default) mirrors production's + /// door registration at GameWindow.cs:3158-3167. + /// + /// + /// + /// No cell fixture is registered for the player's indoor cell + /// 0xA9B40150. Without one, CellTransit.FindCellSet can't + /// portal-walk from indoor → outdoor, so the door (in outdoor cell + /// 0xA9B40029) won't be reachable from the indoor primary. That's + /// the BUG'S ROOT IF the test reproduces the live cnValid=false at + /// tick 13558 — the indoor cell's portal graph is missing the + /// outdoor cell connection. + /// + /// + private static (PhysicsEngine engine, PhysicsDataCache cache) + BuildEngineWithDoorFixture(string datDir) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // 1. Hydrate door BSP from real dat (CacheGfxObj handles ResolvePolygons, + // BoundingSphere extraction, and visual AABB fallback). + using (var dats = new DatCollection(datDir, DatAccessType.Read)) + { + var gfx = dats.Get(DoorGfxObjId); + Assert.NotNull(gfx); + Assert.NotNull(gfx!.PhysicsBSP); + Assert.NotNull(gfx.PhysicsBSP!.Root); + cache.CacheGfxObj(DoorGfxObjId, gfx); + } + Assert.NotNull(cache.GetGfxObj(DoorGfxObjId)); + + // 2. Stub landblock so TryGetLandblockContext succeeds at the door XY. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below Z=94 + var stubTerrain = new TerrainSurface(heights, heightTable); + engine.AddLandblock( + landblockId: DoorLandblockId, + terrain: stubTerrain, + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + // 3. Register the door — BSP shape only (the captured bug attaches + // to the BSP slab; the cylinder is a small foot collider). + // entityWorldPos = BSP world pos so LocalPos=0 puts the BSP at + // the captured center. cellScope=0u mirrors production. + var bspShape = new ShadowShape( + GfxObjId: DoorGfxObjId, + LocalPosition: Vector3.Zero, + LocalRotation: Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.BSP, + Radius: BspRadius, + CylHeight: 0f); + + var cylShape = new ShadowShape( + GfxObjId: 0u, + LocalPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin + LocalRotation: Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.Cylinder, + Radius: CylRadius, + CylHeight: CylHeight); + + engine.ShadowObjects.RegisterMultiPart( + entityId: DoorEntityId, + entityWorldPos: BspWorldPos, + entityWorldRot: Quaternion.Identity, + shapes: new[] { cylShape, bspShape }, + state: DoorClosedState, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: DoorLandblockId); + + return (engine, cache); + } + + // ── Capture loading + comparison (mirrors CellarUpTrajectoryReplayTests) ── + + private static string? ResolveDatDir() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(datDir) ? datDir : null; + } + + private static ResolveCaptureRecord LoadCapturedRecord( + Func predicate) + { + var path = Path.Combine(FixtureDir, "live-capture.jsonl"); + Assert.True(File.Exists(path), + $"Door-bug live-capture fixture missing: {path}."); + + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var record = System.Text.Json.JsonSerializer + .Deserialize(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!; + if (predicate(record)) + return record; + } + + throw new Xunit.Sdk.XunitException( + "No captured record matched the predicate. Update the fixture."); + } + + /// + /// Replays one captured ResolveWithTransition call against + /// , seeded with bodyBefore, and reports + /// the first per-field divergence between live and harness. + /// + private static void AssertCallMatchesCapture( + PhysicsEngine engine, + ResolveCaptureRecord captured) + { + Assert.NotNull(captured.BodyBefore); + Assert.NotNull(captured.BodyAfter); + + var body = SeedBodyFromSnapshot(captured.BodyBefore); + + var harnessResult = engine.ResolveWithTransition( + currentPos: captured.Input.CurrentPos, + targetPos: captured.Input.TargetPos, + cellId: captured.Input.CellId, + sphereRadius: captured.Input.SphereRadius, + sphereHeight: captured.Input.SphereHeight, + stepUpHeight: captured.Input.StepUpHeight, + stepDownHeight: captured.Input.StepDownHeight, + isOnGround: captured.Input.IsOnGround, + body: body, + moverFlags: (ObjectInfoState)captured.Input.MoverFlags, + movingEntityId: captured.Input.MovingEntityId); + + var divergences = new List(); + + AddIfDifferent(divergences, "Result.Position", + captured.Result.Position, harnessResult.Position); + AddIfDifferent(divergences, "Result.CellId", + $"0x{captured.Result.CellId:X8}", $"0x{harnessResult.CellId:X8}"); + AddIfDifferent(divergences, "Result.IsOnGround", + captured.Result.IsOnGround, harnessResult.IsOnGround); + AddIfDifferent(divergences, "Result.CollisionNormalValid", + captured.Result.CollisionNormalValid, harnessResult.CollisionNormalValid); + if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid) + { + AddIfDifferent(divergences, "Result.CollisionNormal", + captured.Result.CollisionNormal, harnessResult.CollisionNormal); + } + + AddIfDifferent(divergences, "BodyAfter.Position", + captured.BodyAfter.Position, body.Position); + AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid", + captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid); + if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid) + { + AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal", + captured.BodyAfter.ContactPlane.Normal, body.ContactPlane.Normal); + AddIfDifferent(divergences, "BodyAfter.ContactPlane.D", + captured.BodyAfter.ContactPlane.D, body.ContactPlane.D); + } + AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid", + captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid); + AddIfDifferent(divergences, "BodyAfter.TransientState", + $"0x{captured.BodyAfter.TransientState:X}", + $"0x{(uint)body.TransientState:X}"); + + if (divergences.Count > 0) + { + string summary = string.Join("\n * ", divergences); + string header = string.Format(System.Globalization.CultureInfo.InvariantCulture, + "Door-bug harness replay of captured tick {0} diverges from live engine. " + + "Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " + + "cellId=0x{7:X8} isOnGround={8}", + captured.Tick, + captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z, + captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z, + captured.Input.CellId, captured.Input.IsOnGround); + throw new Xunit.Sdk.XunitException( + header + "\nDivergences (live -> harness):\n * " + summary); + } + } + + private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new() + { + Position = snap.Position, + Orientation = snap.Orientation, + Velocity = snap.Velocity, + Acceleration = snap.Acceleration, + Omega = snap.Omega, + GroundNormal = snap.GroundNormal, + SlidingNormal = snap.SlidingNormal, + ContactPlaneValid = snap.ContactPlaneValid, + ContactPlane = snap.ContactPlane, + ContactPlaneCellId = snap.ContactPlaneCellId, + ContactPlaneIsWater = snap.ContactPlaneIsWater, + WalkablePolygonValid = snap.WalkablePolygonValid, + WalkablePlane = snap.WalkablePlane, + WalkableVertices = snap.WalkableVertices, + WalkableUp = snap.WalkableUp, + Elasticity = snap.Elasticity, + Friction = snap.Friction, + State = (PhysicsStateFlags)snap.State, + TransientState = (TransientStateFlags)snap.TransientState, + LastUpdateTime = snap.LastUpdateTime, + }; + + private static void AddIfDifferent( + List divergences, string name, T live, T harness) + { + if (EqualityComparer.Default.Equals(live, harness)) return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live={1} harness={2}", name, live, harness)); + } + + private static void AddIfDifferent( + List divergences, string name, Vector3 live, Vector3 harness) + { + if (Vector3.DistanceSquared(live, harness) < 1e-6f) return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})", + name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z)); + } + + private static void AddIfDifferent( + List divergences, string name, float live, float harness) + { + if (MathF.Abs(live - harness) < 1e-3f) return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live={1:F4} harness={2:F4}", name, live, harness)); + } + + private static string FixtureDir => + Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", + "Fixtures", "door-bug"); + + private static string SolutionRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Path.GetDirectoryName(dir); + } + throw new InvalidOperationException( + "Could not locate AcDream.slnx from " + AppContext.BaseDirectory); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs index 70e3f57..1420661 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs @@ -218,4 +218,128 @@ public class DoorSetupGfxObjInspectionTests if (node.NegNode is not null) n += CountTotalPolys(node.NegNode); return n; } + + // ── A6.P4 door bug (2026-05-24, apparatus-replay extension) ── + // Dat-direct EnvCell portal inspection. The trajectory-replay + // harness (DoorBugTrajectoryReplayTests) proved the door BSP is + // never queried at indoor primary cell 0xA9B40150 — no [bsp-test] + // line fires because GetNearbyObjects returns zero shadows. The + // CellTransit.FindCellSet portal traversal isn't surfacing + // outdoor cell 0xA9B40029 from indoor cell 0xA9B40150. This test + // reads the relevant cells' raw portal lists from the dat so we + // can determine whether: + // (a) cell 0xA9B40150 has NO portal with OtherCellId=0xFFFF + // (no exit-portal → exitOutside is never set → bug is in + // the building-shell-transit path, not the cell-graph) + // (b) cell 0xA9B40150 HAS such a portal but FindTransitCellsSphere's + // sphere-vs-plane test rejects it at the player's position + // (c) the portal exists and fires correctly, but AddAllOutsideCells + // computes a different outdoor cell than 0xA9B40029 + // Findings drive the fix direction. + [Fact] + public void HoltburgCottage_CellPortals_DatInspection() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Cells from the captured walkthrough: + // 0xA9B40150 — indoor doorway cell (failing tick primary cell) + // 0xA9B4013F — indoor cottage interior cell (player started here) + // 0xA9B40029 — outdoor cell where door is registered + var cellIds = new uint[] { 0xA9B40150u, 0xA9B4013Fu, 0xA9B40029u }; + foreach (uint cellId in cellIds) + { + _out.WriteLine(""); + InspectCell(dats, cellId); + } + } + + private void InspectCell(DatCollection dats, uint cellId) + { + string typeLabel = (cellId & 0xFFFFu) >= 0x0100u ? "indoor" : "outdoor"; + _out.WriteLine($"=== Cell 0x{cellId:X8} (low=0x{cellId & 0xFFFFu:X4}, type={typeLabel}) ==="); + + bool ok = dats.TryGet(cellId, out var envCell); + if (!ok || envCell is null) + { + _out.WriteLine($" TryGet(0x{cellId:X8}) returned false (not in dat or wrong type — outdoor cells use LandBlockInfo, not EnvCell)"); + return; + } + + _out.WriteLine($" Flags = 0x{(uint)envCell.Flags:X8}"); + _out.WriteLine($" Position.Origin= ({envCell.Position.Origin.X:F3},{envCell.Position.Origin.Y:F3},{envCell.Position.Origin.Z:F3})"); + var q = envCell.Position.Orientation; + _out.WriteLine($" Position.Rot = ({q.X:F4},{q.Y:F4},{q.Z:F4},{q.W:F4})"); + _out.WriteLine($" EnvironmentId = 0x{envCell.EnvironmentId:X8}"); + _out.WriteLine($" CellStructure = 0x{envCell.CellStructure:X8}"); + _out.WriteLine($" StaticObjects = {envCell.StaticObjects.Count}"); + _out.WriteLine($" VisibleCells = {envCell.VisibleCells.Count}"); + _out.WriteLine($" CellPortals = {envCell.CellPortals.Count}"); + for (int i = 0; i < envCell.CellPortals.Count; i++) + { + var p = envCell.CellPortals[i]; + string otherIdStr = p.OtherCellId == 0xFFFFu + ? "0xFFFF (EXIT-OUTSIDE)" + : $"0x{p.OtherCellId:X4} (other-indoor)"; + _out.WriteLine($" [{i}] otherCellId={otherIdStr} polyId=0x{p.PolygonId:X4} flags=0x{(uint)p.Flags:X4}"); + } + + // Resolve the CellStruct (via Environment) so we can inspect the + // portal polygons' plane equations. The plane equation is what + // CellTransit.FindTransitCellsSphere tests the sphere against; if + // the test wrongly rejects, the door is invisible. + // Environment dat IDs are prefixed with 0x0D000000 — see + // GameWindow.cs:5388 (`0x0D000000u | envCell.EnvironmentId`). + bool envOk = dats.TryGet( + 0x0D000000u | envCell.EnvironmentId, out var environment); + if (!envOk || environment is null) + { + _out.WriteLine($" Environment 0x{envCell.EnvironmentId:X8} NOT loadable"); + return; + } + if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) + { + _out.WriteLine($" Environment.Cells[0x{envCell.CellStructure:X8}] NOT present"); + return; + } + _out.WriteLine($" CellStruct verts = {cellStruct.VertexArray?.Vertices.Count ?? 0}"); + _out.WriteLine($" CellStruct polygons = {cellStruct.Polygons?.Count ?? 0} (visible)"); + _out.WriteLine($" CellStruct physicsPolys = {cellStruct.PhysicsPolygons?.Count ?? 0}"); + + // Dump the plane of each portal polygon (the planes + // FindTransitCellsSphere tests the sphere center against). + for (int i = 0; i < envCell.CellPortals.Count; i++) + { + var p = envCell.CellPortals[i]; + if (cellStruct.Polygons is null + || !cellStruct.Polygons.TryGetValue(p.PolygonId, out var poly)) + { + _out.WriteLine($" PortalPoly[{i}=0x{p.PolygonId:X4}] NOT present in CellStruct.Polygons"); + continue; + } + // Resolve vertex positions. + var vs = poly.VertexIds.Select(vid => cellStruct.VertexArray.Vertices[(ushort)vid].Origin).ToList(); + string vlist = string.Join(",", vs.Select(v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})")); + + // Compute the plane using the first three verts (normal = (v1-v0) x (v2-v0), d = -dot(n, v0)). + if (vs.Count >= 3) + { + var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross(vs[1] - vs[0], vs[2] - vs[0])); + float d = -System.Numerics.Vector3.Dot(n, vs[0]); + _out.WriteLine($" PortalPoly[{i}=0x{p.PolygonId:X4}] n_local=({n.X:F4},{n.Y:F4},{n.Z:F4}) d_local={d:F4} verts={vlist}"); + } + else + { + _out.WriteLine($" PortalPoly[{i}=0x{p.PolygonId:X4}] <3 verts, plane n/a verts={vlist}"); + } + } + } }