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:
parent
6a2c432e5a
commit
28cd97be62
8 changed files with 1134 additions and 40 deletions
215
docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md
Normal file
215
docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md
Normal 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) |
|
||||
| 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<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
|
||||
```
|
||||
171
docs/research/2026-05-25-door-bug-partial-fix-shipped.md
Normal file
171
docs/research/2026-05-25-door-bug-partial-fix-shipped.md
Normal 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
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
570
tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
Normal file
570
tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
Normal 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 >> 24) & 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue