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; } } /// /// FIX VALIDATION (2026-06-05) — the stale-footCenter fix in /// RunCheckOtherCellsAndAdvance. Retail's check_other_cells /// (acclient_2013_pseudo_c.txt:272735) re-collides the OTHER cells against the /// LIVE sphere_path.global_sphere — i.e. AFTER the primary cell's /// insert_into_cell may have moved the sphere via a successful /// step_sphere_up. 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, check_other_cells /// 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. /// /// /// 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. /// /// [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}."); } /// /// 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 check_other_cells is again querying other cells at /// a stale pre-step_up position (the cellar-lip wedge regressed). /// [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}."); } /// /// 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 /// (0,-1,0) sliding normal (the cottage south wall). The stale-footCenter /// fix does NOT clear it: AdjustOffset'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. /// /// /// 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 > 0.25·requested. /// /// [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(), 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)."); } }