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>
187 lines
8.4 KiB
C#
187 lines
8.4 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|