From 06c76009f1d8e11f5fdc36c6ab465a910156a9ad Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 18:45:05 +0200 Subject: [PATCH] fix(physics): #265/#166 - stop zeroing grounded residual velocity, wire GroundNormal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture bisect (docs/research/2026-07-30-265-capture-bisect.md, mined from artifacts/matrix-session2-resolve.jsonl records 3415-3434) traced #265's lost roof slides / permanent landing freeze and #166's missing downhill sled to a pre-existing (2026-07-20, ten days before Campaign P - not a regression) mechanism in PlayerMovementController.cs's grounded quantum block: it hand-zeroed Velocity.X/Y to exactly zero every tick once OnWalkable whenever animation root motion drives the walk (the production graphical local-player path), discarding any residual horizontal momentum a fall 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. Two changes: 1. PhysicsEngine.cs now syncs body.GroundNormal (the vector calc_friction dots velocity against, per retail CPhysicsObj::calc_friction 0x0050ee70's `contact_plane.Normal` read) from the committed ContactPlane.Normal at the same commit point that already publishes ContactPlane. GroundNormal had zero production writers before this and silently defaulted to Vector3.UnitZ forever - even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Core-level, so player, remote, ordinary, and projectile movers all benefit uniformly. 2. PlayerMovementController.cs's grounded block no longer reconstructs Velocity at all for the animation-root-motion case (only the headless/test-controller get_state_velocity fallback still does, unchanged). Root motion continues to fully own commanded locomotion; this only stops destroying whatever Velocity already holds, letting it compose with root motion through the same ResolveWithTransition sweep exactly as retail's CPhysicsObj::UpdatePositionInternal composes both channels. Symptom (a), the uphill-jump bounce, traces to a SEPARATE, byte-exact (re-verified against acclient_2013_pseudo_c.txt:282647-282760), already-closed retail mechanism (AD-25, PhysicsObjUpdate. HandleAllCollisions's shouldReflect gate) - confirmed orthogonal to this fix, not addressed here (see the research doc's as-fixed addendum §9.5). Issue265SteepSlopeCaptureBisectTests.cs gains a composed harness (ReplayRealRoofLandingComposed) mirroring PlayerMovementController.cs's per-tick composition against Core types only, proving: the old model reproduces the mined freeze exactly; the new model survives the landing and slides continuously (the real captured geometry glides at constant velocity per retail's own dot>=0.25 early-return - AP-7); a synthetic dot<0.25 case shows genuine exponential decay via calc_friction; and a synthetic uphill-bounce case proves the fix changes nothing about HandleAllCollisions's reflection decision. Co-Authored-By: Claude Opus 5 --- src/AcDream.Core/Physics/PhysicsBody.cs | 26 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 18 + .../Gameplay/PlayerMovementController.cs | 50 +- .../Issue265SteepSlopeCaptureBisectTests.cs | 579 +++++++++++++++++- .../Physics/PhysicsBodyTests.cs | 36 +- 5 files changed, 673 insertions(+), 36 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index a0583edd..c55d72ce 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -628,12 +628,11 @@ public sealed class PhysicsBody /// remaining — matches the observed hammering almost exactly. /// /// Why this is safe to land now: the L.3c test predates the 2026-07-17 - /// "local player animation-owned grounded movement" landing (R6). - /// PlayerMovementController.cs (~line 1742) zeroes Velocity.X/Y to 0 - /// immediately before UpdatePhysicsInternal runs whenever animation root - /// motion drives the walk (the production graphical local-player path - /// since R6) — walking displacement comes from the animation Frame delta - /// applied directly to Position, not from integrating Velocity. Friction + /// "local player animation-owned grounded movement" landing (R6). Walking + /// displacement comes from the animation Frame delta applied directly to + /// Position, not from integrating Velocity, so ordinary root-motion-driven + /// walking never puts real XY speed into Velocity in the first place + /// (nothing writes it there — see the #265/#166 fix note below). Friction /// decaying an already-zero horizontal Velocity is a no-op, so the L.3c /// mechanism does not reproduce on that path. The `else` branch (no /// animation root motion — headless/test-controller movers using @@ -642,6 +641,21 @@ public sealed class PhysicsBody /// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests for /// the regression pin on the root-motion path specifically. /// + /// #265/#166 RESOLVED (2026-07-30, + /// docs/research/2026-07-30-265-capture-bisect.md §9): until this date, + /// PlayerMovementController.cs's grounded-tick block ALSO hand-zeroed + /// Velocity.X/Y to exactly 0 every tick once OnWalkable for the + /// animation-root-motion case (regardless of why the body was grounded — + /// a fall, not just ordinary walking), discarding any residual landing + /// momentum before this very function ever got a chance to decay it, and + /// GroundNormal (the vector this function dots velocity against) had zero + /// production writers and silently defaulted to Vector3.UnitZ. Both gaps + /// are now closed: the grounded block no longer reconstructs Velocity for + /// the root-motion case, and PhysicsEngine.cs syncs GroundNormal from the + /// committed ContactPlane.Normal after every resolve. This function's + /// 0.25f threshold and Sledding overrides were always correctly ported; + /// they simply had nothing real to operate on until now. + /// /// AD-55 RESOLVED (Campaign P final physics slice, 2026-07-30; byte /// decode in docs/research/2026-07-30-ts4-116-oracle-plan.md Addendum). /// Raw bytes of CPhysicsObj::calc_friction @ 0x0050ee70's diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 43d5d853..b800fecb 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1300,6 +1300,18 @@ public sealed class PhysicsEngine body.ContactPlane = ci.ContactPlane; body.ContactPlaneCellId = ci.ContactPlaneCellId; body.ContactPlaneIsWater = ci.ContactPlaneIsWater; + // #265/#166 (2026-07-30): retail CPhysicsObj::calc_friction + // (0x0050ee70) reads `this->contact_plane.Normal` directly off + // the object (see PhysicsBody.calc_friction's doc comment). + // acdream models that same field as the separate GroundNormal + // property so isolated unit tests can drive calc_friction + // without a full resolve, but nothing wrote it from a live + // resolve before now -- calc_friction always saw the Vector3.UnitZ + // default, i.e. every slope behaved like flat ground. Sync it + // here, at the SAME commit point that already publishes + // ContactPlane, so every caller (player, remote, ordinary, + // projectile) gets a real slope normal for free. + body.GroundNormal = ci.ContactPlane.Normal; } else if (ci.LastKnownContactPlaneValid) { @@ -1307,10 +1319,16 @@ public sealed class PhysicsEngine body.ContactPlane = ci.LastKnownContactPlane; body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId; body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater; + body.GroundNormal = ci.LastKnownContactPlane.Normal; } else { body.ContactPlaneValid = false; + // GroundNormal left unchanged/stale -- matches ContactPlane's + // own stale-retention pattern in this branch (comment above). + // calc_friction only reads it while OnWalkable, and OnWalkable + // cannot be true without a valid contact plane, so a stale + // value here is never observed. } // AP-10 (Campaign P Slice P4, 2026-07-30): retail SetPositionInternal diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index d10d788d..32ff26ed 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -1865,20 +1865,46 @@ public sealed class PlayerMovementController * tickDt; } - if (_body.OnWalkable) + // #265/#166 (2026-07-30, docs/research/2026-07-30-265-capture-bisect.md + // §4): retail CPhysicsObj::UpdatePositionInternal (0x00512C30) composes + // BOTH channels every quantum -- the root-motion Frame just written into + // pmDelta.Origin above (commanded locomotion) AND the integrated physics + // Velocity (residual momentum: jump arcs, landing slides) -- via the SAME + // candidate position that PhysicsBody.UpdatePhysicsInternal's Euler step + // (calc_friction + v*dt, below) and the ResolveWithTransition sweep both + // see. This block used to hand-zero Velocity.X/Y to EXACTLY zero on every + // single grounded tick whenever animation root motion drives the walk (the + // production graphical local-player path since R6) -- regardless of why + // the body was OnWalkable. That discarded any horizontal momentum a fall + // or collision had just left on the body (retail settles it via + // calc_friction over subsequent ticks; PhysicsBody.GroundNormal is now + // synced to the real contact-plane normal by PhysicsEngine so calc_friction + // has real slope data to act on) before the integrator ever ran -- a mover + // that landed on a walkable roof/slope with residual horizontal velocity + // had that velocity vanish the very next tick and never moved again. Root + // motion still fully owns COMMANDED locomotion (walking/running + // displacement comes from pmDelta.Origin above, not from Velocity), so + // this does not reintroduce command- or packet-cadence-derived grounded + // translation -- it only stops DESTROYING whatever Velocity already holds. + // Ordinary walking is unaffected: Velocity is already ~0 while grounded + // with no fall/collision in flight (nothing else writes it), so removing + // this zero is a no-op on that path -- see + // GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests + // (PhysicsBodyTests.cs) and Update_AnimationRootMotion_WalkSpeedUnaffected + // ByResidualVelocityFix (PlayerMovementControllerTests.cs). + // + // The headless/test-controller fallback below (no animation runtime -- + // get_state_velocity's doc comment) is unchanged: it still directly + // writes the commanded state velocity into the body every grounded tick, + // exactly as before -- that model has no separate root-motion channel to + // compose with, so overwriting IS its correct per-tick behavior. + if (_body.OnWalkable && !hasAnimationRootMotion) { float savedWorldVz = _body.Velocity.Z; - if (hasAnimationRootMotion) - { - _body.Velocity = new Vector3(0f, 0f, savedWorldVz); - } - else - { - Vector3 stateVelocity = _motion.get_state_velocity(); - _body.set_local_velocity( - new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz), - autonomous: _body.LastMoveWasAutonomous); - } + Vector3 stateVelocity = _motion.get_state_velocity(); + _body.set_local_velocity( + new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz), + autonomous: _body.LastMoveWasAutonomous); } var preIntegratePos = _body.Position; diff --git a/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs index da53acf6..aefacc55 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs @@ -81,6 +81,17 @@ namespace AcDream.Core.Tests.Physics; /// ( 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 { @@ -149,10 +160,33 @@ public class Issue265SteepSlopeCaptureBisectTests // worldPos reconstructs the exact real-world triangle). private static readonly Vector3 RoofCentroid = (RoofV0 + RoofV1 + RoofV2) / 3f; - private static PhysicsEngine MakeRoofEngine() + /// + /// 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, RoofV1 - RoofCentroid, RoofV2 - RoofCentroid }; + 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 @@ -166,7 +200,7 @@ public class Issue265SteepSlopeCaptureBisectTests var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf, - BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = boundingRadius }, }; leaf.Polygons.Add(1); @@ -190,7 +224,7 @@ public class Issue265SteepSlopeCaptureBisectTests PhysicsPolygons = new Dictionary(), Vertices = new VertexArray(), Resolved = resolved, - BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = boundingRadius }, }; cache.RegisterGfxObjForTest(SyntheticGfxId, physics); engine.DataCache = cache; @@ -213,7 +247,7 @@ public class Issue265SteepSlopeCaptureBisectTests gfxObjId: SyntheticGfxId, worldPos: Vector3.Zero, rotation: Quaternion.Identity, - radius: 30f, + radius: boundingRadius, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: LandblockId, @@ -363,4 +397,539 @@ public class Issue265SteepSlopeCaptureBisectTests 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."); + } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs index ae32df41..f1c288ae 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs @@ -502,19 +502,29 @@ public sealed class PhysicsBodyTests { // Campaign P Slice P2 research finding: the reverted 2026-04-30 L.3c // regression (forward locomotion 3 -> 0.16 m/s) cannot reproduce on - // the production graphical local-player path post-R6, because - // PlayerMovementController zeroes Velocity.X/Y to exactly zero every - // tick BEFORE UpdatePhysicsInternal/calc_friction runs whenever - // animation root motion drives the walk (walking displacement comes - // from the animation Frame delta applied directly to Position, not - // from integrating Velocity). This test pins that specific state at - // the PhysicsBody level (the only file this slice may change): - // Velocity.XY == 0 on flat ground is IDENTICAL after calc_friction - // whether the threshold is the old 0.0 or the new retail 0.25 -- - // friction has nothing to hammer because there is no horizontal - // velocity for it to act on. Only the residual vertical (gravity) - // component may be affected by the normal-removal step, exactly as - // retail's own contact handling expects. + // the production graphical local-player path post-R6, because ordinary + // root-motion-driven walking (no fall/collision in flight) never puts + // real horizontal speed into Velocity in the first place -- walking + // displacement comes from the animation Frame delta applied directly + // to Position, not from integrating Velocity, and nothing else writes + // Velocity.XY during ordinary grounded locomotion. This test pins that + // specific state at the PhysicsBody level (the only file this slice + // may change): Velocity.XY == 0 on flat ground is IDENTICAL after + // calc_friction whether the threshold is the old 0.0 or the new + // retail 0.25 -- friction has nothing to hammer because there is no + // horizontal velocity for it to act on. Only the residual vertical + // (gravity) component may be affected by the normal-removal step, + // exactly as retail's own contact handling expects. + // + // #265/#166 (2026-07-30): PlayerMovementController.cs USED TO also + // hand-zero Velocity.X/Y to exactly zero every grounded tick for the + // animation-root-motion case (belt-and-suspenders on top of the "walk + // speed never writes it" fact above) -- that zero is now REMOVED (see + // docs/research/2026-07-30-265-capture-bisect.md §9), because it also + // discarded real residual landing momentum a fall left behind. This + // test's own premise (Velocity.XY already 0, no walk speed in it) is + // unaffected either way -- it exercises calc_friction in isolation and + // never depended on the removed zero. var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Friction = 0.95f;