diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a5aff0ea..fcb4747b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1183,8 +1183,27 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr openings that should block / blocking at openings that should pass, and door collision not matching the door's open/closed state.) +**CORRIDOR PHANTOM FIXED 2026-07-06 (both mechanisms resolved; visual gate +pending — see `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md` +for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide +responses leaked sliding normals retail's BSP layer never writes (fixed: +real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as +theorized is REFUTED: the recorded wall normal `(−1.00,0.03,−0.03)` matches +NO dat polygon (world-space sweep of both seam cells + all portal-adjacent +neighbors) — it is the SYNTHETIC negated movement direction from +`slide_sphere`'s opposing-normals branch, which our port let survive by +returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The +PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY +rotation, the polys are ±Y planes perpendicular to the run (directionally +culled), retail's physics-BSP leaves reference them too, and the dat's +keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid +window/grate-class portals) — NO portal-poly filter needed, no cdb session +needed for this repro. Dat-backed replay +(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and +runs the corridor clean. The issue's DOOR half remains open. + **CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence) -— two stacked mechanisms:** +— two stacked mechanisms (historical; see the 2026-07-06 resolution above):** 1. **PortalSide portal polygons are IN the physics polygon set and we treat them as solid.** Live: running the corridor, the seam crossing `0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal diff --git a/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md b/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md index 2773d01e..3c6c96a2 100644 --- a/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md +++ b/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md @@ -1,5 +1,16 @@ # Pickup prompt — #137 corridor phantom collision (paste into a fresh session) +> **SUPERSEDED 2026-07-06.** The corridor phantom is FIXED (visual gate +> pending) — see `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`. +> Mechanism 2 was real (BSPQuery stub slide responses leaked sliding +> normals; fixed). Mechanism 1's framing was WRONG: the recorded wall +> normal was SYNTHETIC (slide_sphere's opposing branch + a `return OK` vs +> retail's COLLIDED_TS misport — fixed); the PortalSide polys are ±Y +> planes perpendicular to the run, directionally culled, tested by +> retail's own BSP leaves too, and plausibly legitimately solid +> (window/grate class). The step −1 cdb session below is NOT needed for +> this repro. Kept for the audit trail only. + Read `claude-memory/project_physics_collision_digest.md` FIRST (binding DO-NOT-RETRY table), then **ISSUES #137** (the 2026-07-05 CHARACTERIZED section — the full evidence chain lives there), then this file. The 2026-07-05 diff --git a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md index b23773a3..5df6df93 100644 --- a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md +++ b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md @@ -69,14 +69,47 @@ engine-level wall lifecycle pin (persist-on-block via validate → absorbed exactly-anti-parallel frame → oblique escape CLEARS the body state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385). -## What this does and does not fix in the corridor +## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed -The wedge (mechanism 2) is fixed at its entry: a full-advance hit can no -longer persist a phantom normal, so the corridor dead-stop degrades to -whatever mechanism 1 actually produces — a momentary stutter if the portal -poly only face-hits in the crossing window, or an honest (re-tested, -strafe-escapable) wall if it keeps colliding. **Mechanism 1 — PortalSide -portal polygons being solid in our physics set — is still open**; the -pickup's step −1 cdb session (trace whether retail's BSP test chain ever -TESTS those polys, or membership order never reaches them from the passable -side) remains the next action, and needs the user to run retail. +Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory +entirely; **no cdb session needed for this repro**: + +1. **The recorded hit normal matches NO polygon.** A world-space sweep of + both seam cells + every portal-adjacent neighbor + (`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`) + found zero physics polygons within 18° of `(−1.00,0.03,−0.03)` near the + hit point. The normal is the player's **negated movement direction** — a + SYNTHETIC value from `slide_sphere`'s opposing-normals branch + (`reversed = −gDelta` → `set_collision_normal`). +2. **The PortalSide polys were a red herring for this hit.** Cell + 0x8A02016E has IDENTITY rotation (the prior session's "rotation maps + them into the −X wall" was wrong); polys 1/3/5 are ±Y-normal planes at + world y≈−38.33, 1.4 m beside the player's track and PERPENDICULAR to + the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled, + 0x005394f0 tail) rejects them for that movement outright. They ARE + referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`), + so retail tests them too when approached INTO their plane — most likely + they are legitimately solid one-way/window-class geometry (which is why + the dat keeps PortalSide-only portal polys in the physics set while + removing every ExactMatch one). The pickup's warning against a blanket + "skip portal polys" filter stands — no filter is needed at all. +3. **A second slide_sphere port bug found and fixed:** the opposing-normals + branch returned OK where retail returns COLLIDED_TS + (0x005375d7-0x0053762c: `*normal = −gDelta; normalize; + set_collision_normal; return 2`). Our OK let the step complete as-is + while carrying the synthetic reversed-movement collision normal — + `validate_transition`'s epilogue then converted it into the sliding + normal the wedge absorbed on. Fixed at the same TransitionTypes site; + pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`. +4. **The dat-backed corridor replay reproduces the live frame and runs + clean** (`Issue137CorridorSeamReplayTests`): same input, same full + advance to (85.253, −39.776, −5.992), same 016E→017A transit — now + `hit=no`, no sliding normal persisted, and six further forward frames + advance freely. (The pre-fix code did NOT reproduce the wedge in the + replay — the live entry chain involved session state beyond the + replay's reach — so the replay is the CLEAN-corridor pin, not a + red/green falsification; the site-level pins in + `Issue137SlidingNormalLifecycleTests` are the red→green proof.) + +Remaining for #137: the user's corridor re-run (visual gate) + the issue's +door half (doors block/pass per open state — separate acceptance). diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 8d193894..cc21a953 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3730,15 +3730,25 @@ public sealed class Transition return TransitionState.Slid; } - // Opposing normals: give up, reverse direction. - // Retail returns OK here to allow retry with the reversed normal. + // Opposing normals (collision normal anti-parallel to the contact + // plane, e.g. a ceiling-facing normal while grounded): record the + // REVERSED displacement as the collision normal and return COLLIDED. + // Retail CSphere::slide_sphere 0x00537440 @0x005375d7-0x0053762c: + // `*normal = -gDelta; normalize_check_small; set_collision_normal; + // return 2 (COLLIDED_TS)`. #137 (2026-07-06): this previously + // returned OK ("to allow retry with the reversed normal" — a decomp + // misread), which let the step complete as-is carrying a SYNTHETIC + // reversed-movement collision normal — the live corridor hit's + // `n=(-1.00,0.03,-0.03)` (= the negated run direction) matched no + // dat polygon; validate's epilogue then turned it into a persisted + // sliding normal and wedged all forward motion. Vector3 reversed = -gDelta; if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq) { reversed = Vector3.Normalize(reversed); ci.SetCollisionNormal(reversed); } - return TransitionState.OK; + return TransitionState.Collided; } // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs index 2ea033cd..85b3302e 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs @@ -87,4 +87,275 @@ public class Issue137CorridorSeamInspectionTests } } } + + /// + /// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's + /// PhysicsPolygons TABLE does not mean the physics BSP ever tests a + /// polygon — retail's BSPLEAF::sphere_intersects_poly (0x0053d580) + /// iterates the LEAF's in_polys index list (leaf construction + /// 0x0053d4a0: in_polys[i] = &pack_poly[index]), and our + /// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump + /// answers: do the physics-BSP LEAVES of the corridor cells reference the + /// portal polygons? If yes, retail's own BSP query would test them too + /// (→ the passable mechanism must be transit/approach-side — the cdb + /// question). If no, our collision is testing polys retail never reaches + /// (→ a desk-fixable acdream divergence). + /// + [Theory] + [InlineData(0x8A02016Eu)] + [InlineData(0x8A02017Au)] + public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId) + { + 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); + + var envCell = dats.Get(envCellId); + Assert.NotNull(envCell); + var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); + + var portalPolyIds = new System.Collections.Generic.HashSet(); + foreach (var p in envCell.CellPortals) + portalPolyIds.Add((ushort)p.PolygonId); + + _out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ==="); + _out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " + + $"portalPolyIds=[{string.Join(",", portalPolyIds)}] " + + $"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]"); + + var root = cs.PhysicsBSP?.Root; + Assert.NotNull(root); + + int leafCount = 0; + var leafPolyIds = new System.Collections.Generic.HashSet(); + var portalPolyLeafHits = new System.Collections.Generic.List(); + var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>(); + stack.Push((root!, "R")); + while (stack.Count > 0) + { + var (n, path) = stack.Pop(); + if (n.Polygons is { Count: > 0 }) + { + leafCount++; + foreach (var pid in n.Polygons) + { + leafPolyIds.Add(pid); + if (portalPolyIds.Contains(pid)) + portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])"); + } + } + if (n.PosNode is not null) stack.Push((n.PosNode, path + "+")); + if (n.NegNode is not null) stack.Push((n.NegNode, path + "-")); + } + + _out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]"); + var tableNotInLeaves = new System.Collections.Generic.List(); + foreach (var pid in cs.PhysicsPolygons.Keys) + if (!leafPolyIds.Contains(pid)) + tableNotInLeaves.Add(pid); + _out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]"); + + if (portalPolyLeafHits.Count == 0) + { + _out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " + + "retail's sphere_intersects_poly never tests them from this cell's BSP."); + } + else + { + foreach (var hit in portalPolyLeafHits) + _out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}"); + } + } + + /// + /// Mechanism-1 re-characterization (2026-07-06): the live hit normal + /// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO + /// physics polygon of either corridor cell — 0x8A02016E (identity + /// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys, + /// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes + /// 1.4 m north of the player's track, perpendicular to the +X run — the + /// pos_hits_sphere directional cull rejects them for this movement. This + /// sweep hunts the ACTUAL culprit: every physics poly of the seam cell + + /// all portal-adjacent neighbors, world-transformed, scored against the + /// hit point + normal. + /// + [Fact] + public void CorridorSeam_FindPolygonMatchingLiveHit() + { + 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); + + // Live evidence (launch-175-verify2.log:42858). + var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f); + var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f); + hitNormal = System.Numerics.Vector3.Normalize(hitNormal); + const float sphereRadius = 0.48f; + + // Seam cells + every portal-adjacent neighbor of both. + var cellIds = new System.Collections.Generic.HashSet + { + 0x8A02016Eu, 0x8A02017Au, + }; + foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au }) + { + var seedCell = dats.Get(seed); + if (seedCell is null) continue; + foreach (var p in seedCell.CellPortals) + cellIds.Add(0x8A020000u | p.OtherCellId); + } + + foreach (var cellId in cellIds) + { + var envCell = dats.Get(cellId); + if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; } + var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); + if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) + continue; + + var rot = new System.Numerics.Quaternion( + envCell.Position.Orientation.X, envCell.Position.Orientation.Y, + envCell.Position.Orientation.Z, envCell.Position.Orientation.W); + var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) + * System.Numerics.Matrix4x4.CreateTranslation( + envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); + + var portalPolyIds = new System.Collections.Generic.HashSet(); + foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId); + + foreach (var (id, poly) in cs!.PhysicsPolygons) + { + var verts = poly.VertexIds; + if (verts.Count < 3) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; + + var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); + var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); + var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); + var n = System.Numerics.Vector3.Normalize( + System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); + + float align = System.Numerics.Vector3.Dot(n, hitNormal); + if (align < 0.95f) continue; // within ~18° of the recorded normal + + // Plane distance from the hit point. + float d = -System.Numerics.Vector3.Dot(n, w0); + float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d; + if (MathF.Abs(dist) > sphereRadius + 0.1f) continue; + + // Rough proximity: hit point near the polygon's vertex span. + float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f; + float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f; + float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f; + float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f; + if (hitPoint.X < minX || hitPoint.X > maxX || + hitPoint.Y < minY || hitPoint.Y > maxY) continue; + + _out.WriteLine( + $">>> CANDIDATE cell=0x{cellId:X8} poly={id} " + + $"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " + + $"isPortalPoly={portalPolyIds.Contains(id)} " + + $"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " + + $"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); + } + } + _out.WriteLine("(sweep complete)"); + } + + /// + /// Entry-poly hunt: the synthetic reversed-movement collision normal is + /// produced by slide_sphere's opposing-normals branch, which needs an + /// INPUT collision normal anti-parallel to the grounded contact plane — + /// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were + /// filtered out of the wall dump (|n.Z| > 0.3). Sweep both corridor + /// cells for downward polys near the seam column and print where their + /// planes sit relative to the player's head sphere. + /// + [Fact] + public void CorridorSeam_DownwardPolysNearSeam() + { + 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); + + foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au }) + { + var envCell = dats.Get(cellId); + Assert.NotNull(envCell); + var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); + + var rot = new System.Numerics.Quaternion( + envCell.Position.Orientation.X, envCell.Position.Orientation.Y, + envCell.Position.Orientation.Z, envCell.Position.Orientation.W); + var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) + * System.Numerics.Matrix4x4.CreateTranslation( + envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); + + _out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ==="); + foreach (var (id, poly) in cs!.PhysicsPolygons) + { + var verts = poly.VertexIds; + if (verts.Count < 3) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; + if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; + + var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); + var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); + var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); + var n = System.Numerics.Vector3.Normalize( + System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); + if (n.Z > -0.3f) continue; + + // Only near the seam column the player crossed. + float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)); + float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)); + if (maxX < 83.5f || minX > 87.0f) continue; + + var allW = new System.Collections.Generic.List(); + foreach (var vid in verts) + if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv)) + allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world)); + float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; + foreach (var w in allW) + { + minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z); + minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y); + } + + _out.WriteLine( + $" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " + + $"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " + + $"sides={poly.SidesType} stip={poly.Stippling}"); + } + } + _out.WriteLine("(downward sweep complete)"); + } } diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs new file mode 100644 index 00000000..56930097 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs @@ -0,0 +1,187 @@ +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); + } + + 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); + + [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; + } + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs index a5e2899b..98406131 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs @@ -172,6 +172,42 @@ public class Issue137SlidingNormalLifecycleTests Assert.Equal(TransitionState.Collided, result); } + /// + /// CSphere::slide_sphere's opposing-normals branch (collision + /// normal anti-parallel to the contact plane — e.g. a ceiling-facing + /// normal while grounded) records the REVERSED displacement as the + /// collision normal and returns COLLIDED_TS — retail 0x00537440 + /// @0x005375d7-0x0053762c: *normal = −gDelta; normalize; + /// set_collision_normal; return 2. Our port returned OK (its comment + /// even claimed "retail returns OK here"), letting the step complete + /// as-is with a synthetic reversed-movement collision normal — the exact + /// signature of the live corridor hit (`hit=yes n=(−1.00,0.03,−0.03)` = + /// the negated run direction, matching NO dat polygon). + /// + [Fact] + public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal() + { + var t = new Transition(); + t.SpherePath.InitPath( + new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f), + 0xA9B40001u, BSPStepUpFixtures.SphereRadius); + t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false); + + // Make gDelta exactly (0.4, 0, 0): currPos = check sphere − (0.4,0,0). + var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f); + + // Downward collision normal vs the +Z contact plane → cross ≈ 0 + // (parallel), dot = −1 < 0 (opposing) → the reverse branch. + var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos); + + Assert.Equal(TransitionState.Collided, result); + Assert.True(t.CollisionInfo.CollisionNormalValid); + Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f, + $"Collision normal must be the normalized reversed displacement " + + $"(−1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," + + $"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3})."); + } + // ========================================================================= // Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a // REAL wall. Guards the fix against regressing wall behavior, and