fix(phys): A6.P4 door bug — AddAllOutsideCells coord convention + replay apparatus

CellTransit.AddAllOutsideCells assumed sphere coords were absolute world
coords (subtracting lbXf = 0xA9 * 192 = 32448 from the sphere position).
Production has used landblock-local coords since Phase A.1
(streaming-center landblock at world origin), so the subtraction
produced localX = -32316, gridX = -1346 → out-of-range → early return
→ ZERO outdoor cells added.

For outdoor primary cells the bug was masked by GetNearbyObjects's
radial sweep. For indoor primary cells (where #98 gates the outdoor
sweep), the door's outdoor cell 0xA9B40029 never reached
portalReachableCells, the door's BSP was never queried, and the player
walked through Holtburg cottage doors unimpeded.

Fix: AddAllOutsideCells treats worldSphereCenter as landblock-local
directly. Matches retail CLandCell::add_all_outside_cells which uses
the per-cell 6-byte landblock-relative position struct.

Existing CellTransitAddAllOutsideCellsTests + CellTransitFindCellSetTests
updated to use landblock-local sphere coords (they were the only callers
using the world-coord convention; production never did).

Apparatus shipped:
- DoorBugTrajectoryReplayTests — live-capture-driven replay harness
  that pinpointed the bug per-field at unit-test speed (<500ms iteration)
- AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell — direct
  unit test that demonstrates the fix
- FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos
  — verifies cell-portal traversal at the captured sphere position
- DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
  — dat-direct EnvCell + Environment.Cells + portal-poly inspector
- Fixture: tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl
  (tick 13558 walkthrough + tick 22760 outdoor block)

Visual verification (user-driven at Holtburg cottage door, ~50cm off-center):
- outside→inside RUN: now BLOCKS (was: walks through)
- outside→inside WALK: presumed blocks (not retested)
- inside→outside RUN: PARTIAL — body intersects door, sphere slides through
- inside→outside WALK: same partial behavior

The remaining inside→outside asymmetry is a SEPARATE bug in BSP
collision response for two-sided polygons. The [bsp-test] probe now
fires 245 times for the door entity from indoor (was 0 pre-fix) —
door IS being queried; the BSP polygon-level collision response is
the new bug. Handoff at
docs/research/2026-05-25-door-bug-partial-fix-shipped.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 07:53:34 +02:00
parent 6a2c432e5a
commit 28cd97be62
8 changed files with 1134 additions and 40 deletions

View file

@ -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<GfxObj>`), 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) |
| 1099511071 | 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<EnvCell>(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
```

View file

@ -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
```

View file

@ -168,6 +168,35 @@ public static class CellTransit
/// adds the primary cell plus up to 3 neighbours when the radius
/// reaches a cell boundary.
/// </para>
///
/// <para>
/// <see cref="worldSphereCenter"/> 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).
/// </para>
///
/// <para>
/// 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
/// <c>GameWindow.BuildInteriorEntitiesForStreaming</c>'s lbOffset
/// formula). With landblock-local sphere coords, the old subtraction
/// produced <c>localX = 132.36 - 32448 = -32316</c> → <c>gridX = -1346</c>
/// → 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 <c>portalReachableCells</c>, the door's
/// BSP was never queried, and the player walked through unimpeded —
/// the user-reported Holtburg-door walkthrough bug. The fix:
/// treat <c>worldSphereCenter</c> as landblock-local directly, no
/// landblock-world-origin subtraction. This matches retail's
/// <c>CLandCell::add_all_outside_cells</c> which uses the per-cell
/// 6-byte position struct (landblock-relative).
/// </para>
/// </summary>
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;

View file

@ -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}}

View file

@ -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<uint>();
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<uint>();
CellTransit.AddAllOutsideCells(
worldSphereCenter: new Vector3(32448f + 23.6f, 34560f + 12f, 0f),
worldSphereCenter: new Vector3(23.6f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u,
candidates);

View file

@ -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(

View file

@ -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;
/// <summary>
/// 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
/// (<c>door-walkthrough.jsonl</c>) confirms:
///
/// <list type="bullet">
/// <item>Tick 13558 — player at (132.36, 16.81, 94) cell 0xA9B40150
/// (indoor cottage cell), targets (132.43, 17.20, 94). Engine
/// returns <c>result.Position = target</c> with
/// <c>collisionNormalValid = false</c>. Clean walkthrough.</item>
/// <item>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.</item>
/// </list>
///
/// 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
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> 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
/// <c>docs/research/2026-05-24-door-collision-session-end-handoff.md</c>.
///
/// <para>
/// SKIP if ACDREAM_DAT_DIR (or the default
/// <c>%USERPROFILE%\Documents\Asheron's Call</c>) is unavailable — keeps
/// CI green. Local developer runs always have it.
/// </para>
/// </summary>
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 ─────────────────────────────────────────────────────────
/// <summary>
/// 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
/// <c>collisionNormalValid=false</c>, 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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;
}
}
/// <summary>
/// Drive <see cref="CellTransit.FindTransitCellsSphere"/> directly with
/// cell 0xA9B40150 hydrated from the real dat and the sphere position
/// captured at tick 13558. Asserts <c>exitOutside</c> 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.
/// </summary>
[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<DatReaderWriter.DBObjs.EnvCell>(CellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
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<uint>();
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}")));
}
/// <summary>
/// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId
/// 0xA9B40150. Expects outdoor cell 0xA9B40029 (the door's cell) in
/// the result.
///
/// <para>
/// This is the suspected root cause of the bug: AddAllOutsideCells
/// computes <c>localX = worldSphereCenter.X - lbXf</c> where
/// <c>lbXf = ((cellId &gt;&gt; 24) &amp; 0xFF) * 192</c>. 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.
/// </para>
/// </summary>
[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<uint>();
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 ─────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>Door GfxObj 0x010044B5 hydrated from the real dat
/// (mirrors <c>DoorSetupGfxObjInspectionTests</c>'s read pattern
/// via <see cref="DatCollection.Get{T}"/>).</item>
/// <item>A landblock 0xA9B40000 stub (terrain far below) so
/// <c>TryGetLandblockContext</c> succeeds at the door's XY.</item>
/// <item>Door registered via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> 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.</item>
/// </list>
///
/// <para>
/// No cell fixture is registered for the player's indoor cell
/// 0xA9B40150. Without one, <c>CellTransit.FindCellSet</c> 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.
/// </para>
/// </summary>
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<GfxObj>(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<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
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<ResolveCaptureRecord, bool> 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<ResolveCaptureRecord>(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture.");
}
/// <summary>
/// Replays one captured ResolveWithTransition call against
/// <paramref name="engine"/>, seeded with bodyBefore, and reports
/// the first per-field divergence between live and harness.
/// </summary>
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<string>();
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<T>(
List<string> divergences, string name, T live, T harness)
{
if (EqualityComparer<T>.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<string> 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<string> 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);
}
}

View file

@ -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<EnvCell>(cellId, out var envCell);
if (!ok || envCell is null)
{
_out.WriteLine($" TryGet<EnvCell>(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<DatReaderWriter.DBObjs.Environment>(
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}");
}
}
}
}