using System.Collections.Generic; using System.Numerics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// /// Issue #265 capture-driven bisection: "Steep-slope response set" (uphill-jump /// bounce, lost roof slide, edge wedge). This harness replays a REAL trajectory /// mined from a live capture (ACDREAM_CAPTURE_RESOLVE, /// artifacts/matrix-session2-resolve.jsonl, records 3415-3434) through a /// synthetic single-polygon built from the EXACT /// polygon the live capture landed on /// (bodyAfter.walkableVertices: (240,0,88),(264,0,80),(264,24,68), /// normal (2,3,6)/7 = (0.2857,0.4286,0.8571) — a moderate, WALKABLE-BY-THRESHOLD /// roof slope, normal.Z=0.857 > PhysicsGlobals.FloorZ (0.6642)). /// /// /// Live symptom this reproduces (mining evidence): the player falls /// (v ≈ (11.15, 14.13, -23.14) m/s at landing) onto this roof slope. Live capture /// record 3433 shows collisionNormalValid=true, the correct real polygon /// normal, and walkablePolygonValid=true — a legitimate walkable landing. /// Record 3434 (the very next tick) shows the body FROZEN: velocity forced to /// exactly (0,0,0), transientState=7 (Contact|OnWalkable|Sliding), and the /// position stays byte-identical for the remaining 12,292 captured ticks (to /// the end of the file) — i.e. the player never moves again. A second, /// independent instance of the same shape appears at records 3153-3199+ (a /// shallower ~18° roof edge, frozen for 46+ captured ticks). Full mining /// evidence: docs/research/2026-07-30-265-capture-bisect.md. /// /// /// /// Candidate mechanism (S1): commit db2889af ("#116 shape-1") /// changed BSPQuery.cs's Path-6 hasSphere1 (head-sphere-only hit /// while airborne, foot sphere clear) branch from a steepness-gated /// SetCollide→Adjusted (shallow, Z≥FloorZ) / slide-tangent-then-Slid (steep, /// Z<FloorZ) dual path — IDENTICAL in shape to the still-unchanged sphere0 /// (foot) branch a few lines above it — to an UNCONDITIONAL /// SetCollisionNormal + return Collided, regardless of steepness. A /// `Collided` return short-circuits TransitionalInsert immediately /// (if (transitState == TransitionState.Collided) return /// TransitionState.Collided;) — it never reaches the retry loop's Phase 3 /// (if (sp.Collide) ...DoCheckWalkable...Placement retry...), which is /// the ONLY place a shallow/walkable head-sphere hit can smoothly commit to a /// real ContactPlane + OnWalkable via the SetCollide+Adjusted /// path. This test's real captured polygon has normal.Z=0.857 — well /// ABOVE FloorZ (0.6642) — so it is the SHALLOW case, not the steep one; /// S1 removed the steepness branch entirely, so this shallow graze now takes /// the SAME hard-stop path a steep hit would. /// /// /// /// Method: integrates the EXACT /// captured ballistic state (position + velocity) from record 3415 forward /// with real gravity (dt=1/30s, matching retail's tick rate), calling /// every tick exactly like /// does at the /// Core boundary (this harness intentionally stops at that boundary — it does /// NOT call PhysicsObjUpdate.HandleAllCollisions or model the R6 /// animation-root-motion grounded-movement zeroing, both of which live outside /// Core and are confirmed NOT part of the S1/S2 candidate set — see the research /// doc). Once the mover reports IsOnGround, the harness keeps REQUESTING /// forward motion each tick (simulating held input) so a genuine "does the /// engine allow continued advance across this surface" signal is observable, /// rather than trivially replaying the live capture's own (already-frozen, /// no-input) subsequent targets. /// /// /// /// A/B protocol (see the research doc for the executed results): this /// same test is run unmodified against (i) HEAD, (ii) BSPQuery.cs with /// the S1 sphere1 branch reverted to mirror the still-current sphere0 shape /// (local, uncommitted diagnostic edit), (iii) production unaffected by S2 /// (calc_friction's AP-7 threshold) since S2 has zero call sites outside its /// own unit test — confirmed by grep -rn "\.calc_friction(" src/ — so no /// S2 toggle is needed for THIS harness, and (iv) both. The per-tick dump /// ( list, printed via ) /// is the diff target. /// /// public class Issue265SteepSlopeCaptureBisectTests { private readonly ITestOutputHelper _out; public Issue265SteepSlopeCaptureBisectTests(ITestOutputHelper output) => _out = output; // ── Real captured polygon (bodyAfter.walkableVertices, record 3433) ────── // artifacts/matrix-session2-resolve.jsonl, tick 3433, cell 0xAAB40011. // normal = cross(v1-v0, v2-v0) normalized = (2,3,6)/7 exactly. private static readonly Vector3 RoofV0 = new(240f, 0f, 88f); private static readonly Vector3 RoofV1 = new(264f, 0f, 80f); private static readonly Vector3 RoofV2 = new(264f, 24f, 68f); // Outdoor cell suffix MUST be < 0x0100 (retail's indoor/outdoor LandCell // convention — CellTransit.BuildShadowCellSet branches on it) and its // block index must be (0,0) so that the flood's landblock-local grid math // (CellTransit.AddAllOutsideCells, an 8x8 24-m-cell grid over the 192-m // landblock) treats this harness's coordinates as directly landblock-local // — CellGraph.TryGetTerrainOrigin has no registered terrain for this // synthetic landblock so it Zero-falls-back, meaning raw "world" position // IS landblock-local position (documented anchor-frame convention, same // one Ts4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests rely on). // The harness's very first run registered the shadow object at the REAL // captured world coordinates (X≈256) under this convention — 256 is // outside the valid [0,192) landblock-local range, so the flood produced // an empty cell set and the object was silently never registered at all // (zero collisions the whole replay). Fix: re-anchor the entire synthetic // scene (triangle + approach trajectory) at the roof centroid so every // coordinate here is small and landblock-local (see the research doc's // harness-commissioning note). // Suffix 0x0001 is the canonical (gridX=0, gridY=0) outdoor LandCell — the // grid cell whose local origin is (0,0) — matching this harness's // re-anchored roof centroid at world (0,0,0) (see the note above). An // earlier attempt used suffix 0x0011; CellTransit.AddAllOutsideCells' // LandDefs.AdjustToOutside re-derives the (lx,ly) grid cell from the // sphere's ACTUAL position and silently corrects a mismatched seed, so the // registration landed in cell 0x00000001 regardless of the literal seed // passed — GetObjectsInCell(0x00000011) found nothing (see the research // doc's harness-commissioning note). private const uint CellId = 0x00000001u; private const uint LandblockId = 0x00000000u; private const uint SyntheticGfxId = 0x265BEEF1u; private const int TicksPerSecond = 30; private const float Gravity = -9.8f; private const float SphereRadius = 0.48f; // production human Setup 0x02000001 private const float SphereHeight = 1.835f; // production human Setup 0x02000001 // Real captured state, record index 3415 (session2, tick 3415) — 18 ticks // before the landing/freeze at record 3433/3434. vx/vy are constant across // this whole approach (pure ballistic fall, no further horizontal drive). // Re-anchored: subtract RoofCentroid from the real captured world position // (see the landblock-local note above) — the RELATIVE approach geometry // (distance, direction, velocity) is preserved exactly. private static readonly Vector3 ApproachStartPosReal = new(244.59f, -0.81f, 92.79f); private static readonly Vector3 ApproachStartVel = new(11.1509495f, 14.129979f, -16.72f); // ShadowObjects.Register's broad-phase culls candidates by distance from // `worldPos` within `radius` — registering at Vector3.Zero with the real // (far-from-origin) captured world coordinates put the polygon ~264 units // from the query point, well outside any sane radius, so the very first // run of this harness found ZERO collisions at all (see the research doc's // "harness commissioning" note). Fix: register the entity at the // triangle's centroid and express the polygon in LOCAL coordinates // relative to that centroid (identity rotation, scale 1 — world = local + // worldPos reconstructs the exact real-world triangle). private static readonly Vector3 RoofCentroid = (RoofV0 + RoofV1 + RoofV2) / 3f; private static PhysicsEngine MakeRoofEngine() { var resolved = new Dictionary(); var verts = new[] { RoofV0 - RoofCentroid, RoofV1 - RoofCentroid, RoofV2 - RoofCentroid }; var normal = Vector3.Normalize(Vector3.Cross(verts[1] - verts[0], verts[2] - verts[0])); float d = -Vector3.Dot(normal, verts[0]); resolved[1] = new ResolvedPolygon { Vertices = verts, Plane = new Plane(normal, d), NumPoints = 3, SidesType = CullMode.None, }; var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, }; leaf.Polygons.Add(1); var heights = new byte[81]; var heightTab = new float[256]; for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes var engine = new PhysicsEngine(); engine.AddLandblock( LandblockId, new TerrainSurface(heights, heightTab), System.Array.Empty(), System.Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var cache = new PhysicsDataCache(); var bspTree = new PhysicsBSPTree { Root = leaf }; var physics = new GfxObjPhysics { BSP = bspTree, PhysicsPolygons = new Dictionary(), Vertices = new VertexArray(), Resolved = resolved, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, }; cache.RegisterGfxObjForTest(SyntheticGfxId, physics); engine.DataCache = cache; // ShadowObjectRegistry is the per-cell shadow-object index (BR-7/A6.P4): // Register() FLOODS from a SEED CELL outward and registers the entity // into the resulting cell set; the query side (GetObjectsInCell) looks // up strictly by the mover's CURRENT cell id. Leaving seedCellId at its // default (0u) makes Register() call DeriveOutdoorSeed(worldPos, ...), // which computes its OWN outdoor landcell id from world position — for // the real captured coordinates used here (worldPos.X=256, well outside // landblock 0xAAB40000's own 192 m span) that derives to a DIFFERENT // cell than the literal CellId this harness resolves against, so the // very first run of this fixture found zero collisions (see the // research doc's harness-commissioning note). Passing seedCellId // explicitly bypasses the derivation and floods from the exact cell // the replay loop queries. engine.ShadowObjects.Register( entityId: SyntheticGfxId, gfxObjId: SyntheticGfxId, worldPos: Vector3.Zero, rotation: Quaternion.Identity, radius: 30f, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: LandblockId, collisionType: ShadowCollisionType.BSP, scale: 1.0f, seedCellId: CellId); return engine; } public sealed record TickSample( int Tick, Vector3 Pos, float Advance, bool CollisionNormalValid, Vector3 CollisionNormal, bool OnGround, int FrozenStreak); /// /// Replays the real captured ballistic approach + landing, then keeps /// REQUESTING forward motion (simulating held input) for /// additional ticks once grounded, to /// see whether the engine allows continued advance across the roof surface /// or wedges in place. Returns one per tick. /// public static List ReplayRealRoofLanding(int postLandingTicks = 60) { var engine = MakeRoofEngine(); const float dt = 1f / TicksPerSecond; var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; Vector3 pos = ApproachStartPosReal - RoofCentroid; Vector3 vel = ApproachStartVel; uint cell = CellId; bool grounded = false; int frozenStreak = 0; int ticksSinceGrounded = -1; var samples = new List(); // Budget: enough ticks to cover the ~18-tick ballistic approach plus the // requested post-landing continuation window. int maxTicks = 18 + postLandingTicks + 20; for (int tick = 0; tick < maxTicks; tick++) { if (!grounded) vel = new Vector3(vel.X, vel.Y, vel.Z + Gravity * dt); // Once grounded, keep requesting the SAME horizontal advance each // tick (simulating held forward input) — this is the "does a // slide continue" probe. Vertical requested delta is zero (resting // against the surface, not still falling). Vector3 requestedVel = grounded ? new Vector3(vel.X, vel.Y, 0f) : vel; Vector3 target = pos + requestedVel * dt; var result = engine.ResolveWithTransition( currentPos: pos, targetPos: target, cellId: cell, sphereRadius: SphereRadius, sphereHeight: SphereHeight, stepUpHeight: 0.6f, stepDownHeight: 1.5f, isOnGround: grounded, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000000u); float advance = Vector3.Distance(result.Position, pos); if (advance < 0.001f) frozenStreak++; else frozenStreak = 0; samples.Add(new TickSample( tick, result.Position, advance, result.CollisionNormalValid, result.CollisionNormal, result.IsOnGround, frozenStreak)); pos = result.Position; cell = result.CellId; body.Position = pos; if (!grounded && result.IsOnGround) { grounded = true; ticksSinceGrounded = 0; } else if (grounded) { ticksSinceGrounded++; if (ticksSinceGrounded >= postLandingTicks) break; } } return samples; } /// /// Characterization test: dumps the full per-tick trajectory so the A/B /// bisect (this file's class doc) can diff HEAD vs the S1-reverted local /// edit. Always passes — this is a diagnostic capture, matching the /// project's existing LiveCompare_FirstCap_DiagnosticDump-style /// tests. The actual pass/fail verdict is recorded in /// docs/research/2026-07-30-265-capture-bisect.md, not as a /// hardcoded assertion here, because the correct fix shape (and therefore /// the correct future regression assertion) is still being decided. /// [Fact] public void RealCapturedRoofLanding_CharacterizeCurrentBehavior() { PhysicsDiagnostics.ResetForTest(); PhysicsDiagnostics.ProbeIndoorBspEnabled = true; PhysicsDiagnostics.ProbeBuildingEnabled = true; try { var samples = ReplayRealRoofLanding(); int maxFrozen = 0; int landedAtTick = -1; foreach (var s in samples) { maxFrozen = System.Math.Max(maxFrozen, s.FrozenStreak); if (landedAtTick < 0 && s.OnGround) landedAtTick = s.Tick; _out.WriteLine(string.Format( System.Globalization.CultureInfo.InvariantCulture, "t{0,3}: pos=({1:F3},{2:F3},{3:F3}) adv={4:F4} cnv={5} n=({6:F3},{7:F3},{8:F3}) onGround={9} frozen={10}", s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z, s.Advance, s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z, s.OnGround, s.FrozenStreak)); } _out.WriteLine($"=== landedAtTick={landedAtTick} maxFrozenStreak={maxFrozen} totalTicks={samples.Count} ==="); // Sanity-only assertion: the replay must actually reach the roof // (land) within the ballistic approach window — if this fails the // synthetic fixture itself is wrong, not a physics-engine finding. Assert.True(landedAtTick is >= 0 and < 30, $"Replay never reached the synthetic roof polygon (landedAtTick={landedAtTick}); " + "fixture geometry or approach trajectory needs adjustment before this is a valid oracle."); } finally { PhysicsDiagnostics.ResetForTest(); } } }