acdream/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs
Erik e8651b3819 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>
2026-07-05 18:27:40 +02:00

361 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.IO;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe
/// evidence (launch-175-verify2.log:42858): crossing corridor cells
/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal
/// (1,0,0) — pointing straight back against the movement — after which the
/// stale sliding normal wedges all forward motion (ok=False hit=no, offset
/// projected to zero). Question this dump answers: does cell 0x8A02017A's
/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane
/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in
/// our collision set where retail filters them?
/// </summary>
public class Issue137CorridorSeamInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output;
[Theory]
[InlineData(0x8A02016Eu)]
[InlineData(0x8A02017Au)]
public void CorridorCell_PhysicsPolysAndPortals_DatInspection(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);
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
_out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " +
$"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})");
_out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}");
_out.WriteLine($" CellPortals={envCell.CellPortals.Count}");
foreach (var p in envCell.CellPortals)
_out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}");
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
_out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)");
foreach (var (id, poly) in cs.PhysicsPolygons)
{
// Compute the face normal from the vertex fan (same math as
// PhysicsDataCache.ResolvePolygons).
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 n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross(
v1.Origin - v0.Origin, v2.Origin - v0.Origin));
// Only print near-horizontal-normal polys (walls) — the seam wall
// candidates; floors/ceilings are noise here.
if (MathF.Abs(n.Z) > 0.3f) continue;
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
}
// The portal polygons live in the VISUAL polygon set — print their
// ids so overlap with the physics set (same id space?) is visible.
_out.WriteLine($" VisualPolygons={cs.Polygons.Count}");
foreach (var p in envCell.CellPortals)
{
if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp))
{
_out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})");
bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId);
_out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}");
}
}
}
/// <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)");
}
}