fix #137 (corridor phantom resolved): slide_sphere opposing branch returns Collided; the 'wall' was synthetic

The mechanism-1 theory (PortalSide portal polys solid in our physics set)
is REFUTED for the corridor repro, and the remaining half of the phantom
is fixed — no cdb session needed:

- The live hit normal (-1.00,0.03,-0.03) matches NO dat polygon: a
  world-space sweep of both seam cells + every portal-adjacent neighbor
  (CorridorSeam_FindPolygonMatchingLiveHit) returns zero candidates. The
  normal is the negated movement direction — the SYNTHETIC value
  slide_sphere's opposing-normals branch records (reversed = -gDelta).
- Cell 0x8A02016E has IDENTITY rotation (the prior session's 'rotation
  maps the portal planes into the -X wall' was a misattribution). The
  PortalSide polys to 0x011E are +-Y planes 1.4 m beside the player's
  track, perpendicular to the +X run — pos_hits_sphere's directional
  cull rejects them for that movement. They ARE referenced by the dat's
  physics-BSP leaves (CorridorCell_PhysicsBspLeafMembership), so retail
  tests them too when approached into their plane; the dat's
  keep-PortalSide / strip-ExactMatch asymmetry reads as intentional
  (solid window/grate-class portals). No portal-poly filter — exactly
  the blanket-skip the pickup warned against.
- Port fix: CSphere::slide_sphere's opposing-normals branch
  (0x005375d7-0x0053762c) records the reversed displacement and returns
  COLLIDED_TS; our port returned OK ('retail returns OK here' was a
  decomp misread), letting the step complete as-is with the synthetic
  collision normal that validate's epilogue then persisted as the
  sliding normal the wedge absorbed on. TransitionTypes opposing branch
  now returns Collided; pinned by
  SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal
  (RED->GREEN).
- Dat-backed replay (Issue137CorridorSeamReplayTests) reproduces the
  live hit frame verbatim (same in/out to the millimeter, same 016E->017A
  transit, same +8mm settle) and runs the corridor CLEAN: hit=no, no
  sliding normal persisted, six further forward frames advance freely.
- Inspection tests extended: physics-BSP leaf membership walk +
  hit-normal candidate sweep + downward-poly sweep (all report-style,
  dat-gated). Pickup prompt banner'd SUPERSEDED; ISSUES #137 updated
  (door half stays open); audit doc extended with the resolution.

Suites: Core 2551 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 18:27:40 +02:00
parent a11df5b8d3
commit e8651b3819
7 changed files with 581 additions and 14 deletions

View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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;
}
// -----------------------------------------------------------------------

View file

@ -87,4 +87,275 @@ public class Issue137CorridorSeamInspectionTests
}
}
}
/// <summary>
/// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's
/// <c>PhysicsPolygons</c> TABLE does not mean the physics BSP ever tests a
/// polygon — retail's <c>BSPLEAF::sphere_intersects_poly</c> (0x0053d580)
/// iterates the LEAF's <c>in_polys</c> index list (leaf construction
/// 0x0053d4a0: <c>in_polys[i] = &amp;pack_poly[index]</c>), 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).
/// </summary>
[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<EnvCell>(envCellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
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<ushort>();
var portalPolyLeafHits = new System.Collections.Generic.List<string>();
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<ushort>();
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}");
}
}
/// <summary>
/// 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.
/// </summary>
[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<uint>
{
0x8A02016Eu, 0x8A02017Au,
};
foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au })
{
var seedCell = dats.Get<EnvCell>(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<EnvCell>(cellId);
if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; }
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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<ushort>();
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)");
}
/// <summary>
/// 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| &gt; 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.
/// </summary>
[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<EnvCell>(cellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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<System.Numerics.Vector3>();
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)");
}
}

View file

@ -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;
/// <summary>
/// #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`).
///
/// <para>
/// Dat facts pinned by <see cref="Issue137CorridorSeamInspectionTests"/>:
/// 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
/// (<c>CSphere::slide_sphere</c> 0x00537440 @0x0053762c) returns
/// COLLIDED_TS from that branch; our port returned OK — letting the step
/// complete with full advance and the synthetic normal persisted.
/// </para>
///
/// <para>
/// 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).
/// </para>
/// </summary>
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;
}
/// <summary>
/// Hydrate the two seam cells + every portal-adjacent neighbor into a
/// PhysicsEngine, exactly as the streaming path does (CacheCellStruct
/// with the dat world transform).
/// </summary>
private static PhysicsEngine BuildCorridorEngine(DatCollection dats)
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
foreach (var seed in new[] { SeamCellWest, SeamCellEast })
{
var seedCell = dats.Get<EnvCell>(seed);
Assert.NotNull(seedCell);
foreach (var p in seedCell!.CellPortals)
toLoad.Add(0x8A020000u | p.OtherCellId);
}
foreach (var cellId in toLoad)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) continue;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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;
}
}
}

View file

@ -172,6 +172,42 @@ public class Issue137SlidingNormalLifecycleTests
Assert.Equal(TransitionState.Collided, result);
}
/// <summary>
/// <c>CSphere::slide_sphere</c>'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 <b>COLLIDED_TS</b> — retail 0x00537440
/// @0x005375d7-0x0053762c: <c>*normal = gDelta; normalize;
/// set_collision_normal; return 2</c>. 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).
/// </summary>
[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