fix(physics): #265/#166 - stop zeroing grounded residual velocity, wire GroundNormal
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 <noreply@anthropic.com>
This commit is contained in:
parent
61e959169b
commit
06c76009f1
5 changed files with 673 additions and 36 deletions
|
|
@ -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 <c>CPhysicsObj::calc_friction @ 0x0050ee70</c>'s
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,17 @@ namespace AcDream.Core.Tests.Physics;
|
|||
/// (<see cref="TickSample"/> list, printed via <see cref="ITestOutputHelper"/>)
|
||||
/// is the diff target.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>2026-07-30 update:</b> the bisection above found the real culprit was
|
||||
/// NEITHER S1 nor S2 but a third, pre-existing mechanism outside Core
|
||||
/// entirely (<c>PlayerMovementController.cs</c>'s grounded-tick velocity
|
||||
/// zero) — see <c>docs/research/2026-07-30-265-capture-bisect.md</c> §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 <c>ResolveWithTransition</c> boundary and
|
||||
/// remain unchanged) and is the actual fix's acceptance test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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()
|
||||
/// <param name="scale">
|
||||
/// Enlarges the triangle about its centroid while preserving its exact
|
||||
/// plane (the centroid is coplanar with its own triangle, so it sits at
|
||||
/// <c>d=0</c> 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).
|
||||
/// </param>
|
||||
private static PhysicsEngine MakeRoofEngine(float scale = 1f)
|
||||
{
|
||||
float boundingRadius = 30f * MathF.Max(scale, 1f);
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||
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<ushort, Polygon>(),
|
||||
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 <c>preserveResidualVelocityOnGroundedTick</c> toggle below
|
||||
// reproduces the OLD (buggy) shape when <c>false</c> and the NEW
|
||||
// (fixed) shape when <c>true</c> — 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);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the real captured ballistic approach onto the same synthetic
|
||||
/// roof polygon as <see cref="ReplayRealRoofLanding"/>, but — unlike that
|
||||
/// harness, which stops at the bare <c>ResolveWithTransition</c> boundary
|
||||
/// — drives the body through the SAME per-tick composition
|
||||
/// <c>PlayerMovementController.Update</c>'s grounded quantum loop uses:
|
||||
/// (1) the grounded velocity zero/preserve decision (the toggle under
|
||||
/// test), (2) <c>body.calc_acceleration()</c> +
|
||||
/// <c>body.UpdatePhysicsInternal(dt)</c> (the SAME Euler integrator that
|
||||
/// internally calls <c>calc_friction</c> — production's real
|
||||
/// composition, not a hand-rolled reimplementation), (3)
|
||||
/// <c>PhysicsEngine.ResolveWithTransition</c> over the pre/post-integrate
|
||||
/// span, (4) the landing Z-hand-zero + Contact/OnWalkable commit exactly
|
||||
/// as <c>PlayerMovementController.cs</c>'s
|
||||
/// <c>if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)</c>
|
||||
/// block, and (5) <c>PhysicsObjUpdate.HandleAllCollisions</c> gated on
|
||||
/// <c>candidateMoved</c>, byte-identical to production. NO root motion
|
||||
/// is requested (unlike <see cref="ReplayRealRoofLanding"/>'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).
|
||||
/// </summary>
|
||||
public static List<ComposedTickSample> 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<ComposedTickSample>();
|
||||
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<ComposedTickSample> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>PlayerMovementController.cs</c> shape it documents,
|
||||
/// which would invalidate the "freeze -> slide" claim of the sibling
|
||||
/// fixed-model test below.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic decay case: the real mined landing's velocity happens to
|
||||
/// point AWAY from the roof surface fast enough
|
||||
/// (<c>dot(velocity, GroundNormal) >= 0.25</c>, 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.
|
||||
/// </summary>
|
||||
[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<ComposedTickSample>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symptom (a) check (jumping into an uphill slope should not bounce).
|
||||
/// Per the research doc's byte-level re-derivation of retail
|
||||
/// <c>handle_all_collisions</c> (pc:282647-282760) against
|
||||
/// <c>PhysicsObjUpdate.HandleAllCollisions</c>, <c>shouldReflect</c> is
|
||||
/// gated on <c>prevOnWalkable</c> (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
|
||||
/// <c>PhysicsObjUpdate.HandleAllCollisions</c> / <c>BSPQuery</c>, not
|
||||
/// this change.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue