diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 897e90dc..9bd67c5c 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -66,6 +66,12 @@ public enum TransientStateFlags : uint
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
+ // retail frames_stationary_fall carried across frames: transition() seeds fsf from
+ // these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf
+ // into them at the end of the frame (pc:282743/282749/282753).
+ StationaryFall = 0x00000010, // bit 4 — fsf == 1
+ StationaryStop = 0x00000020, // bit 5 — fsf == 2
+ StationaryStuck = 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
}
@@ -179,9 +185,28 @@ public sealed class PhysicsBody
/// Orientation quaternion (struct offsets 0x60–0x80 column matrix).
public Quaternion Orientation { get; set; } = Quaternion.Identity;
- /// World-space velocity (+0xE0/E4/E8).
+ /// World-space velocity (+0xE0/E4/E8) — retail m_velocityVector: the INTENDED
+ /// velocity that the integrator advances (gravity, friction, MaxVelocity clamp) and that
+ /// drives the collision sweep. Zeroed by handle_all_collisions when
+ /// > 1 (the "bleed on block").
public Vector3 Velocity { get; set; }
+ /// Retail cached_velocity — a SEPARATE field from : the
+ /// REALIZED velocity (resolved displacement / dt) written after each transition in
+ /// UpdateObjectInternal (0x005158cb-005158ff, pc:283693). Read only for reporting /
+ /// dead-reckoning / camera slope-align (get_velocity 0x005113c0); it is NEVER fed back
+ /// into or the integrator. Kept separate per the verified retail
+ /// two-velocity model — do not collapse the two.
+ public Vector3 CachedVelocity { get; set; }
+
+ /// Retail collision_info.frames_stationary_fall carried on the body between
+ /// frames. ValidateTransition increments it (0→1→2→3) when the sphere fails to advance and
+ /// resets it to 0 when it moves; at fsf≥2 an upward contact plane is manufactured;
+ /// handle_all_collisions zeros when fsf>1 (the airborne-stuck
+ /// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
+ /// pc:272625-656; handle_all_collisions pc:282695.
+ public int FramesStationaryFall { get; set; }
+
/// World-space acceleration (+0xEC/F0/F4).
public Vector3 Acceleration { get; set; }
@@ -497,11 +522,12 @@ public sealed class PhysicsBody
calc_friction(dt, velocityMag2);
- // If velocity fell below the "small" threshold after friction, stop.
- // Only apply when grounded — while airborne, gravity must accumulate
- // even when velocity is near zero (e.g., at jump apex).
- if (velocityMag2 - SmallVelocitySquared < 0.0002f
- && TransientState.HasFlag(TransientStateFlags.OnWalkable))
+ // Retail UpdatePhysicsInternal (0x005107be): zero velocity below 0.25 m/s
+ // UNCONDITIONALLY — NOT gated on OnWalkable. At the jump apex this zeros the
+ // residual horizontal drift; the unconditional `Velocity += Acceleration * dt`
+ // below immediately re-applies gravity, so the fall still accumulates. (The old
+ // OnWalkable gate was an acdream divergence; the verbatim rebuild removes it.)
+ if (velocityMag2 - SmallVelocitySquared < 0.0002f)
Velocity = Vector3.Zero;
// Euler integration: position += v*dt + 0.5*a*dt²
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
index f5593edd..1c936d34 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
@@ -162,6 +162,46 @@ public sealed class PhysicsBodyTests
Assert.False(body.IsActive);
}
+ [Fact]
+ public void UpdatePhysicsInternal_zeroes_small_velocity_even_when_airborne()
+ {
+ // Retail UpdatePhysicsInternal (0x005107be) zeroes velocity below 0.25 m/s
+ // UNCONDITIONALLY — NOT gated on OnWalkable. acdream previously gated it on
+ // OnWalkable; the verbatim rebuild removes the gate. Gravity re-accelerates the
+ // same frame via the unconditional `Velocity += Acceleration * dt`, so the fall
+ // still accumulates on Z.
+ var body = MakeAirborne(); // not Contact, not OnWalkable
+ body.set_velocity(new Vector3(0.1f, 0f, 0f)); // < 0.25 m/s
+ body.Acceleration = new Vector3(0f, 0f, PhysicsBody.Gravity);
+
+ body.UpdatePhysicsInternal(1f / 30f);
+
+ Assert.True(MathF.Abs(body.Velocity.X) < 1e-4f, $"X not zeroed: {body.Velocity.X}");
+ Assert.True(body.Velocity.Z < 0f, $"gravity did not accumulate: {body.Velocity.Z}");
+ }
+
+ // ════════════════════════════════════════════════════════════════════
+ // frames_stationary_fall carry state (retail transient_state bits)
+ // ════════════════════════════════════════════════════════════════════
+
+ [Fact]
+ public void TransientStateFlags_has_stationary_bits()
+ {
+ // retail transient_state StationaryFall/Stop/Stuck (handle_all_collisions
+ // pc:282743/282749/282753; seeded back into transition pc:280940-947).
+ Assert.Equal(0x10u, (uint)TransientStateFlags.StationaryFall);
+ Assert.Equal(0x20u, (uint)TransientStateFlags.StationaryStop);
+ Assert.Equal(0x40u, (uint)TransientStateFlags.StationaryStuck);
+ }
+
+ [Fact]
+ public void PhysicsBody_has_fsf_and_cached_velocity_defaults()
+ {
+ var body = new PhysicsBody();
+ Assert.Equal(0, body.FramesStationaryFall);
+ Assert.Equal(Vector3.Zero, body.CachedVelocity);
+ }
+
// ════════════════════════════════════════════════════════════════════
// set_velocity — velocity clamping
// ════════════════════════════════════════════════════════════════════