feat(#182): PhysicsBody fsf state + CachedVelocity; ungate small-velocity-zero (Slice 1a)

Verbatim UpdatePhysicsInternal (0x00510700): the small-velocity-zero
(<0.25 m/s) fires unconditionally, not gated on OnWalkable (the old acdream
divergence) — gravity re-accelerates the same frame via the unconditional
v += a*dt. Adds TransientStateFlags.Stationary{Fall,Stop,Stuck} (0x10/0x20/0x40)
for the fsf round-trip, plus PhysicsBody.FramesStationaryFall and the separate
CachedVelocity field (retail's two-velocity model — reporting/DR only, never fed
to the integrator). All 1536 physics tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 13:39:28 +02:00
parent 8bb8b20411
commit 6e8117741b
2 changed files with 72 additions and 6 deletions

View file

@ -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
/// <summary>Orientation quaternion (struct offsets 0x600x80 column matrix).</summary>
public Quaternion Orientation { get; set; } = Quaternion.Identity;
/// <summary>World-space velocity (+0xE0/E4/E8).</summary>
/// <summary>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
/// <see cref="FramesStationaryFall"/> &gt; 1 (the "bleed on block").</summary>
public Vector3 Velocity { get; set; }
/// <summary>Retail cached_velocity — a SEPARATE field from <see cref="Velocity"/>: 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 <see cref="Velocity"/> or the integrator. Kept separate per the verified retail
/// two-velocity model — do not collapse the two.</summary>
public Vector3 CachedVelocity { get; set; }
/// <summary>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 <see cref="Velocity"/> when fsf&gt;1 (the airborne-stuck
/// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
/// pc:272625-656; handle_all_collisions pc:282695.</summary>
public int FramesStationaryFall { get; set; }
/// <summary>World-space acceleration (+0xEC/F0/F4).</summary>
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²

View file

@ -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
// ════════════════════════════════════════════════════════════════════