acdream/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs
Erik cc4590f9e5 fix(p2): cellar-lip wedge — check_other_cells must use the LIVE sphere position
Root cause of the "blocked at the last cellar step" wedge (the primary,
ramp-climb family — 20/29 captured records). The prior session's pinned
"find_walkable is never called during the step-down" was a probe artifact:
a fresh [fc-dispatch]/[step-sphere-down] trace proves Path-3 StepSphereDown
IS reached for both the carried cell and the iterated other-cell.

The real divergence is in Transition.CheckOtherCells. Retail's
check_other_cells (acclient_2013_pseudo_c.txt:272735 → (*cell+0x88)(this))
re-collides the OTHER cells against the LIVE sphere_path.global_sphere — the
position AFTER the primary insert_into_cell ran. The primary collide can MOVE
the sphere: a Path-5 full-hit dispatches step_sphere_up, and a successful
step-up climbs the foot onto the cottage floor yet still returns OK. acdream
instead reused a footCenter snapshot captured BEFORE the primary collide, so
once the lip-riser step-up climbed the foot onto the floor, check_other_cells
still queried 0171 at the pre-climb (sunk ~0.25 m below the floor) position →
the foot spuriously near-missed the very floor it had climbed onto →
neg_step_up → a doomed second step_up vs the floor normal (0,0,1) whose
step_up_slide unwound the climb → validate_transition reverted → 0% advance.

Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the top of
RunCheckOtherCellsAndAdvance (one line). Pre-fix 0/29 wedge records advanced;
post-fix 20/29 climb onto Z≈94.

No regression: full Core suite 1321 pass / 4 fail (the documented baseline:
Apparatus_Grounded_50cmOffCenter, 2× DoorBugTrajectoryReplay LiveCompare_*,
BSPStepUpTests.D4) / 1 skip. The 2 door LiveCompare divergences are
byte-identical with/without the fix (the door's step_up FAILS → sphere
restored → position unchanged → footCenter == live).

Tests: CellarLipWedgeTests.Fix_StaleFootCenter_RampRecordClimbsCottageFloor +
Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance (new, GREEN).
DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY documents the
remaining 9/29 (0,-1,0)-sliding-normal +Y-kill family (slide territory,
deferred to the visual gate).

Apparatus retained (gated on ACDREAM_PROBE_INDOOR_BSP): [fc-dispatch] in
BSPQuery.FindCollisions + [step-sphere-down] in BSPQuery.StepSphereDown +
CellarLipWedgeTests.Diagnostic_TraceRecordByIndex — strip once the residual
is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:15:19 +02:00

543 lines
26 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// P2 cellar-LIP wedge (2026-06-04) — deterministic reproduction of the
/// "blocked at the last step" wedge at the Holtburg cottage cellar lip,
/// distinct from the earlier issue-#98 cellar (cells 0xA9B4014X).
///
/// <para>
/// Built from live captures this session:
/// <list type="bullet">
/// <item><b>Retail-connector trace</b> (CEnvCell::find_collisions) proved
/// retail's connector cell <c>0xA9B40175</c> NEVER blocks (2692 OK +
/// 94 Adjusted + 0 Collided + 0 Slid over ~85K samples).</item>
/// <item><b>acdream live capture</b> at the wedge: the player (r=0.48 body
/// sphere) is mid-climb at world Z=93.936, carried in the 0.364 m-tall
/// threshold slab <c>0xA9B40175</c>. Its 0.48 sphere grazes the slab's
/// X wall (local X=9; sphere at X=8.523 reaches 9.003 — a 3 mm graze)
/// → <c>StepSphereUp</c> → <c>DoStepUp</c> fails (no CP on the flat
/// cottage floor) → <c>StepUpSlide</c> returns <c>Collided</c> → the
/// per-cell collide returns Collided → wedge.</item>
/// </list>
/// </para>
///
/// <para>
/// Fixtures are real cell dumps (<c>ACDREAM_DUMP_CELLS</c>) of the three lip
/// cells: <c>0xA9B40171</c> (cottage floor), <c>0xA9B40174</c>, <c>0xA9B40175</c>
/// (threshold connector). The <c>BSP=null</c> hydration gap is bridged with a
/// synthetic single-leaf BSP, same as <see cref="CellarUpTrajectoryReplayTests"/>.
/// </para>
///
/// <para>
/// <b>RED status:</b> the climb-advance assertion is expected to FAIL while the
/// wedge exists (the player freezes at the threshold ~Z=93.94 instead of
/// reaching the cottage floor ~Z=94.48). When the fix lands it flips to GREEN.
/// </para>
/// </summary>
public class CellarLipWedgeTests
{
private const uint CottageFloorId = 0xA9B40171u; // cottage room floor
private const uint Connector74Id = 0xA9B40174u;
private const uint ThresholdId = 0xA9B40175u; // 0.364 m threshold slab
// Player physics from PlayerMovementController.cs (human, from Setup).
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.40f;
private const float StepDownHeight = 0.04f;
// The live-captured wedge state. The probe's [indoor-bsp] wpos is the
// FOOT-SPHERE CENTER (153.406, 9.754, 93.936); the engine body Position is
// the foot BOTTOM = center radius = Z 93.456. Player carried in the
// 0.364 m threshold slab 0xA9B40175, climbing the cellar stairs; observed
// motion at the lip is world Y (into the cottage).
private static readonly Vector3 WedgeSphereCenter = new(153.406f, 9.754f, 93.936f);
private static readonly Vector3 WedgeBodyPos =
new(153.406f, 9.754f, 93.936f - SphereRadius); // foot bottom Z=93.456
private static readonly Vector3 PerTickOffset = new(0f, -0.10f, 0f);
private const float CottageFloorZ = 94.00f;
private const float RestOnCottageZ = CottageFloorZ + SphereRadius; // ≈94.48
/// <summary>
/// Diagnostic: drive the player off the threshold toward the cottage and
/// dump the trajectory + indoor-BSP/step probes. Always passes; the
/// captured stdout shows exactly what the engine does each tick.
/// </summary>
[Fact]
public void Diagnostic_DriveOffThreshold_DumpTrajectory()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
var saved = Console.Out;
var sw = new StringWriter();
Console.SetOut(sw);
try
{
var engine = BuildEngineWithLipFixtures();
var body = BuildWedgeBody();
var traj = SimulateTicks(engine, body, ThresholdId, 4);
Console.SetOut(saved);
var probeLines = sw.ToString();
File.WriteAllText(
Path.Combine(Path.GetTempPath(), "lip-wedge-diag.log"),
"TRAJECTORY:\n" + string.Join("\n", traj.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}")) +
"\n\nPROBES:\n" + probeLines);
Assert.True(true);
}
finally
{
Console.SetOut(saved);
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
}
}
/// <summary>
/// DOCUMENTS-THE-BUG (passes while the wedge exists; FAILS when the fix
/// lands). Seeded at the live wedge position (foot-sphere center Z=93.936,
/// carried in the 0.364 m threshold slab 0xA9B40175) and driven forward,
/// the player FREEZES — blocked by the slab's X side wall (poly normal
/// world (1,0,0)) — instead of advancing onto the cottage floor. Retail's
/// 0175 never blocks (live CEnvCell::find_collisions trace: 0 Collided/Slid).
///
/// <para>
/// <b>Faithfulness caveat:</b> the per-tick drive direction (world Y) is an
/// approximation — the exact <c>targetPos</c> would come from an
/// <c>ACDREAM_CAPTURE_RESOLVE</c> JSONL of the live wedge. The Y drive is
/// PARALLEL to the X wall, so it reproduces the block but may not be the
/// real climb path. A candidate fix (replacing the A6.P4 neg-poly
/// "return Collided" shortcut with retail's slide_sphere) did NOT clear this:
/// the slide returns Slid with offset=0 (displacement already along the
/// crease), the loop re-checks with gDelta≈0 → SlideSphere's
/// offset.LengthSquared&lt;ε → Collided branch → revert. The real fix needs a
/// faithful repro + the slide/loop-commit investigation (see the handoff
/// doc 2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, Correction 2).
/// </para>
///
/// When the wedge is fixed the player advances and this assertion FAILS —
/// that is the signal to flip it to assert the climb.
/// </summary>
[Fact]
public void DocumentsWedge_PlayerFrozenAtThreshold_BlockedByMinusXWall()
{
var engine = BuildEngineWithLipFixtures();
var body = BuildWedgeBody();
var traj = SimulateTicks(engine, body, ThresholdId, 30);
var final = traj[^1];
float yAdvance = WedgeBodyPos.Y - final.Position.Y; // +ve = moved into cottage
float zRise = final.Position.Z - WedgeBodyPos.Z; // +ve = climbed
Assert.True(
yAdvance < 0.1f && zRise < 0.1f,
$"DOCUMENTS-THE-BUG: expected the player to be FROZEN at the threshold " +
$"(the X-wall wedge). Instead it advanced to " +
$"({final.Position.X:F3},{final.Position.Y:F3},{final.Position.Z:F3}) " +
$"after 30 ticks (yAdvance={yAdvance:F3}, zRise={zRise:F3}). If the wedge " +
$"fix landed, FLIP this assertion to require the climb " +
$"(Z≥{CottageFloorZ - 0.05f:F2}, yAdvance>0.5).");
}
// ───────────────────────────── helpers ─────────────────────────────
private static PhysicsBody BuildWedgeBody() => new()
{
Position = WedgeBodyPos, // foot bottom Z=93.456
Orientation = Quaternion.Identity,
// Best-effort grounded seed: a flat floor at foot level so the player
// starts "on the ground" mid-climb (the exact body-before state would
// come from an ACDREAM_CAPTURE_RESOLVE JSONL; this approximation puts
// the foot-sphere center at Z=93.936 — the live wedge — so the
// geometric X-wall full-hit fires).
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z),
ContactPlaneCellId = ThresholdId,
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z),
WalkableVertices = new[]
{
new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
private static List<TrajPoint> SimulateTicks(
PhysicsEngine engine, PhysicsBody body, uint initialCellId, int tickCount)
{
uint cellId = initialCellId;
bool isOnGround = true;
var traj = new List<TrajPoint> { new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid) };
for (int tick = 1; tick <= tickCount; tick++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: target,
cellId: cellId,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
traj.Add(new(tick, body.Position, cellId, isOnGround, body.ContactPlaneValid));
}
return traj;
}
private sealed record TrajPoint(int Tick, Vector3 Position, uint CellId, bool IsOnGround, bool CpValid);
// ─────────────────────── faithful live-wedge replay ───────────────────────
// Replays a captured wedge ResolveWithTransition call (exact currentPos /
// targetPos / body-before from ACDREAM_CAPTURE_RESOLVE at the live cellar
// lip) through the lip-cell engine. The live climb direction is X,+Y (the
// synthetic Y guess was backwards). 29 representative wedge records are in
// Fixtures/cellar-lip/wedge-records.jsonl.
private static readonly System.Text.Json.JsonSerializerOptions WedgeJsonOptions =
new() { IncludeFields = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
private static List<ResolveCaptureRecord> LoadWedgeRecords()
{
var path = Path.Combine(FixtureDir, "wedge-records.jsonl");
Assert.True(File.Exists(path), $"Wedge fixture missing: {path}");
var list = new List<ResolveCaptureRecord>();
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line)) continue;
list.Add(System.Text.Json.JsonSerializer.Deserialize<ResolveCaptureRecord>(line, WedgeJsonOptions)!);
}
return list;
}
private static PhysicsBody SeedBody(PhysicsBodySnapshot s) => new()
{
Position = s.Position,
Orientation = s.Orientation,
Velocity = s.Velocity,
Acceleration = s.Acceleration,
Omega = s.Omega,
GroundNormal = s.GroundNormal,
SlidingNormal = s.SlidingNormal,
ContactPlaneValid = s.ContactPlaneValid,
ContactPlane = s.ContactPlane,
ContactPlaneCellId = s.ContactPlaneCellId,
ContactPlaneIsWater = s.ContactPlaneIsWater,
WalkablePolygonValid = s.WalkablePolygonValid,
WalkablePlane = s.WalkablePlane,
WalkableVertices = s.WalkableVertices,
WalkableUp = s.WalkableUp,
Elasticity = s.Elasticity,
Friction = s.Friction,
State = (PhysicsStateFlags)s.State,
TransientState = (TransientStateFlags)s.TransientState,
LastUpdateTime = s.LastUpdateTime,
};
private static (Vector3 res, float requested, float advance) ReplayRecord(ResolveCaptureRecord rec)
{
var engine = BuildEngineWithLipFixtures();
var body = SeedBody(rec.BodyBefore!);
var result = engine.ResolveWithTransition(
currentPos: rec.Input.CurrentPos,
targetPos: rec.Input.TargetPos,
cellId: rec.Input.CellId,
sphereRadius: rec.Input.SphereRadius,
sphereHeight: rec.Input.SphereHeight,
stepUpHeight: rec.Input.StepUpHeight,
stepDownHeight: rec.Input.StepDownHeight,
isOnGround: rec.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)rec.Input.MoverFlags,
movingEntityId: rec.Input.MovingEntityId);
float requested = Vector3.Distance(rec.Input.CurrentPos, rec.Input.TargetPos);
float advance = Vector3.Distance(rec.Input.CurrentPos, result.Position);
return (result.Position, requested, advance);
}
/// <summary>
/// Diagnostic: replay every captured wedge record and report advance% — to
/// confirm the lip-cell engine reproduces the live stuck (0% advance) before
/// asserting a fix. Always passes; results in the message + %TEMP% file.
/// </summary>
[Fact]
public void Diagnostic_ReplayLiveWedgeRecords_Advance()
{
var recs = LoadWedgeRecords();
var lines = new List<string>();
foreach (var (rec, i) in recs.Select((r, i) => (r, i)))
{
if (rec.BodyBefore is null) continue;
var (res, req, adv) = ReplayRecord(rec);
var cpN = rec.BodyBefore.ContactPlane.Normal;
lines.Add($"#{i} cp=({cpN.X:F2},{cpN.Y:F2},{cpN.Z:F2}) req={req:F3} adv={adv:F3} ({(req>0?100*adv/req:0):F0}%) " +
$"cur=({rec.Input.CurrentPos.X:F2},{rec.Input.CurrentPos.Y:F2},{rec.Input.CurrentPos.Z:F2}) " +
$"tgt=({rec.Input.TargetPos.X:F2},{rec.Input.TargetPos.Y:F2},{rec.Input.TargetPos.Z:F2}) " +
$"res=({res.X:F2},{res.Y:F2},{res.Z:F2})");
}
File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-replay.log"), string.Join("\n", lines));
Assert.True(true, string.Join("\n", lines.Take(10)));
}
/// <summary>
/// Diagnostic: replay ONE floor-CP wedge record with the step-up + indoor
/// probes on, capturing why the step-up fails. Output to %TEMP%/lip-wedge-stepup.log.
/// </summary>
[Fact]
public void Diagnostic_ReplayFloorCpRecord_StepUpProbes()
{
var rec = LoadWedgeRecords().First(r => r.BodyBefore is not null
&& r.BodyBefore.ContactPlane.Normal.Z > 0.99f);
var saved = Console.Out;
var sw = new StringWriter();
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
Console.SetOut(sw);
try
{
var (res, req, adv) = ReplayRecord(rec);
Console.SetOut(saved);
File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-stepup.log"),
$"record cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " +
$"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " +
$"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString());
Assert.True(true);
}
finally
{
Console.SetOut(saved);
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
/// <summary>
/// TEMP diagnostic (2026-06-05): trace ONE record by index with full probes,
/// to a per-index %TEMP%/lip-trace-{idx}.log. Used to compare a ramp record
/// (no sliding normal) against a floor record. STRIP after fix.
/// </summary>
[Theory]
[InlineData(6)] // STILL 0% post-footCenter-fix: flat floor, sliding normal (0,-1,0)
[InlineData(13)] // STILL 0% post-footCenter-fix: ramp, NO sliding normal, motion -X,+Y
[InlineData(0)] // STILL 0% post-footCenter-fix: ramp, sliding normal (0,-1,0)
[InlineData(21)] // STILL 0% post-footCenter-fix: ramp, NO slide, motion -X,-Y (away?)
public void Diagnostic_TraceRecordByIndex(int idx)
{
var rec = LoadWedgeRecords()[idx];
var saved = Console.Out;
var sw = new StringWriter();
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
Console.SetOut(sw);
try
{
var (res, req, adv) = ReplayRecord(rec);
Console.SetOut(saved);
var bb = rec.BodyBefore!;
File.WriteAllText(Path.Combine(Path.GetTempPath(), $"lip-trace-{idx}.log"),
$"record #{idx} cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " +
$"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " +
$"cp=({bb.ContactPlane.Normal.X:F2},{bb.ContactPlane.Normal.Y:F2},{bb.ContactPlane.Normal.Z:F2}) " +
$"slide=({bb.SlidingNormal.X:F2},{bb.SlidingNormal.Y:F2},{bb.SlidingNormal.Z:F2}) ts=0x{bb.TransientState:X2} " +
$"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString());
Assert.True(true);
}
finally
{
Console.SetOut(saved);
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
/// <summary>
/// FIX VALIDATION (2026-06-05) — the stale-footCenter fix in
/// <c>RunCheckOtherCellsAndAdvance</c>. Retail's <c>check_other_cells</c>
/// (acclient_2013_pseudo_c.txt:272735) re-collides the OTHER cells against the
/// LIVE <c>sphere_path.global_sphere</c> — i.e. AFTER the primary cell's
/// <c>insert_into_cell</c> may have moved the sphere via a successful
/// <c>step_sphere_up</c>. acdream captured the foot-sphere center BEFORE the
/// primary collide and reused that stale snapshot, so once the lip-riser
/// step_up climbed the foot onto the cottage floor, <c>check_other_cells</c>
/// still queried 0171 at the pre-climb (sunk, penetrating) position → the foot
/// spuriously near-missed the very floor it had climbed onto → a doomed second
/// step_up against the floor normal whose slide unwound the climb →
/// validate_transition reverted → 0% advance.
///
/// <para>
/// Pre-fix: 0/29 captured wedge records advanced. Post-fix: the ramp-climb
/// family (≈20/29) advances onto the cottage floor (Z≈94). This asserts a
/// representative ramp record (#9, cp Z=0.78, no sliding normal) now climbs.
/// </para>
/// </summary>
[Fact]
public void Fix_StaleFootCenter_RampRecordClimbsCottageFloor()
{
var rec = LoadWedgeRecords()[9];
var (res, requested, advance) = ReplayRecord(rec);
Assert.True(advance > 0.25f * requested && res.Z >= CottageFloorZ - 0.05f,
$"Expected ramp record #9 to climb onto the cottage floor after the " +
$"stale-footCenter fix. advance={advance:F3} (req={requested:F3}), " +
$"res=({res.X:F3},{res.Y:F3},{res.Z:F3}); want advance>0.25·req and Z≥{CottageFloorZ - 0.05f:F2}.");
}
/// <summary>
/// FIX REGRESSION GUARD (2026-06-05): the majority of captured wedge records
/// advance after the stale-footCenter fix. Pre-fix 0/29 → post-fix ≈20/29.
/// A drop here means <c>check_other_cells</c> is again querying other cells at
/// a stale pre-step_up position (the cellar-lip wedge regressed).
/// </summary>
[Fact]
public void Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance()
{
var recs = LoadWedgeRecords();
int advanced = 0, total = 0;
foreach (var rec in recs)
{
if (rec.BodyBefore is null) continue;
total++;
var (res, req, adv) = ReplayRecord(rec);
if (adv > 0.25f * req) advanced++;
}
Assert.True(advanced >= 18,
$"Expected ≥18 of {total} captured wedge records to advance >0.25·req " +
$"after the stale-footCenter fix; got {advanced}.");
}
/// <summary>
/// DOCUMENTS-THE-BUG (passes while a RESIDUAL wedge exists; flip when fixed).
/// Record #6 is a FLOOR-contact-plane record that ALSO carries a stale
/// <c>(0,-1,0)</c> sliding normal (the cottage south wall). The stale-footCenter
/// fix does NOT clear it: <c>AdjustOffset</c>'s slide-crease projects the
/// into-cottage +Y motion onto the floor×wall crease (the world X axis) and
/// ZEROES it before the sphere moves, so only the X residual survives → it
/// full-hits the slab's X wall → a step_up that fails on the flat floor (no CP)
/// → Collided → revert → 0% advance.
///
/// <para>
/// This is the SEPARATE "(0,-1,0) sliding-normal +Y-kill" family (7/29 records).
/// It is slide-recovery territory — explicitly OUT OF SCOPE for this pass per
/// the kickoff ("do not re-investigate ... slide") — and is suspected to be a
/// buggy-trajectory artifact (the stale slide accumulated only because the
/// player was already oscillating; once the ramp-climb advances cleanly the
/// player should not enter the south-wall-slide-into-doorway state). The visual
/// gate decides whether it needs a follow-up. If the residual is fixed, flip
/// this to require advance &gt; 0.25·requested.
/// </para>
/// </summary>
[Fact]
public void DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY()
{
var recs = LoadWedgeRecords();
var rec = recs.First(r => r.BodyBefore is not null
&& r.BodyBefore.ContactPlane.Normal.Z > 0.99f);
var (res, requested, advance) = ReplayRecord(rec);
var c = rec.Input.CurrentPos; var t = rec.Input.TargetPos;
Assert.True(advance < 0.1f * requested,
$"DOCUMENTS-RESIDUAL: expected the player STUCK (sliding-normal +Y-kill). " +
$"Instead it advanced: cur=({c.X:F3},{c.Y:F3},{c.Z:F3}) tgt=({t.X:F3},{t.Y:F3},{t.Z:F3}) " +
$"res=({res.X:F3},{res.Y:F3},{res.Z:F3}) requested={requested:F3} advance={advance:F3}. " +
$"If the slide +Y-kill residual is fixed, FLIP this to require advance>0.25·requested.");
}
private static PhysicsEngine BuildEngineWithLipFixtures()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
foreach (var cellId in new[] { CottageFloorId, Connector74Id, ThresholdId })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
Assert.True(File.Exists(path), $"Lip fixture missing: {path}");
var dump = CellDumpSerializer.Read(path);
var cell = CellDumpSerializer.Hydrate(dump);
cache.RegisterCellStructForTest(cellId, AttachSyntheticBsp(cell));
}
// Empty-terrain landblock so FindObjCollisions' TryGetLandblockContext
// succeeds at the lip XY (X≈153, Y≈9). Flat far-below surface; the
// indoor BSP path fires first so terrain is never consulted.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static CellPhysics AttachSyntheticBsp(CellPhysics cell)
{
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 0f), Radius = 15f },
};
foreach (var kv in cell.Resolved)
leaf.Polygons.Add(kv.Key);
return new CellPhysics
{
BSP = new PhysicsBSPTree { Root = leaf },
PhysicsPolygons = cell.PhysicsPolygons,
Vertices = cell.Vertices,
WorldTransform = cell.WorldTransform,
InverseWorldTransform = cell.InverseWorldTransform,
Resolved = cell.Resolved,
CellBSP = cell.CellBSP,
Portals = cell.Portals,
PortalPolygons = cell.PortalPolygons,
VisibleCellIds = cell.VisibleCellIds,
};
}
private static string FixtureDir =>
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", "Fixtures", "cellar-lip");
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException("Could not locate solution root (AcDream.slnx).");
}
}