acdream/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs
Erik aa96d7ad77 fix #137 (window climb): the player's collision capsule topped out at 1.2m — head sphere 0.63m too low
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>
2026-07-05 19:57:11 +02:00

500 lines
23 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 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;
}
}
}