using System; using System.IO; using System.Numerics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; using Env = System.Environment; using Plane = System.Numerics.Plane; namespace AcDream.Core.Tests.Physics; /// /// #137 corridor-seam replay (2026-07-06) — dat-backed reproduction of the /// Facility Hub phantom hit (launch-175-verify2.log:42858): running +X down /// the corridor, crossing 0x8A02016E → 0x8A02017A at x≈85.25, the live /// client recorded `ok=True hit=yes n=(−1.00,0.03,−0.03)` with full advance, /// persisted the sliding normal, and every later forward resolve absorbed to /// zero (`ok=False hit=no`). /// /// /// Dat facts pinned by : /// neither corridor cell (nor any portal-adjacent neighbor) has a physics /// polygon whose plane matches that normal near the hit point — the recorded /// normal is SYNTHETIC (the negated movement direction), which is exactly /// what slide_sphere's opposing-normals branch records. Retail /// (CSphere::slide_sphere 0x00537440 @0x0053762c) returns /// COLLIDED_TS from that branch; our port returned OK — letting the step /// complete with full advance and the synthetic normal persisted. /// /// /// /// This replay drives the real engine over the real dat cells with the /// live-log positions and player dimensions, and pins: the seam crossing /// must complete WITHOUT persisting a sliding normal, and continued forward /// running must keep advancing (no absorbing wedge). /// /// public class Issue137CorridorSeamReplayTests { private readonly ITestOutputHelper _out; public Issue137CorridorSeamReplayTests(ITestOutputHelper output) => _out = output; private const uint SeamCellWest = 0x8A02016Eu; private const uint SeamCellEast = 0x8A02017Au; private static string? FindDatDir() { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); return Directory.Exists(datDir) ? datDir : null; } /// /// Hydrate the two seam cells + every portal-adjacent neighbor into a /// PhysicsEngine, exactly as the streaming path does (CacheCellStruct /// with the dat world transform). /// private static PhysicsEngine BuildCorridorEngine(DatCollection dats) { var engine = new PhysicsEngine(); engine.DataCache = new PhysicsDataCache(); var toLoad = new System.Collections.Generic.HashSet { SeamCellWest, SeamCellEast }; foreach (var seed in new[] { SeamCellWest, SeamCellEast }) { var seedCell = dats.Get(seed); Assert.NotNull(seedCell); foreach (var p in seedCell!.CellPortals) toLoad.Add(0x8A020000u | p.OtherCellId); } // Expand three portal rings — the live collision cell array reaches // cells three hops out (0x8A020166, the under-ramp room whose ceiling // is the ramp slab's underside, is ring-3 in the 2026-07-06 // seam-shake trace; with only two rings the offline flood can never // add it and the shake does not reproduce). for (int ring = 0; ring < 3; ring++) { foreach (var known in new System.Collections.Generic.List(toLoad)) { var cell = dats.Get(known); if (cell is null) continue; foreach (var p in cell.CellPortals) toLoad.Add(0x8A020000u | p.OtherCellId); } } foreach (var cellId in toLoad) { var envCell = dats.Get(cellId); if (envCell is null) continue; var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); if (environment is null) continue; if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue; var rot = new Quaternion( envCell.Position.Orientation.X, envCell.Position.Orientation.Y, envCell.Position.Orientation.Z, envCell.Position.Orientation.W); var world = Matrix4x4.CreateFromQuaternion(rot) * Matrix4x4.CreateTranslation( envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world); } return engine; } private static PhysicsBody GroundedBody() { var body = new PhysicsBody(); body.ContactPlaneValid = true; // Corridor floor at world z = −6 → n·p + d = 0 with n = +Z, d = 6. body.ContactPlane = new Plane(Vector3.UnitZ, 6f); body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; // The live session carried a walkable polygon (walkable=True on every // [resolve] line) — seed the corridor floor slab so the transition's // SetWalkable path runs like live. body.WalkablePolygonValid = true; body.WalkablePlane = new Plane(Vector3.UnitZ, 6f); body.WalkableUp = Vector3.UnitZ; body.WalkableVertices = new[] { new Vector3(75f, -41.67f, -6f), new Vector3(85f, -41.67f, -6f), new Vector3(85f, -38.33f, -6f), new Vector3(75f, -38.33f, -6f), }; return body; } private ResolveResult Resolve(PhysicsEngine engine, PhysicsBody body, Vector3 from, Vector3 to, uint cellId) => engine.ResolveWithTransition( currentPos: from, targetPos: to, cellId: cellId, sphereRadius: 0.48f, // human player, PlayerMovementController:885 sphereHeight: 1.2f, // human player, PlayerMovementController:886 stepUpHeight: 0.4f, // PlayerMovementController defaults stepDownHeight: 0.4f, isOnGround: true, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); /// /// 2026-07-06 seam-shake repro, snapshot-exact (probe session /// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101, /// repeated ×46): running WEST across the x=75 boundary /// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, −40.035, −6) /// toward (74.685, −39.988, −6), the resolve blocks with the SYNTHETIC /// reversed-movement normal (0.997, −0.078, −0.002) and out==in — every /// frame — the "shaking at the seam" report. /// /// /// Probe-traced chain: the foot sphere (tangent to the floor) crosses /// onto 0165's ramp floor; the ramp slab is double-faced and the /// UNDERSIDE face (poly 0, n=(−0.03,0,−1)) grazes the sphere within the /// hit threshold → recorded as a foot near-miss → neg-poly step-up /// dispatch with a downward normal → the nested step-up's walkable probe /// rejects the exactly-tangent ramp floor ([walkable-nearest] /// gap=−0.0000 overlapsSphere=False) → StepUpSlide → /// slide_sphere(downward normal vs up-facing contact plane) → the /// opposing-normals branch → Collided → revert. Repeat. /// /// [Fact] public void SeamShake_WestBoundary_SnapshotExact_Advances() { var datDir = FindDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dat directory not found"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var engine = BuildCorridorEngine(dats); // Body seeded EXACTLY from the capture's bodyBefore (tick 4101). var body = new PhysicsBody(); body.ContactPlaneValid = true; body.ContactPlane = new Plane(Vector3.UnitZ, 6f); body.ContactPlaneCellId = SeamCellWest; body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; body.WalkablePolygonValid = true; body.WalkablePlane = new Plane(Vector3.UnitZ, 6f); body.WalkableUp = Vector3.UnitZ; body.WalkableVertices = new[] { new Vector3(75f, -38.33333f, -6f), new Vector3(75f, -41.66667f, -6f), new Vector3(78.33333f, -41.66667f, -6f), new Vector3(78.33333f, -38.33333f, -6f), }; var from = new Vector3(75.28674f, -40.03537f, -6f); var to = new Vector3(74.6854f, -39.988018f, -6f); // Emit the same step-level probes the live session logged so the // offline trace can be line-diffed against launch-137-seam-probes.log // — the first divergent line names the state the replay is missing. var probeBuffer = new System.IO.StringWriter(); var prevOut = Console.Out; ResolveResult r1; try { Console.SetOut(probeBuffer); PhysicsDiagnostics.ProbeStepWalkEnabled = true; PhysicsDiagnostics.ProbePushBackEnabled = true; PhysicsDiagnostics.ProbeIndoorBspEnabled = true; r1 = engine.ResolveWithTransition( currentPos: from, targetPos: to, cellId: SeamCellWest, sphereRadius: 0.48f, sphereHeight: 1.2f, stepUpHeight: 0.6f, // live Setup values from the capture stepDownHeight: 1.5f, isOnGround: true, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); } finally { PhysicsDiagnostics.ProbeStepWalkEnabled = false; PhysicsDiagnostics.ProbePushBackEnabled = false; PhysicsDiagnostics.ProbeIndoorBspEnabled = false; Console.SetOut(prevOut); } _out.WriteLine(probeBuffer.ToString()); _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}"); Assert.True(r1.Position.X < from.X - 0.3f, $"The westward boundary crossing onto the ramp must advance " + $"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " + $"advance with the reversed-movement normal = the seam shake."); } /// /// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log): /// running from the ramp top in 0x8A020179 into the corridor-end opening /// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the /// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) → /// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head /// (and camera) through the opening's roof. The opening is ~1.3 m tall /// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry /// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom: /// "should not be able to run into it". /// [Fact] public void WindowOpening_HeadCannotFit_EntryBlocked() { var datDir = FindDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dat directory not found"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var engine = BuildCorridorEngine(dats); var body = new PhysicsBody(); body.ContactPlaneValid = true; body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level body.ContactPlaneCellId = 0x8A020179u; body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; // Walk the live approach (ramp-top toward the corridor-end opening) // so the engine self-accumulates its contact-plane/walkable state, // then push into the opening for several held-key frames (the live // climb happened under a held key, not a single resolve). var pos = new Vector3(88.60f, -41.10f, -5.05f); uint cell = 0x8A020179u; ResolveResult r = default; bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1"; for (int i = 0; i < 22; i++) { var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f)); var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f; var probeBuffer = new System.IO.StringWriter(); var prevOut = Console.Out; try { if (probeFrames && i >= 9) { Console.SetOut(probeBuffer); PhysicsDiagnostics.ProbeStepWalkEnabled = true; PhysicsDiagnostics.ProbeIndoorBspEnabled = true; } r = engine.ResolveWithTransition( currentPos: pos, targetPos: pos + step, cellId: cell, sphereRadius: 0.48f, // #137: the corrected capsule top (dat Setup 0x02000001, // head sphere center 1.350 → top 1.830; Height 1.835). // The live climb happened under the old 1.2f (head top // 1.2 m — no head collision at the lintel). sphereHeight: 1.835f, stepUpHeight: 0.6f, stepDownHeight: 1.5f, isOnGround: true, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); } finally { if (probeFrames && i >= 9) { PhysicsDiagnostics.ProbeStepWalkEnabled = false; PhysicsDiagnostics.ProbeIndoorBspEnabled = false; Console.SetOut(prevOut); } } if (probeFrames && i >= 9 && i <= 10) _out.WriteLine(probeBuffer.ToString()); _out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + $"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " + $"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})"); pos = r.Position; cell = r.CellId; Assert.NotEqual(0x8A02017Eu, r.CellId); Assert.True(r.Position.Y > -41.6f, $"A 1.68 m character must not enter the 1.3 m-tall opening " + $"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " + $"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " + $"0x8A02017E, head through the roof)."); } } /// /// The window-climb's placement half, pinned at the exact site: at the /// step-up's raised position on the alcove sill (foot −5.019), the HEAD /// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south /// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's /// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's /// step-down placement insert (CTransition::step_down 0x0050b3b3 → /// placement transitional_insert → BSPTREE::sphere_intersects_solid /// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable. /// Our placement passed (the live + offline climb), so our Path-1 solid /// test misses the head-vs-solid overlap. /// [Fact] public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides() { var datDir = FindDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dat directory not found"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var engine = BuildCorridorEngine(dats); var cell = engine.DataCache!.GetCellStruct(0x8A020179u); Assert.NotNull(cell); Assert.NotNull(cell!.BSP?.Root); // The raised (post-sill-climb) pose from the offline repro's r9. var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform); var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform); var t = new Transition(); t.SpherePath.InitPath( new Vector3(89.683f, -41.247f, -5.019f), new Vector3(89.683f, -41.247f, -5.019f), 0x8A020179u, 0.48f, 1.2f); t.SpherePath.InsertType = InsertType.Placement; Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin); var result = BSPQuery.FindCollisions( cell.BSP!.Root, cell.Resolved, t, new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f }, new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f }, footLocal, Vector3.UnitZ, 1.0f, cellRot, engine, worldOrigin: cellOrigin); _out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " + $"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})"); Assert.Equal(TransitionState.Collided, result); } /// /// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing /// at (84.851, −39.764, −6.000) — the foot sphere already straddling the /// x=85 cell boundary by 0.33 m — the first move attempt toward /// (85.453, −39.782) blocked with the synthetic reversed-movement normal /// (−1.00, 0.03, −0.02), out==in, cp lost (cp=none), and repeated every /// frame (the "shaking at the seam" report). The deeper straddle start is /// what the original replay frame (84.638 → 85.253) didn't cover. /// [Fact] public void SeamCrossing_FromDeepStraddleStart_Advances() { var datDir = FindDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dat directory not found"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var engine = BuildCorridorEngine(dats); var body = GroundedBody(); var from = new Vector3(84.851f, -39.764f, -6.000f); var to = new Vector3(85.453f, -39.782f, -6.000f); var r1 = Resolve(engine, body, from, to, SeamCellWest); _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " + $"bodyCpValid={body.ContactPlaneValid}"); Assert.True(r1.Position.X > from.X + 0.2f, $"The straddling-start seam crossing must advance " + $"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " + $"reversed-movement normal = the 2026-07-06 seam shake."); } [Fact] public void SeamCrossing_DoesNotPersistSyntheticSlidingNormal_AndRunContinues() { var datDir = FindDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dat directory not found"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var engine = BuildCorridorEngine(dats); var body = GroundedBody(); // ── The live hit frame verbatim (launch-175-verify2.log:42858) ── var from = new Vector3(84.638f, -39.758f, -6.000f); var to = new Vector3(85.253f, -39.776f, -6.000f); var r1 = Resolve(engine, body, from, to, SeamCellWest); _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " + $"slidingN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})"); // The corridor is straight and open: the crossing must not leave the // body carrying a sliding normal (there is no wall to slide on — // Issue137CorridorSeamInspectionTests proved no polygon matches the // live-recorded normal; retail's slide_sphere opposing branch returns // COLLIDED and its validate handling never lets a synthetic // reversed-movement normal survive a clean corridor run). Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding), "Crossing the open corridor seam must not persist a sliding " + "normal — the live wedge's entry state (#137 mechanism 2)."); // ── Keep running +X (the live session's held-W frames) ────────── var pos = r1.Position; var cell = r1.CellId; for (int i = 0; i < 6; i++) { var step = new Vector3(0.13f, -0.004f, 0f); // ~run speed per tick, same heading var r = Resolve(engine, body, pos, pos + step, cell); _out.WriteLine($"r{i + 2}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + $"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " + $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}"); Assert.True(r.Position.X > pos.X + 0.05f, $"Forward run must keep advancing through the open corridor " + $"(frame {i + 2}: {pos.X:F3} → {r.Position.X:F3}) — zero advance " + $"= the #137 absorbing wedge."); pos = r.Position; cell = r.CellId; } } }