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:
parent
a11df5b8d3
commit
e8651b3819
7 changed files with 581 additions and 14 deletions
|
|
@ -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
|
openings that should block / blocking at openings that should pass, and door collision not
|
||||||
matching the door's open/closed state.)
|
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)
|
**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
|
1. **PortalSide portal polygons are IN the physics polygon set and we treat
|
||||||
them as solid.** Live: running the corridor, the seam crossing
|
them as solid.** Live: running the corridor, the seam crossing
|
||||||
`0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal
|
`0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
# Pickup prompt — #137 corridor phantom collision (paste into a fresh session)
|
# 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
|
Read `claude-memory/project_physics_collision_digest.md` FIRST (binding
|
||||||
DO-NOT-RETRY table), then **ISSUES #137** (the 2026-07-05 CHARACTERIZED
|
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
|
section — the full evidence chain lives there), then this file. The 2026-07-05
|
||||||
|
|
|
||||||
|
|
@ -69,14 +69,47 @@ engine-level wall lifecycle pin (persist-on-block via validate →
|
||||||
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
|
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
|
||||||
state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385).
|
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
|
Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory
|
||||||
longer persist a phantom normal, so the corridor dead-stop degrades to
|
entirely; **no cdb session needed for this repro**:
|
||||||
whatever mechanism 1 actually produces — a momentary stutter if the portal
|
|
||||||
poly only face-hits in the crossing window, or an honest (re-tested,
|
1. **The recorded hit normal matches NO polygon.** A world-space sweep of
|
||||||
strafe-escapable) wall if it keeps colliding. **Mechanism 1 — PortalSide
|
both seam cells + every portal-adjacent neighbor
|
||||||
portal polygons being solid in our physics set — is still open**; the
|
(`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`)
|
||||||
pickup's step −1 cdb session (trace whether retail's BSP test chain ever
|
found zero physics polygons within 18° of `(−1.00,0.03,−0.03)` near the
|
||||||
TESTS those polys, or membership order never reaches them from the passable
|
hit point. The normal is the player's **negated movement direction** — a
|
||||||
side) remains the next action, and needs the user to run retail.
|
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).
|
||||||
|
|
|
||||||
|
|
@ -3730,15 +3730,25 @@ public sealed class Transition
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opposing normals: give up, reverse direction.
|
// Opposing normals (collision normal anti-parallel to the contact
|
||||||
// Retail returns OK here to allow retry with the reversed normal.
|
// 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;
|
Vector3 reversed = -gDelta;
|
||||||
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||||
{
|
{
|
||||||
reversed = Vector3.Normalize(reversed);
|
reversed = Vector3.Normalize(reversed);
|
||||||
ci.SetCollisionNormal(reversed);
|
ci.SetCollisionNormal(reversed);
|
||||||
}
|
}
|
||||||
return TransitionState.OK;
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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] = &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| > 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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -172,6 +172,42 @@ public class Issue137SlidingNormalLifecycleTests
|
||||||
Assert.Equal(TransitionState.Collided, result);
|
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
|
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
|
||||||
// REAL wall. Guards the fix against regressing wall behavior, and
|
// REAL wall. Guards the fix against regressing wall behavior, and
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue