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;