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
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue