The 'run into the last corridor window and pop up through its roof' report: the live callers passed sphereHeight: 1.2f into SpherePath.InitPath, whose head-sphere formula (height - radius) put the head sphere center at 0.72 - the capsule top at 1.2m. The top 0.63m of a 1.83m character had NO collision, so at the corridor-end window alcove (0x8A020179 -> 0x8A02017E: 0.70m sill face, 1.3m opening, sloped funnel behind) the step-up's placement never saw the head overlapping the lintel solids and let the player climb in head-through-roof. Dat truth (HumanSetup_CollisionSpheres_DatTruth): Setup 0x02000001 spheres = (0,0,0.475) r=0.48 and (0,0,1.350) r=0.48 - capsule top 1.83 = Setup.Height 1.835. Retail collides with that sphere list verbatim (CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere, GetSphere, m_scale)). Fix: PlayerMovementController + the GameWindow remote resolve now pass sphereHeight: 1.835f (capsule top; head center 1.355 vs dat 1.350). InitPath unchanged - captured-input replay fixtures (recorded 1.2 inputs) stay byte-identical. Register TS-46: the (radius, capsule-top) scalar approximation of the Setup sphere list (5mm foot/head offsets; remotes use human dims) with the retire path (plumb the sphere list). Pins: WindowOpening_HeadCannotFit_EntryBlocked (22-frame walked approach wall-slides at the sill, never enters 0x8A02017E) + WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides (Path-1 placement rejects the raised head in the lintel solids) + the WindowShaft_FullPolyDump / HumanSetup dat inspections. Suites: Core 2562 / App 713 / UI 425 / Net 385, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
500 lines
23 KiB
C#
500 lines
23 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);
|
||
}
|
||
|
||
// Expand three portal rings — the live collision cell array reaches
|
||
// cells three hops out (0x8A020166, the under-ramp room whose ceiling
|
||
// is the ramp slab's underside, is ring-3 in the 2026-07-06
|
||
// seam-shake trace; with only two rings the offline flood can never
|
||
// add it and the shake does not reproduce).
|
||
for (int ring = 0; ring < 3; ring++)
|
||
{
|
||
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||
{
|
||
var cell = dats.Get<EnvCell>(known);
|
||
if (cell is null) continue;
|
||
foreach (var p in cell.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);
|
||
|
||
/// <summary>
|
||
/// 2026-07-06 seam-shake repro, snapshot-exact (probe session
|
||
/// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101,
|
||
/// repeated ×46): running WEST across the x=75 boundary
|
||
/// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, −40.035, −6)
|
||
/// toward (74.685, −39.988, −6), the resolve blocks with the SYNTHETIC
|
||
/// reversed-movement normal (0.997, −0.078, −0.002) and out==in — every
|
||
/// frame — the "shaking at the seam" report.
|
||
///
|
||
/// <para>
|
||
/// Probe-traced chain: the foot sphere (tangent to the floor) crosses
|
||
/// onto 0165's ramp floor; the ramp slab is double-faced and the
|
||
/// UNDERSIDE face (poly 0, n=(−0.03,0,−1)) grazes the sphere within the
|
||
/// hit threshold → recorded as a foot near-miss → neg-poly step-up
|
||
/// dispatch with a downward normal → the nested step-up's walkable probe
|
||
/// rejects the exactly-tangent ramp floor ([walkable-nearest]
|
||
/// gap=−0.0000 overlapsSphere=False) → StepUpSlide →
|
||
/// slide_sphere(downward normal vs up-facing contact plane) → the
|
||
/// opposing-normals branch → Collided → revert. Repeat.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public void SeamShake_WestBoundary_SnapshotExact_Advances()
|
||
{
|
||
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);
|
||
|
||
// Body seeded EXACTLY from the capture's bodyBefore (tick 4101).
|
||
var body = new PhysicsBody();
|
||
body.ContactPlaneValid = true;
|
||
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||
body.ContactPlaneCellId = SeamCellWest;
|
||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
body.WalkablePolygonValid = true;
|
||
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||
body.WalkableUp = Vector3.UnitZ;
|
||
body.WalkableVertices = new[]
|
||
{
|
||
new Vector3(75f, -38.33333f, -6f),
|
||
new Vector3(75f, -41.66667f, -6f),
|
||
new Vector3(78.33333f, -41.66667f, -6f),
|
||
new Vector3(78.33333f, -38.33333f, -6f),
|
||
};
|
||
|
||
var from = new Vector3(75.28674f, -40.03537f, -6f);
|
||
var to = new Vector3(74.6854f, -39.988018f, -6f);
|
||
|
||
// Emit the same step-level probes the live session logged so the
|
||
// offline trace can be line-diffed against launch-137-seam-probes.log
|
||
// — the first divergent line names the state the replay is missing.
|
||
var probeBuffer = new System.IO.StringWriter();
|
||
var prevOut = Console.Out;
|
||
ResolveResult r1;
|
||
try
|
||
{
|
||
Console.SetOut(probeBuffer);
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||
|
||
r1 = engine.ResolveWithTransition(
|
||
currentPos: from,
|
||
targetPos: to,
|
||
cellId: SeamCellWest,
|
||
sphereRadius: 0.48f,
|
||
sphereHeight: 1.2f,
|
||
stepUpHeight: 0.6f, // live Setup values from the capture
|
||
stepDownHeight: 1.5f,
|
||
isOnGround: true,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||
}
|
||
finally
|
||
{
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||
Console.SetOut(prevOut);
|
||
}
|
||
_out.WriteLine(probeBuffer.ToString());
|
||
|
||
_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)}");
|
||
|
||
Assert.True(r1.Position.X < from.X - 0.3f,
|
||
$"The westward boundary crossing onto the ramp must advance " +
|
||
$"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " +
|
||
$"advance with the reversed-movement normal = the seam shake.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log):
|
||
/// running from the ramp top in 0x8A020179 into the corridor-end opening
|
||
/// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the
|
||
/// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) →
|
||
/// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head
|
||
/// (and camera) through the opening's roof. The opening is ~1.3 m tall
|
||
/// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry
|
||
/// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom:
|
||
/// "should not be able to run into it".
|
||
/// </summary>
|
||
[Fact]
|
||
public void WindowOpening_HeadCannotFit_EntryBlocked()
|
||
{
|
||
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 = new PhysicsBody();
|
||
body.ContactPlaneValid = true;
|
||
body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level
|
||
body.ContactPlaneCellId = 0x8A020179u;
|
||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
|
||
// Walk the live approach (ramp-top toward the corridor-end opening)
|
||
// so the engine self-accumulates its contact-plane/walkable state,
|
||
// then push into the opening for several held-key frames (the live
|
||
// climb happened under a held key, not a single resolve).
|
||
var pos = new Vector3(88.60f, -41.10f, -5.05f);
|
||
uint cell = 0x8A020179u;
|
||
ResolveResult r = default;
|
||
bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1";
|
||
for (int i = 0; i < 22; i++)
|
||
{
|
||
var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f));
|
||
var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f;
|
||
|
||
var probeBuffer = new System.IO.StringWriter();
|
||
var prevOut = Console.Out;
|
||
try
|
||
{
|
||
if (probeFrames && i >= 9)
|
||
{
|
||
Console.SetOut(probeBuffer);
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||
}
|
||
r = engine.ResolveWithTransition(
|
||
currentPos: pos,
|
||
targetPos: pos + step,
|
||
cellId: cell,
|
||
sphereRadius: 0.48f,
|
||
// #137: the corrected capsule top (dat Setup 0x02000001,
|
||
// head sphere center 1.350 → top 1.830; Height 1.835).
|
||
// The live climb happened under the old 1.2f (head top
|
||
// 1.2 m — no head collision at the lintel).
|
||
sphereHeight: 1.835f,
|
||
stepUpHeight: 0.6f,
|
||
stepDownHeight: 1.5f,
|
||
isOnGround: true,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||
}
|
||
finally
|
||
{
|
||
if (probeFrames && i >= 9)
|
||
{
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||
Console.SetOut(prevOut);
|
||
}
|
||
}
|
||
if (probeFrames && i >= 9 && i <= 10)
|
||
_out.WriteLine(probeBuffer.ToString());
|
||
_out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||
$"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})");
|
||
pos = r.Position;
|
||
cell = r.CellId;
|
||
|
||
Assert.NotEqual(0x8A02017Eu, r.CellId);
|
||
Assert.True(r.Position.Y > -41.6f,
|
||
$"A 1.68 m character must not enter the 1.3 m-tall opening " +
|
||
$"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " +
|
||
$"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " +
|
||
$"0x8A02017E, head through the roof).");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The window-climb's placement half, pinned at the exact site: at the
|
||
/// step-up's raised position on the alcove sill (foot −5.019), the HEAD
|
||
/// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south
|
||
/// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's
|
||
/// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's
|
||
/// step-down placement insert (CTransition::step_down 0x0050b3b3 →
|
||
/// placement transitional_insert → BSPTREE::sphere_intersects_solid
|
||
/// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable.
|
||
/// Our placement passed (the live + offline climb), so our Path-1 solid
|
||
/// test misses the head-vs-solid overlap.
|
||
/// </summary>
|
||
[Fact]
|
||
public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides()
|
||
{
|
||
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 cell = engine.DataCache!.GetCellStruct(0x8A020179u);
|
||
Assert.NotNull(cell);
|
||
Assert.NotNull(cell!.BSP?.Root);
|
||
|
||
// The raised (post-sill-climb) pose from the offline repro's r9.
|
||
var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER
|
||
var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER
|
||
|
||
var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform);
|
||
var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform);
|
||
|
||
var t = new Transition();
|
||
t.SpherePath.InitPath(
|
||
new Vector3(89.683f, -41.247f, -5.019f),
|
||
new Vector3(89.683f, -41.247f, -5.019f),
|
||
0x8A020179u, 0.48f, 1.2f);
|
||
t.SpherePath.InsertType = InsertType.Placement;
|
||
|
||
Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin);
|
||
|
||
var result = BSPQuery.FindCollisions(
|
||
cell.BSP!.Root,
|
||
cell.Resolved,
|
||
t,
|
||
new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f },
|
||
new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f },
|
||
footLocal,
|
||
Vector3.UnitZ,
|
||
1.0f,
|
||
cellRot,
|
||
engine,
|
||
worldOrigin: cellOrigin);
|
||
|
||
_out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " +
|
||
$"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})");
|
||
|
||
Assert.Equal(TransitionState.Collided, result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing
|
||
/// at (84.851, −39.764, −6.000) — the foot sphere already straddling the
|
||
/// x=85 cell boundary by 0.33 m — the first move attempt toward
|
||
/// (85.453, −39.782) blocked with the synthetic reversed-movement normal
|
||
/// (−1.00, 0.03, −0.02), out==in, cp lost (cp=none), and repeated every
|
||
/// frame (the "shaking at the seam" report). The deeper straddle start is
|
||
/// what the original replay frame (84.638 → 85.253) didn't cover.
|
||
/// </summary>
|
||
[Fact]
|
||
public void SeamCrossing_FromDeepStraddleStart_Advances()
|
||
{
|
||
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();
|
||
|
||
var from = new Vector3(84.851f, -39.764f, -6.000f);
|
||
var to = new Vector3(85.453f, -39.782f, -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)} " +
|
||
$"bodyCpValid={body.ContactPlaneValid}");
|
||
|
||
Assert.True(r1.Position.X > from.X + 0.2f,
|
||
$"The straddling-start seam crossing must advance " +
|
||
$"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " +
|
||
$"reversed-movement normal = the 2026-07-06 seam shake.");
|
||
}
|
||
|
||
[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;
|
||
}
|
||
}
|
||
}
|