diff --git a/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs new file mode 100644 index 00000000..1d94e784 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Tests.Conformance; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// §2b corner camera-seal replay (2026-06-10) — INVESTIGATION RESULT: the +/// "camera penetrates the wall at corners" hypothesis is REFUTED. Replays the +/// captured "escape" camera sweeps from corner-seal-capture.log through the +/// REAL with the camera's exact +/// call shape ( | PathClipped | FreeRotate | +/// PerfectClip, single 0.3 m sphere — mirrors +/// src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.SweepEye). +/// +/// WHAT THE REPLAY + GEOMETRY MAP PROVED (Diagnostic fact below): +/// every captured zero-contact traversal runs through a REAL OPENING, not a wall — +/// the building's exit-door portal (0170 → 0xFFFF) for the viewer-outdoor frames, and +/// the 0171↔0173↔0172 doorway chain (0173 is a 20 cm threshold cell — two parallel +/// portal planes at local x=4.10/3.90) for the corner-press frames. The captured eyes +/// land INSIDE the door openings' rectangles; the containment walk shows no path point +/// inside solid cell volume. 8,703 of 14,230 indoor sweeps in the same capture DID +/// collide (pull-in up to 2.77 m) — camera collision works; nothing penetrates. +/// The user-visible "background at the corner" is therefore NOT a collision bug: it is +/// the §2a edge-on portal-clip collapse with the eye hovering at the doorway plane +/// (render-side; see docs/research/2026-06-10 corner-seal handoff). +/// +/// The assertion fact below is a CHARACTERIZATION pinning the verified-correct +/// behavior: viewer sweeps along these captured opening paths must pass WITHOUT +/// collision (a future change that makes doorway air solid would break the camera). +/// +/// Capture provenance (worktree root, untracked): corner-seal-capture.log +/// L20104 / L20224 / L20386 (door-exit) + L23035 / L81340 / L87958 (corner press); +/// pivot = player + (0,0,1.5) per RetailChaseCamera.PivotHeight. +/// +public class CameraCornerSealReplayTests +{ + private readonly ITestOutputHelper _out; + public CameraCornerSealReplayTests(ITestOutputHelper output) => _out = output; + + private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314) + private const float PivotHeight = 1.5f; // RetailChaseCamera.PivotHeight + + // Captured corner-escape samples: (label, start cellId, player feet position, + // desired eye = the [flap-sweep] in= value). Player from the same frame's + // [pv-input]; S1 has both the render-interpolated and raw physics player (1 cm + // apart) — both must stop at the wall, so both are exercised. + private static readonly (string Label, uint CellId, Vector3 Player, Vector3 Eye)[] Samples = + { + ("S1-render 0170", 0xA9B40170u, new Vector3(154.934845f, 16.451527f, 94.000000f), + new Vector3(154.797028f, 19.320539f, 96.248833f)), + ("S1-raw 0170", 0xA9B40170u, new Vector3(154.945374f, 16.451527f, 94.000000f), + new Vector3(154.797028f, 19.320539f, 96.248833f)), + ("S2 0171", 0xA9B40171u, new Vector3(155.164307f, 14.392493f, 94.000000f), + new Vector3(154.804489f, 18.434128f, 96.248833f)), + ("S3 0171", 0xA9B40171u, new Vector3(155.475723f, 11.463923f, 94.000000f), + new Vector3(154.990143f, 16.249460f, 96.248833f)), + }; + + private static (PhysicsEngine, PhysicsDataCache, + System.Collections.Generic.Dictionary) + BuildBuildingEngine(DatCollection dats) + { + // Same fixture as ThresholdPortalCrossingReplayTests.BuildBuildingEngine: + // the full Holtburg building cell range + a stub far-below landblock. + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var envCells = new System.Collections.Generic.Dictionary(); + for (uint low = 0x016Fu; low <= 0x0175u; low++) + { + uint id = ConformanceDats.HoltburgLandblock | low; + envCells[id] = ConformanceDats.LoadEnvCell(dats, cache, id); + } + + 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, envCells); + } + + /// + /// Mirror of PhysicsCameraCollisionProbe.SweepEye's transition call (the App-layer + /// probe is not referencable from Core tests; the call shape is duplicated here and + /// kept in sync by the file-header provenance note). + /// + private static ResolveResult SweepViewer(PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId) + { + uint startCell = cellId; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var (pivotCell, found) = engine.AdjustPosition(cellId, pivot); + if (found) startCell = pivotCell; + } + + // InitPath sphere-center convention: shift down by the radius (ToSpherePath). + Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius); + Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius); + + return engine.ResolveWithTransition( + currentPos: begin, + targetPos: end, + cellId: startCell, + sphereRadius: ViewerSphereRadius, + sphereHeight: 0f, + stepUpHeight: 0f, + stepDownHeight: 0f, + isOnGround: false, + body: null, + moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped + | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip, + movingEntityId: 0); + } + + /// + /// Diagnostic (no assertions): per-sweep BSP dispatch trace via the A6.P1 + /// [push-back] probe family. Compares the LEAKING camera path (S1) against two + /// controls into the same north boundary: a horizontal viewer-style sweep at + /// chest height and a player-style grounded sweep. Outcome decides whether the + /// room's exterior boundary is sealed in the cell physics BSP at all (controls + /// collide ⇒ path/angle-specific miss) or genuinely open (nothing collides ⇒ + /// the building-shell GfxObj is the only enclosure and the live isViewer + /// shadow-query path is the half that failed). + /// + [Fact] + public void Diagnostic_DispatchTrace_LeakPath_vs_Controls() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (engine, cache, envCells) = BuildBuildingEngine(dats); + + var s = Samples[0]; // S1-render 0170 + Vector3 pivot = s.Player + new Vector3(0f, 0f, PivotHeight); + + RunTraced(engine, "LEAK viewer pivot->eye", () => + SweepViewer(engine, pivot, s.Eye, s.CellId)); + + RunTraced(engine, "CTRL-H viewer horizontal +3Y", () => + SweepViewer(engine, pivot, pivot + new Vector3(0f, 3f, 0f), s.CellId)); + + RunTraced(engine, "CTRL-P player grounded +3Y", () => + engine.ResolveWithTransition( + currentPos: s.Player, + targetPos: s.Player + new Vector3(0f, 3f, 0f), + cellId: s.CellId, + sphereRadius: 0.4f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.1f, + isOnGround: true, + body: null, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0)); + + // ── Geometry map: what does the leak path actually cross? ────────────── + _out.WriteLine("=== containment along LEAK path (pivot -> eye, 11 steps) ==="); + for (int i = 0; i <= 10; i++) + { + Vector3 p = Vector3.Lerp(pivot, s.Eye, i / 10f); + string inCells = ""; + foreach (var kv in envCells) + if (kv.Value.PointInCell(p)) + inCells += $" 0x{kv.Key & 0xFFFFu:X4}"; + if (inCells.Length == 0) inCells = " (none)"; + _out.WriteLine(System.FormattableString.Invariant( + $" t={i / 10f:F1} ({p.X:F2},{p.Y:F2},{p.Z:F2}) ->{inCells}")); + } + + _out.WriteLine("=== full room map: origin + portals (cell-LOCAL polygon bounds) ==="); + foreach (uint low in new uint[] { 0x016Fu, 0x0170u, 0x0171u, 0x0172u, 0x0173u, 0x0174u, 0x0175u }) + { + var cp = cache.GetCellStruct(ConformanceDats.HoltburgLandblock | low); + if (cp is null) { _out.WriteLine($" 0x{low:X4}: no CellPhysics"); continue; } + var o = cp.WorldTransform.Translation; + _out.WriteLine(System.FormattableString.Invariant( + $" 0x{low:X4} origin=({o.X:F2},{o.Y:F2},{o.Z:F2}) portals={cp.Portals.Count}")); + foreach (var portal in cp.Portals) + { + string bounds = "(polygon missing)"; + if (cp.PortalPolygons is not null + && cp.PortalPolygons.TryGetValue(portal.PolygonId, out var poly)) + { + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (var v in poly.Vertices) + { + min = Vector3.Min(min, v); + max = Vector3.Max(max, v); + } + bounds = System.FormattableString.Invariant( + $"x[{min.X:F2},{max.X:F2}] y[{min.Y:F2},{max.Y:F2}] z[{min.Z:F2},{max.Z:F2}]"); + } + _out.WriteLine(System.FormattableString.Invariant( + $" -> other=0x{portal.OtherCellId:X4} poly={portal.PolygonId} {bounds}")); + } + } + + // ── Containment for the live 0172->0171 corner frames (captured eye points + // + their pivots). The corner press lived in room 0172; the viewer classified + // into 0171 (2,963 frames). Which cell volume actually contains those points? + _out.WriteLine("=== containment: corner-press eyes + pivots (player=0172 viewer=0171 frames) ==="); + var cornerPoints = new (string Label, Vector3 P)[] + { + ("eyeA (L23035)", new Vector3(154.607880f, 11.154252f, 96.248787f)), + ("eyeB (L81340)", new Vector3(157.477249f, 7.912723f, 96.248863f)), + ("eyeC (L87958)", new Vector3(157.452667f, 7.914233f, 96.248856f)), + ("pivotA", new Vector3(157.976959f, 8.622595f, 95.500000f)), + ("pivotB/C", new Vector3(159.936676f, 7.701012f, 95.500000f)), + }; + foreach (var (label, p) in cornerPoints) + { + string inCells = ""; + foreach (var kv in envCells) + if (kv.Value.PointInCell(p)) + inCells += $" 0x{kv.Key & 0xFFFFu:X4}"; + if (inCells.Length == 0) inCells = " (none)"; + _out.WriteLine(System.FormattableString.Invariant( + $" {label} ({p.X:F2},{p.Y:F2},{p.Z:F2}) ->{inCells}")); + } + } + + private void RunTraced(PhysicsEngine engine, string label, Func sweep) + { + var sw = new StringWriter(); + var prev = Console.Out; + ResolveResult r; + try + { + Console.SetOut(sw); + PhysicsDiagnostics.ProbePushBackEnabled = true; + r = sweep(); + } + finally + { + PhysicsDiagnostics.ProbePushBackEnabled = false; + Console.SetOut(prev); + } + + var lines = sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + _out.WriteLine(System.FormattableString.Invariant( + $"=== {label}: end=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cell=0x{r.CellId:X8} collNorm={r.CollisionNormalValid} ok={r.Ok} probeLines={lines.Length} ===")); + for (int i = 0; i < lines.Length && i < 40; i++) + _out.WriteLine(" " + lines[i].TrimEnd()); + if (lines.Length > 40) + _out.WriteLine($" ... +{lines.Length - 40} more"); + } + + [Fact] + public void ViewerSweep_ThroughOpenings_PassesWithoutCollision() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (engine, _, _) = BuildBuildingEngine(dats); + + var failures = new System.Collections.Generic.List(); + foreach (var s in Samples) + { + Vector3 pivot = s.Player + new Vector3(0f, 0f, PivotHeight); + float desiredBack = Vector3.Distance(pivot, s.Eye); + + var r = SweepViewer(engine, pivot, s.Eye, s.CellId); + + Vector3 eyeOut = r.Position + new Vector3(0f, 0f, ViewerSphereRadius); + float eyeBack = Vector3.Distance(pivot, eyeOut); + float pulledIn = desiredBack - eyeBack; + + // Characterization (see file header): these captured paths run through + // real openings (exit door / doorway-threshold chain). The geometry-map + // diagnostic verified no solid is crossed, so the sweep must pass clean — + // no collision, full traversal. + bool passedClean = !r.CollisionNormalValid && pulledIn < 0.10f && r.Ok; + + _out.WriteLine(System.FormattableString.Invariant( + $"{s.Label}: ok={r.Ok} collNorm={r.CollisionNormalValid} desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={pulledIn:F2} endCell=0x{r.CellId:X8} {(passedClean ? "CLEAN" : "OBSTRUCTED")}")); + + if (!passedClean) + failures.Add(s.Label); + } + + Assert.True(failures.Count == 0, + "Viewer sweep through a verified-open doorway path was obstructed or cut short " + + "(camera would wrongly pull in at openings) for: " + string.Join(", ", failures)); + } +}