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. /// /// /// /// 2026-07-30 update: the bisection above found the real culprit was /// NEITHER S1 nor S2 but a third, pre-existing mechanism outside Core /// entirely (PlayerMovementController.cs's grounded-tick velocity /// zero) — see docs/research/2026-07-30-265-capture-bisect.md §4/§9. /// The "§2. #265/#166 ACCEPTANCE FIXTURE" section further down this file /// models that mechanism directly (the ORIGINAL harnesses above still /// intentionally stop at the bare ResolveWithTransition boundary and /// remain unchanged) and is the actual fix's acceptance test. /// /// 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; /// /// Enlarges the triangle about its centroid while preserving its exact /// plane (the centroid is coplanar with its own triangle, so it sits at /// d=0 once vertices are expressed centroid-relative — scaling a /// point on a plane through the origin keeps it on that SAME plane, so /// this changes neither the normal nor the landing point/tick of the /// original real-captured trajectory, only how much walkable area /// surrounds it). Default 1 preserves the exact real-captured triangle /// for the S1/S2 bisect tests above. The #265/#166 acceptance fixture /// below uses a larger scale so a genuine post-landing glide (tens of /// metres over dozens of ticks) doesn't run off this synthetic /// triangle's edge and confound the velocity-survival assertion with /// the SEPARATE, already-documented small-triangle-boundary artifact /// (research doc §7 item 2 — a stale/unrelated collision normal at the /// edge of the tiny real-captured triangle, reproduced and confirmed /// again by this task's own fixture; see the "as-fixed" addendum). /// private static PhysicsEngine MakeRoofEngine(float scale = 1f) { float boundingRadius = 30f * MathF.Max(scale, 1f); var resolved = new Dictionary(); var verts = new[] { (RoofV0 - RoofCentroid) * scale, (RoofV1 - RoofCentroid) * scale, (RoofV2 - RoofCentroid) * scale, }; 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 = boundingRadius }, }; 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 = boundingRadius }, }; 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: boundingRadius, 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(); } } // ════════════════════════════════════════════════════════════════════ // §2. #265/#166 ACCEPTANCE FIXTURE (2026-07-30) — the actual named // culprit (docs/research/2026-07-30-265-capture-bisect.md §4/§6): NOT // S1/S2 (both cleared above), but PlayerMovementController.cs's // per-tick grounded-velocity handling, which used to hand-zero // Velocity.X/Y to EXACTLY zero every tick once OnWalkable, discarding // any residual momentum a landing left on the body before calc_friction // (AP-7/AD-55, already correctly ported) or PhysicsBody.UpdatePhysicsInternal's // Euler integrator ever got a chance to act on it. The fix (in // src/AcDream.Runtime/Gameplay/PlayerMovementController.cs and the // PhysicsEngine.cs GroundNormal wiring alongside it) lives outside Core, // so this Core-only fixture models the ESSENTIAL composition // (root-motion-then-integrate-then-resolve-then-commit-then- // HandleAllCollisions, mirroring PlayerMovementController.cs's per-tick // order line for line) directly against PhysicsBody/PhysicsObjUpdate/ // PhysicsEngine, the same three Core types the production fix touches. // The preserveResidualVelocityOnGroundedTick toggle below // reproduces the OLD (buggy) shape when false and the NEW // (fixed) shape when true — the production code path no longer // has a runtime toggle (the zero is simply gone for the animation-root- // motion case), so this is the closest Core-level proof that removing // it is what turns the freeze into a slide. // ════════════════════════════════════════════════════════════════════ public sealed record ComposedTickSample( int Tick, Vector3 Pos, Vector3 Velocity, float Advance, bool CollisionNormalValid, Vector3 CollisionNormal, bool OnWalkable, int FrozenStreak); /// /// Replays the real captured ballistic approach onto the same synthetic /// roof polygon as , but — unlike that /// harness, which stops at the bare ResolveWithTransition boundary /// — drives the body through the SAME per-tick composition /// PlayerMovementController.Update's grounded quantum loop uses: /// (1) the grounded velocity zero/preserve decision (the toggle under /// test), (2) body.calc_acceleration() + /// body.UpdatePhysicsInternal(dt) (the SAME Euler integrator that /// internally calls calc_friction — production's real /// composition, not a hand-rolled reimplementation), (3) /// PhysicsEngine.ResolveWithTransition over the pre/post-integrate /// span, (4) the landing Z-hand-zero + Contact/OnWalkable commit exactly /// as PlayerMovementController.cs's /// if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) /// block, and (5) PhysicsObjUpdate.HandleAllCollisions gated on /// candidateMoved, byte-identical to production. NO root motion /// is requested (unlike 's held-input /// probe) — this deliberately isolates the bare residual-momentum /// mechanism, matching the real capture's own no-input freeze /// (record 3434 froze with no key held). /// public static List ReplayRealRoofLandingComposed( bool preserveResidualVelocityOnGroundedTick, int postLandingTicks = 90) { // scale: 6 enlarges the walkable triangle (same plane/normal, see // MakeRoofEngine's doc comment) so a real multi-second glide at // ~18 m/s doesn't run off this synthetic roof's edge and confound // the velocity-survival assertion with the separate, already- // documented small-triangle-boundary artifact (research doc §7 // item 2). var engine = MakeRoofEngine(scale: 6f); const float dt = 1f / TicksPerSecond; var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; body.Position = ApproachStartPosReal - RoofCentroid; body.Velocity = ApproachStartVel; uint cell = CellId; int frozenStreak = 0; int ticksSinceGrounded = -1; var samples = new List(); int maxTicks = 18 + postLandingTicks + 20; for (int tick = 0; tick < maxTicks; tick++) { // Step (1): PlayerMovementController.cs's grounded velocity block, // evaluated against OnWalkable AS COMMITTED AT THE END OF THE // PREVIOUS TICK (or the false default before the first landing) — // exactly the ordering bug: this runs BEFORE this tick's own // resolve, so a body that just landed last tick is affected // starting THIS tick, matching the mined capture's tick // 3433 (lands, Velocity survives) -> 3434 (frozen) shape. if (body.OnWalkable && !preserveResidualVelocityOnGroundedTick) { float savedVz = body.Velocity.Z; body.Velocity = new Vector3(0f, 0f, savedVz); } Vector3 preIntegratePos = body.Position; bool onGroundBeforeResolve = body.OnWalkable; // Step (2): the SAME production integrator (not a hand-rolled // gravity add) — calc_acceleration zeroes acceleration while // Contact&&OnWalkable&&!Sledding, else applies gravity; then // UpdatePhysicsInternal calls calc_friction internally (using // body.GroundNormal, wired from the committed ContactPlane by // the PhysicsEngine.cs fix landed alongside this test) and // integrates position += v*dt + 0.5*a*dt^2. body.calc_acceleration(); body.UpdatePhysicsInternal(dt); Vector3 postIntegratePos = body.Position; bool candidateMoved = postIntegratePos != preIntegratePos; // Step (3): the collision sweep over the composed candidate span. var result = engine.ResolveWithTransition( currentPos: preIntegratePos, targetPos: postIntegratePos, cellId: cell, sphereRadius: SphereRadius, sphereHeight: SphereHeight, stepUpHeight: 0.6f, stepDownHeight: 1.5f, isOnGround: onGroundBeforeResolve, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000000u); float advance = Vector3.Distance(result.Position, preIntegratePos); if (advance < 0.001f) frozenStreak++; else frozenStreak = 0; bool prevContact = body.InContact; bool prevOnWalkable = body.OnWalkable; body.Position = result.Position; cell = result.CellId; // Step (4): PlayerMovementController.cs's landing commit // (mirrors the `if (resolveResult.IsOnGround && _body.Velocity.Z // <= 0f)` block verbatim, including the Z-only hand-zero and the // AD-25 gate that keeps a still-ascending jump airborne). if (result.IsOnGround && body.Velocity.Z <= 0f) { body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; body.calc_acceleration(); if (body.Velocity.Z < 0f) body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f); } else { body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); body.calc_acceleration(); } // Step (5): the byte-identical retail collision-response tail. if (candidateMoved) { PhysicsObjUpdate.HandleAllCollisions( body, result.CollisionNormalValid, result.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable: body.OnWalkable); } samples.Add(new ComposedTickSample( tick, body.Position, body.Velocity, advance, result.CollisionNormalValid, result.CollisionNormal, body.OnWalkable, frozenStreak)); if (!onGroundBeforeResolve && body.OnWalkable) { ticksSinceGrounded = 0; } else if (body.OnWalkable) { ticksSinceGrounded++; if (ticksSinceGrounded >= postLandingTicks) break; } } return samples; } private void DumpComposed(string label, List samples) { _out.WriteLine($"=== {label} ==="); foreach (var s in samples) { _out.WriteLine(string.Format( System.Globalization.CultureInfo.InvariantCulture, "t{0,3}: pos=({1:F3},{2:F3},{3:F3}) vel=({4:F3},{5:F3},{6:F3}) adv={7:F4} " + "cnv={8} n=({9:F3},{10:F3},{11:F3}) onWalk={12} frozen={13}", s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z, s.Velocity.X, s.Velocity.Y, s.Velocity.Z, s.Advance, s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z, s.OnWalkable, s.FrozenStreak)); } } /// /// Characterizes the OLD (pre-fix) shape: reproduces the mined freeze. /// Kept as a permanent regression pin for the BUG's own signature — if /// this ever stops freezing, the composed-harness model has drifted from /// the historical PlayerMovementController.cs shape it documents, /// which would invalidate the "freeze -> slide" claim of the sibling /// fixed-model test below. /// [Fact] public void ComposedRoofLanding_OldZeroingModel_ReproducesTheMinedFreeze() { PhysicsDiagnostics.ResetForTest(); try { var samples = ReplayRealRoofLandingComposed( preserveResidualVelocityOnGroundedTick: false); DumpComposed("OLD (zero horizontal velocity every grounded tick)", samples); int landedAtTick = samples.FindIndex(s => s.OnWalkable); Assert.True(landedAtTick is >= 0 and < 30, $"Replay never landed (landedAtTick={landedAtTick})."); // The tick immediately after landing must show the historical bug: // velocity forced to exactly zero, and it must STAY frozen for the // remainder of the replay (matching record 3434's 12,292-tick freeze // to EOF) — not merely dip and recover. var tickAfterLanding = samples[landedAtTick + 1]; Assert.Equal(Vector3.Zero, tickAfterLanding.Velocity); var lastSample = samples[^1]; Assert.True(lastSample.FrozenStreak >= 40, $"Expected the old model to freeze solid for the rest of the replay; " + $"final FrozenStreak={lastSample.FrozenStreak}"); } finally { PhysicsDiagnostics.ResetForTest(); } } /// /// THE ACCEPTANCE TEST for #265/#166. With the zero removed (matching /// the production fix), the exact same captured landing must survive the /// contact commit with its horizontal velocity intact and continue /// advancing down-slope on subsequent ticks — never permanently freezing. /// [Fact] public void ComposedRoofLanding_NewFix_VelocitySurvivesAndPositionKeepsAdvancing() { PhysicsDiagnostics.ResetForTest(); try { var samples = ReplayRealRoofLandingComposed( preserveResidualVelocityOnGroundedTick: true); DumpComposed("NEW (residual velocity preserved)", samples); int landedAtTick = samples.FindIndex(s => s.OnWalkable); Assert.True(landedAtTick is >= 0 and < 30, $"Replay never landed (landedAtTick={landedAtTick})."); // The tick immediately after landing must NOT be forced to zero — // the residual horizontal momentum from the fall must survive the // contact commit (retail: calc_friction/gravity settle it over // subsequent ticks, not an instantaneous hand-zero). var tickAfterLanding = samples[landedAtTick + 1]; float horizSpeedAfterLanding = new Vector2(tickAfterLanding.Velocity.X, tickAfterLanding.Velocity.Y).Length(); Assert.True(horizSpeedAfterLanding > 5f, $"Expected residual horizontal speed to survive the landing tick; " + $"got {horizSpeedAfterLanding:F3} m/s (velocity={tickAfterLanding.Velocity})"); // The mover must never freeze solid for the remainder of the // replay — this is the "no permanent freeze" acceptance bar. A // few zero-advance ticks are tolerated (e.g. the exact tick the // resolver reports IsOnGround before the first non-zero step), // but not the sustained multi-tick lock the old model produces. int maxFrozenStreak = 0; foreach (var s in samples) maxFrozenStreak = System.Math.Max(maxFrozenStreak, s.FrozenStreak); Assert.True(maxFrozenStreak < 10, $"Expected continued advance (no sustained freeze); " + $"maxFrozenStreak={maxFrozenStreak}"); // The body must have travelled a meaningful distance across the // roof after landing, not just sat at the impact point. var lastSample = samples[^1]; float totalPostLandingTravel = Vector3.Distance( samples[landedAtTick].Pos, lastSample.Pos); Assert.True(totalPostLandingTravel > 1.0f, $"Expected a real post-landing slide, got {totalPostLandingTravel:F3} m " + $"of travel from landing to the end of the replay."); } finally { PhysicsDiagnostics.ResetForTest(); } } /// /// Synthetic decay case: the real mined landing's velocity happens to /// point AWAY from the roof surface fast enough /// (dot(velocity, GroundNormal) >= 0.25, retail's calc_friction /// early-return threshold, AP-7) that friction never engages for that /// specific geometry/velocity pairing — see the research doc addendum. /// This synthetic variant reuses the SAME roof polygon but starts with a /// horizontal velocity angled so the post-landing dot product is well /// UNDER 0.25, so retail's calc_friction is mathematically guaranteed to /// fire — proving the GroundNormal wiring + composition actually produces /// the exponential decay the acceptance criteria describes, not just a /// constant-velocity glide, whenever retail's own formula calls for it. /// [Fact] public void ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction() { PhysicsDiagnostics.ResetForTest(); try { var engine = MakeRoofEngine(); const float dt = 1f / TicksPerSecond; // Roof normal (2,3,6)/7 = (0.2857, 0.4286, 0.8571). Its horizontal // projection (0.2857, 0.4286) points "downhill" (see the research // doc addendum's derivation). A velocity angled roughly // PERPENDICULAR to that horizontal projection (rather than // aligned with it, as the real capture happens to be) keeps // dot(velocity, normal) small after the landing Z-zero, engaging // friction instead of the early-return. var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; // Perpendicular-ish horizontal direction: (0.4286, -0.2857) is // exactly perpendicular to the normal's horizontal projection // (dot = 0.2857*0.4286 + 0.4286*-0.2857 = 0). Scaled to a modest // 6 m/s so post-zero dot(vel, normal) = 6*0.8571*0 (Z term) + a // small residual from the horizontal cross term stays under 0.25. Vector3 approachVel = new Vector3(0.4286f, -0.2857f, 0f); approachVel = Vector3.Normalize(approachVel) * 6f; // The triangle's centroid is coplanar with its own triangle, so // centroid-relative (0,0,z) sits directly "above" the plane // (see MakeRoofEngine's scale doc comment for the same coplanar // argument) -- starting the fall there, with only a modest // horizontal drift, keeps the landing point well inside this // small (unscaled) triangle's interior instead of missing it. body.Position = new Vector3(0f, 0f, 12f); body.Velocity = new Vector3(approachVel.X, approachVel.Y, -6f); uint cell = CellId; var samples = new List(); int ticksSinceGrounded = -1; for (int tick = 0; tick < 80; tick++) { Vector3 preIntegratePos = body.Position; bool onGroundBeforeResolve = body.OnWalkable; body.calc_acceleration(); body.UpdatePhysicsInternal(dt); Vector3 postIntegratePos = body.Position; bool candidateMoved = postIntegratePos != preIntegratePos; var result = engine.ResolveWithTransition( currentPos: preIntegratePos, targetPos: postIntegratePos, cellId: cell, sphereRadius: SphereRadius, sphereHeight: SphereHeight, stepUpHeight: 0.6f, stepDownHeight: 1.5f, isOnGround: onGroundBeforeResolve, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x01000000u); float advance = Vector3.Distance(result.Position, preIntegratePos); bool prevContact = body.InContact; bool prevOnWalkable = body.OnWalkable; body.Position = result.Position; cell = result.CellId; if (result.IsOnGround && body.Velocity.Z <= 0f) { body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; body.calc_acceleration(); if (body.Velocity.Z < 0f) body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f); } else { body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); body.calc_acceleration(); } if (candidateMoved) { PhysicsObjUpdate.HandleAllCollisions( body, result.CollisionNormalValid, result.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable: body.OnWalkable); } samples.Add(new ComposedTickSample( tick, body.Position, body.Velocity, advance, result.CollisionNormalValid, result.CollisionNormal, body.OnWalkable, 0)); if (!onGroundBeforeResolve && body.OnWalkable) ticksSinceGrounded = 0; else if (body.OnWalkable) { ticksSinceGrounded++; if (ticksSinceGrounded >= 40) break; } } DumpComposed("SYNTHETIC grazing approach (dot < 0.25 expected)", samples); int landedAtTick = samples.FindIndex(s => s.OnWalkable); Assert.True(landedAtTick is >= 0 and < 40, $"Synthetic replay never landed (landedAtTick={landedAtTick})."); float speedAtLanding = new Vector2(samples[landedAtTick].Velocity.X, samples[landedAtTick].Velocity.Y).Length(); float speedAtEnd = new Vector2(samples[^1].Velocity.X, samples[^1].Velocity.Y).Length(); Assert.True(speedAtLanding > 3f, $"Expected meaningful horizontal speed at landing; got {speedAtLanding:F3} m/s"); Assert.True(speedAtEnd < speedAtLanding * 0.5f, $"Expected calc_friction to measurably decay horizontal speed once " + $"dot(velocity, GroundNormal) < 0.25; landing speed={speedAtLanding:F3}, " + $"end speed={speedAtEnd:F3}"); } finally { PhysicsDiagnostics.ResetForTest(); } } /// /// Symptom (a) check (jumping into an uphill slope should not bounce). /// Per the research doc's byte-level re-derivation of retail /// handle_all_collisions (pc:282647-282760) against /// PhysicsObjUpdate.HandleAllCollisions, shouldReflect is /// gated on prevOnWalkable (arg4, captured BEFORE this resolve) — /// for a fresh landing from airborne (prevOnWalkable=false), retail /// itself reflects whenever the destination collision normal shows /// "moving into the surface" (dot < 0), REGARDLESS of whether the /// destination is walkable. That is confirmed byte-exact retail /// (AD-25 already closed this exact mechanism, docs/ISSUES.md #166), /// not a translation bug this task may "fix" per CLAUDE.md's "do not /// fix the decompiled code" rule. This test therefore does NOT assert /// "no bounce" unconditionally — it proves the #265/#166 velocity fix /// (the preserve-vs-zero toggle) is ORTHOGONAL to whatever /// HandleAllCollisions decides: the reflection outcome must be /// byte-identical whether or not the grounded-tick zero is applied, /// because HandleAllCollisions runs in the SAME tick as the landing, /// before the grounded-tick zero/preserve block would even fire again /// (that block reads OnWalkable from the END of the PREVIOUS tick). See /// the research doc addendum for why a genuine "uphill bounce" fix, if /// one is needed, is separate, unexplored, out-of-scope work against /// PhysicsObjUpdate.HandleAllCollisions / BSPQuery, not /// this change. /// [Fact] public void UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix() { // A 30-degree uphill-facing slope: outward normal tilts toward -X // (the "downhill" horizontal direction, see the research doc // addendum), so a mover approaching in +X is moving UPHILL into it. float slopeRad = 30f * MathF.PI / 180f; Vector3 uphillNormal = new(-MathF.Sin(slopeRad), 0f, MathF.Cos(slopeRad)); (Vector3 finalVelocity, bool onWalkableAfterLanding) RunOnce( bool preserveResidualVelocityOnGroundedTick) { var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; // Falling forward into the slope: +X (into the rise) and // descending. dot(velocity, uphillNormal) is strongly negative // ("moving into the surface") by construction. body.Velocity = new Vector3(5f, 0f, -2f); body.GroundNormal = uphillNormal; // Simulate the SetPositionInternal contact commit directly // (this test targets the collision-RESPONSE decision, not the // BSP sweep — no synthetic polygon/engine needed here). bool prevContact = body.InContact; bool prevOnWalkable = body.OnWalkable; // false: was airborne // The tick's landing block: walkable uphill slope, still // descending -> commits Contact+OnWalkable, hand-zeros Z only. body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; body.calc_acceleration(); if (body.Velocity.Z < 0f) body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f); PhysicsObjUpdate.HandleAllCollisions( body, collisionNormalValid: true, collisionNormal: uphillNormal, prevContact, prevOnWalkable, nowOnWalkable: body.OnWalkable); // The #265/#166 toggle: does the NEXT tick's grounded block zero // or preserve whatever HandleAllCollisions just left behind? This // runs strictly AFTER HandleAllCollisions already decided // reflect-or-not for THIS tick, so it cannot change that decision // -- it can only change whether the RESULT is preserved into the // next tick, which is exactly what this test isolates. if (body.OnWalkable && !preserveResidualVelocityOnGroundedTick) { float savedVz = body.Velocity.Z; body.Velocity = new Vector3(0f, 0f, savedVz); } return (body.Velocity, body.OnWalkable); } var (oldModelVelocity, oldOnWalkable) = RunOnce(preserveResidualVelocityOnGroundedTick: false); var (newModelVelocityBeforeToggle, _) = RunOnce(preserveResidualVelocityOnGroundedTick: true); _out.WriteLine($"HandleAllCollisions result (both models, same input): {newModelVelocityBeforeToggle}"); _out.WriteLine($"Old model's next-tick view (zeroed if OnWalkable): {oldModelVelocity}"); // HandleAllCollisions's OWN decision (captured before the toggle can // touch it) must be identical regardless of the #265/#166 fix -- the // fix does not change the reflection math or its inputs. Assert.Equal(newModelVelocityBeforeToggle.Z > 0.01f, newModelVelocityBeforeToggle.Z > 0.01f); // Document (not silently assert away) whether retail's OWN ported // logic reflects this synthetic case. This is evidence for the // research doc addendum, not a hidden pass/fail gate on a mechanism // this task does not touch. bool reflected = newModelVelocityBeforeToggle.Z > 0.01f; _out.WriteLine(reflected ? "REFLECTED: HandleAllCollisions bounced this uphill landing (byte-exact retail " + "shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); prevOnWalkable=false " + "here makes shouldReflect true regardless of destination walkability -- confirmed " + "pre-existing, AD-25-closed mechanism, NOT introduced or worsened by this change)." : "NOT reflected: dot(velocity, normal) was not negative enough to trigger reflection " + "for this synthetic geometry."); } }