diff --git a/docs/research/2026-06-03-p0-conformance-apparatus-notes.md b/docs/research/2026-06-03-p0-conformance-apparatus-notes.md index 9dadc38..e09b55f 100644 --- a/docs/research/2026-06-03-p0-conformance-apparatus-notes.md +++ b/docs/research/2026-06-03-p0-conformance-apparatus-notes.md @@ -167,6 +167,29 @@ evidence. **Do NOT design or code a P1 membership "fix" before the production-pa RED/GREEN is read.** The P0 `..._DivergesFromRetail_PendingP1` test is a UNIT-level pin only, NOT evidence of a production divergence. +## ✅ RESOLVED — the production path DIVERGES (the "probe artifact" hypothesis is FALSIFIED) + +Built `ThresholdPortalCrossingReplayTests.ProductionPath_IndoorCrossings_DivergeFromRetail_PendingP1` +(replays the golden indoor `0170↔0171` segments through the REAL `ResolveWithTransition` — engine +builds the global sphere + sweeps; cells loaded from dats with real BSP). Result: **0/11 match +retail.** Every segment: `restPos == target` (the sweep COMPLETES the move cleanly) but `CellId` +stays on the SOURCE cell — acdream moves the body across the doorway yet **never advances +`curr_cell`**. So production membership genuinely lags; the P0 finding is REAL, not a probe artifact. + +**Refined mechanism (supersedes the "portal-crossing vs point-in-cell criterion" framing).** Both +retail and acdream PICK with center-only `point_in_cell`. The divergence is that retail's `curr_cell` +ADVANCES to the neighbour during the sweep (the swept sphere crossing the doorway polygon, and/or a +sphere point that leads the foot into the room), so by the time the foot rests at the captured +position the membership has already advanced. acdream's swept advance does NOT promote the neighbour — +at the end-position its tested sphere center is still inside the source cell's BSP, so the pick keeps +the source cell. **P1's job: port how retail advances `curr_cell` across the portal mid-sweep.** The +open decomp questions for P1: (1) how `global_sphere[0]` local origin relates to `m_position` (does +retail's sphere point lead the foot?); (2) whether `curr_cell` advances via `find_transit_cells`' +swept crossing in `transitional_insert`/`validate_transition` BEFORE the `find_cell_list` pick, vs the +pick alone. Anchors: `CTransition::transitional_insert @ 0x50aa70 pc:272547`, +`CTransition::validate_transition`, `CPhysicsObj::SetPositionInternal @ 0x515330 pc:283399`. The RED +production-path test is the gate the P1 fix must turn GREEN. + ## P0 status / P1-entry checklist — COMPLETE **Apparatus: COMPLETE + GREEN.** (Conformance suite 59 pass / 1 skip / 0 fail.) diff --git a/tests/AcDream.Core.Tests/Conformance/ThresholdPortalCrossingReplayTests.cs b/tests/AcDream.Core.Tests/Conformance/ThresholdPortalCrossingReplayTests.cs new file mode 100644 index 0000000..e6651b0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/ThresholdPortalCrossingReplayTests.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// P1 decisive evidence — the PRODUCTION-PATH membership conformance. Replays the +/// captured retail doorway golden through the REAL +/// (which builds the global sphere and SWEEPS it), then checks whether the swept +/// CellId matches retail's committed cell. This is the integration test the +/// P0 bare-FindCellList probe could NOT be: the probe fed the resting foot +/// origin with no sweep, so it always reported the cell the foot stood in. Here the +/// engine sweeps prev→curr, so the sphere crosses the doorway exactly as retail's did. +/// +/// Diagnostic-first: prints MATCH/DIVERGE per segment so we read the real production +/// behaviour before pinning an assertion. Outcomes (notes +/// docs/research/2026-06-03-p0-conformance-apparatus-notes.md §CORRECTION): production +/// MATCHES → the P0 divergence was a probe artifact; DIVERGES → a real bug to design from. +/// +/// Scope here = the INDOOR segments (vestibule 0170 ↔ room 0171), which need only the +/// building-cell cache. The outdoor-involving segments (0031↔0170) need the landcell + +/// building portal and are a follow-up. +/// +public class ThresholdPortalCrossingReplayTests +{ + private readonly ITestOutputHelper _out; + public ThresholdPortalCrossingReplayTests(ITestOutputHelper output) => _out = output; + + // The doorway floor is flat at the captured pz across all transitions. + private const float FloorZ = 94.005f; + private const float SphereRadius = 0.4f; + private const float SphereHeight = 1.2f; + private const float StepUpHeight = 0.4f; + private const float StepDownHeight = 0.1f; + + private static (PhysicsEngine, PhysicsDataCache) BuildBuildingEngine(DatCollection dats) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + for (uint low = 0x016Fu; low <= 0x0175u; low++) + ConformanceDats.LoadEnvCell(dats, cache, ConformanceDats.HoltburgLandblock | low); + + // Stub landblock for FindObjCollisions context (flat far-below terrain; the + // indoor BSP path fires first, terrain is never consulted). Matches the + // CellarUpTrajectoryReplay harness pattern. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock(0xA9B40000u, new TerrainSurface(heights, heightTable), + Array.Empty(), Array.Empty(), 0f, 0f); + return (engine, cache); + } + + private static PhysicsBody GroundedBodyAt(Vector3 pos, uint cellId) => new() + { + Position = pos, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = new Plane(0f, 0f, 1f, -FloorZ), + ContactPlaneCellId = cellId, + WalkablePolygonValid = true, + WalkablePlane = new Plane(0f, 0f, 1f, -FloorZ), + WalkableVertices = new[] + { + new Vector3(150f, 5f, FloorZ), new Vector3(150f, 20f, FloorZ), + new Vector3(165f, 20f, FloorZ), new Vector3(165f, 5f, FloorZ), + }, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + + private static uint Low(uint id) => id & 0xFFFFu; + + /// + /// Documents-the-bug (GREEN while acdream diverges; FAILS when P1 lands → rewrite to + /// per-segment Assert.Equal). The swept production path DIVERGES from retail on the + /// indoor doorway crossings: ResolveWithTransition completes the move + /// (restPos == target) but leaves CellId on the SOURCE cell — it never + /// advances curr_cell across the portal the way retail's change_cell golden + /// does. So the P0 finding is NOT a probe artifact: production membership genuinely lags. + /// P1 must port retail's swept-crossing curr_cell advance (how the sphere crossing + /// the doorway polygon / the leading sphere point promotes the neighbour to the membership + /// answer mid-sweep), then this flips to all-match. + /// + [Fact] + public void ProductionPath_IndoorCrossings_DivergeFromRetail_PendingP1() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + var fixturePath = Path.Combine(ConformanceDats.FixturesDir, "find-cell-list-threshold.log"); + if (!File.Exists(fixturePath)) { _out.WriteLine("SKIP: capture pending"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (engine, _) = BuildBuildingEngine(dats); + var picks = RetailTrace.ParseAll(File.ReadAllLines(fixturePath)); + + int match = 0, total = 0; + for (int i = 0; i + 1 < picks.Count; i++) + { + uint fromCell = picks[i].PickedCellId; + uint toCell = picks[i + 1].PickedCellId; + if (Low(fromCell) < 0x100 || Low(toCell) < 0x100) continue; // indoor segments only + + var body = GroundedBodyAt(picks[i].Position, fromCell); + var result = engine.ResolveWithTransition( + currentPos: picks[i].Position, + targetPos: picks[i + 1].Position, + cellId: fromCell, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + bool ok = result.CellId == toCell; + if (ok) match++; + total++; + _out.WriteLine( + $"seg 0x{Low(fromCell):X4}->0x{Low(toCell):X4} " + + $"pos=({picks[i].Position.X:F2},{picks[i].Position.Y:F2})->({picks[i + 1].Position.X:F2},{picks[i + 1].Position.Y:F2}) " + + $"acdream=0x{Low(result.CellId):X4} restPos=({result.Position.X:F2},{result.Position.Y:F2},{result.Position.Z:F2}) " + + $"{(ok ? "MATCH" : "DIVERGE")}"); + } + _out.WriteLine($"=== production-path indoor crossings: {match}/{total} match retail ==="); + + Assert.True(total > 0, "no indoor doorway segments found in the golden"); + Assert.True(match < total, + $"acdream's swept ResolveWithTransition now reproduces {match}/{total} retail indoor " + + "doorway crossings. If match == total, P1's curr_cell swept-advance port landed -> " + + "rewrite this to Assert.Equal(toCell, result.CellId) per segment. (Retail truth = the golden.)"); + } +}