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; /// /// 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). /// /// /// Built from live captures this session: /// /// Retail-connector trace (CEnvCell::find_collisions) proved /// retail's connector cell 0xA9B40175 NEVER blocks (2692 OK + /// 94 Adjusted + 0 Collided + 0 Slid over ~85K samples). /// acdream live capture 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 0xA9B40175. 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) /// → StepSphereUpDoStepUp fails (no CP on the flat /// cottage floor) → StepUpSlide returns Collided → the /// per-cell collide returns Collided → wedge. /// /// /// /// /// Fixtures are real cell dumps (ACDREAM_DUMP_CELLS) of the three lip /// cells: 0xA9B40171 (cottage floor), 0xA9B40174, 0xA9B40175 /// (threshold connector). The BSP=null hydration gap is bridged with a /// synthetic single-leaf BSP, same as . /// /// /// /// RED status: 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. /// /// 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 /// /// 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. /// [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; } } /// /// 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). /// /// /// Faithfulness caveat: the per-tick drive direction (world −Y) is an /// approximation — the exact targetPos would come from an /// ACDREAM_CAPTURE_RESOLVE 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<ε → 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). /// /// /// When the wedge is fixed the player advances and this assertion FAILS — /// that is the signal to flip it to assert the climb. /// [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 SimulateTicks( PhysicsEngine engine, PhysicsBody body, uint initialCellId, int tickCount) { uint cellId = initialCellId; bool isOnGround = true; var traj = new List { 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 LoadWedgeRecords() { var path = Path.Combine(FixtureDir, "wedge-records.jsonl"); Assert.True(File.Exists(path), $"Wedge fixture missing: {path}"); var list = new List(); foreach (var line in File.ReadLines(path)) { if (string.IsNullOrWhiteSpace(line)) continue; list.Add(System.Text.Json.JsonSerializer.Deserialize(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); } /// /// 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. /// [Fact] public void Diagnostic_ReplayLiveWedgeRecords_Advance() { var recs = LoadWedgeRecords(); var lines = new List(); 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))); } /// /// 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. /// [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; } } /// /// FAITHFUL documents-the-bug (passes while the wedge exists; FAILS when the /// fix lands → flip to assert the climb). Replays a captured FLOOR-contact- /// plane wedge through the lip-cell engine; the player is STUCK (0% advance). /// /// /// Root cause (traced via Diagnostic_ReplayFloorCpRecord_StepUpProbes): /// the player is at the doorway EDGE of the cottage floor (0171, poly 0x0023, /// Z=94). The step-up's step-down finds that floor and the 0.48 sphere /// OVERLAPS it (0.085 m below), but acdream's walkable check REJECTS it /// because the sphere center projects outside the floor poly's edge /// (insideEdges=False, gap=−0.395) → no contact plane → step-up /// fails → StepUpSlide=Collided. Retail accepts the floor at its edge and /// crosses (0175 never blocks). Fix is in the walkable-edge acceptance /// (WalkableHitsSphere / PolygonHitsSpherePrecise / CheckWalkable edge math) /// — compare retail CPolygon::walkable_hits_sphere + check_walkable. DOOR /// REGRESSION RISK: walkable changes are global; visual-gate + door tests. /// /// [Fact] public void DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge() { 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-THE-BUG: expected the player STUCK at the cottage-floor edge. " + $"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 walkable-edge fix landed, 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(), portals: Array.Empty(), 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)."); } }