using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Unit tests for PhysicsBody — the C# port of CPhysicsObj's core simulation /// from acclient.exe (FUN_005111d0, FUN_00511420, FUN_00511ec0, FUN_00511fa0, /// FUN_00511de0, FUN_00515020, and the named /// CPhysicsObj::calc_friction at 0050ee70). /// public sealed class PhysicsBodyTests { // ── helpers ────────────────────────────────────────────────────────── private static PhysicsBody MakeAirborne() { var body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, }; // Airborne: not in Contact, not OnWalkable body.TransientState = TransientStateFlags.Active; return body; } private static PhysicsBody MakeGrounded() { var body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, }; body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; return body; } // ════════════════════════════════════════════════════════════════════ // calc_acceleration // ════════════════════════════════════════════════════════════════════ [Fact] public void calc_acceleration_airborne_gravity_sets_minus_9_8_on_z() { var body = MakeAirborne(); body.calc_acceleration(); Assert.Equal(0f, body.Acceleration.X); Assert.Equal(0f, body.Acceleration.Y); Assert.Equal(-9.8f, body.Acceleration.Z, precision: 6); } [Fact] public void calc_acceleration_grounded_zeros_acceleration_and_omega() { var body = MakeGrounded(); body.Acceleration = new Vector3(1f, 2f, 3f); body.Omega = new Vector3(0.5f, 0.5f, 0.5f); body.calc_acceleration(); Assert.Equal(Vector3.Zero, body.Acceleration); Assert.Equal(Vector3.Zero, body.Omega); } [Fact] public void calc_acceleration_no_gravity_flag_zeros_acceleration() { var body = new PhysicsBody { State = PhysicsStateFlags.None, // no Gravity flag TransientState = TransientStateFlags.Active, }; body.Acceleration = new Vector3(0f, 0f, -9.8f); body.calc_acceleration(); Assert.Equal(Vector3.Zero, body.Acceleration); } [Fact] public void calc_acceleration_sledding_airborne_still_applies_gravity() { // Sledding but not grounded — gravity still applies var body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.Sledding, TransientState = TransientStateFlags.Active, }; body.calc_acceleration(); Assert.Equal(-9.8f, body.Acceleration.Z, precision: 6); } // ════════════════════════════════════════════════════════════════════ // UpdatePhysicsInternal — Euler integration // ════════════════════════════════════════════════════════════════════ [Fact] public void UpdatePhysicsInternal_integrates_position_correctly_one_step() { // Analytical: x(t) = x0 + v0*t + 0.5*a*t² // With v0=(1,0,0), a=(0,0,-9.8), dt=0.1 // x = 0.1 // z = 0.5 * (-9.8) * 0.01 = -0.049 var body = MakeAirborne(); body.Velocity = new Vector3(1f, 0f, 0f); body.Acceleration = new Vector3(0f, 0f, -9.8f); body.UpdatePhysicsInternal(0.1f); Assert.Equal(0.1f, body.Position.X, precision: 5); Assert.Equal(0f, body.Position.Y, precision: 5); // 0.5 * (-9.8) * 0.01 = -0.049 Assert.Equal(-0.049f, body.Position.Z, precision: 4); } [Fact] public void UpdatePhysicsInternal_velocity_updated_by_acceleration_times_dt() { var body = MakeAirborne(); body.Velocity = new Vector3(0f, 0f, 0f); body.Acceleration = new Vector3(0f, 0f, -9.8f); body.UpdatePhysicsInternal(0.5f); // velocity += accel * dt = (0, 0, -9.8 * 0.5) = (0, 0, -4.9) Assert.Equal(0f, body.Velocity.X, precision: 5); Assert.Equal(0f, body.Velocity.Y, precision: 5); Assert.Equal(-4.9f, body.Velocity.Z, precision: 4); } [Fact] public void UpdatePhysicsInternal_multiple_frames_accumulates_correctly() { // Free-fall from rest under gravity for N frames of dt each. // Analytical z(t) = 0.5 * g * t² where g = -9.8 // After 10 frames of 0.1 s each (total t=1.0 s): // z = 0.5 * (-9.8) * 1.0 = -4.9 // The Euler integrator accumulates small truncation error, so allow 2% tolerance. var body = MakeAirborne(); body.Velocity = Vector3.Zero; body.Acceleration = new Vector3(0f, 0f, PhysicsBody.Gravity); const int frames = 10; const float dt = 0.1f; for (int i = 0; i < frames; i++) body.UpdatePhysicsInternal(dt); float expected = 0.5f * PhysicsBody.Gravity * (frames * dt) * (frames * dt); Assert.True(MathF.Abs(body.Position.Z - expected) < 0.15f, $"Expected z ≈ {expected:F4}, got {body.Position.Z:F4}"); } [Fact] public void UpdatePhysicsInternal_zero_velocity_clears_active_flag_when_grounded() { var body = MakeGrounded(); body.Velocity = Vector3.Zero; body.TransientState |= TransientStateFlags.Active; body.UpdatePhysicsInternal(0.1f); 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 // ════════════════════════════════════════════════════════════════════ [Fact] public void set_velocity_below_max_stores_velocity_unchanged() { var body = new PhysicsBody(); var v = new Vector3(10f, 5f, 2f); body.set_velocity(v); Assert.Equal(v, body.Velocity); } [Fact] public void set_velocity_above_max_clamps_to_MaxVelocity_magnitude() { var body = new PhysicsBody(); // velocity with magnitude > 50 var v = new Vector3(100f, 0f, 0f); body.set_velocity(v); Assert.True(body.Velocity.Length() <= PhysicsBody.MaxVelocity + 1e-4f, $"Velocity magnitude {body.Velocity.Length()} exceeds MaxVelocity {PhysicsBody.MaxVelocity}"); Assert.Equal(PhysicsBody.MaxVelocity, body.Velocity.Length(), precision: 4); } [Fact] public void set_velocity_diagonal_above_max_clamps_and_preserves_direction() { var body = new PhysicsBody(); var dir = Vector3.Normalize(new Vector3(3f, 4f, 0f)); // unit vector var v = dir * 80f; // magnitude = 80 > 50 body.set_velocity(v); Assert.Equal(PhysicsBody.MaxVelocity, body.Velocity.Length(), precision: 3); // Direction should be preserved var resultDir = Vector3.Normalize(body.Velocity); Assert.Equal(dir.X, resultDir.X, precision: 4); Assert.Equal(dir.Y, resultDir.Y, precision: 4); } [Fact] public void set_velocity_sets_active_flag() { var body = new PhysicsBody(); body.TransientState = TransientStateFlags.None; body.set_velocity(new Vector3(1f, 0f, 0f)); Assert.True(body.IsActive); } [Fact] public void set_velocity_exactly_at_max_is_not_clamped() { var body = new PhysicsBody(); var v = new Vector3(PhysicsBody.MaxVelocity, 0f, 0f); body.set_velocity(v); Assert.Equal(v.X, body.Velocity.X, precision: 4); Assert.Equal(0f, body.Velocity.Y, precision: 4); Assert.Equal(0f, body.Velocity.Z, precision: 4); } // ════════════════════════════════════════════════════════════════════ // set_local_velocity — body→world transform // ════════════════════════════════════════════════════════════════════ [Fact] public void set_local_velocity_identity_orientation_passes_through() { var body = new PhysicsBody { Orientation = Quaternion.Identity }; body.set_local_velocity(new Vector3(1f, 0f, 0f)); Assert.Equal(1f, body.Velocity.X, precision: 5); Assert.Equal(0f, body.Velocity.Y, precision: 5); Assert.Equal(0f, body.Velocity.Z, precision: 5); } [Fact] public void set_local_velocity_90_degree_yaw_rotates_forward_to_right() { // A 90° CCW rotation around Z maps +X in local space to +Y in world space. var body = new PhysicsBody { Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f) }; body.set_local_velocity(new Vector3(1f, 0f, 0f)); // After 90° yaw: local +X becomes world +Y (approximately) Assert.True(MathF.Abs(body.Velocity.X) < 1e-4f, $"Expected Vx≈0, got {body.Velocity.X}"); Assert.True(MathF.Abs(body.Velocity.Y - 1f) < 1e-4f, $"Expected Vy≈1, got {body.Velocity.Y}"); Assert.True(MathF.Abs(body.Velocity.Z) < 1e-4f, $"Expected Vz≈0, got {body.Velocity.Z}"); } [Fact] public void set_local_velocity_180_degree_yaw_reverses_horizontal_forward() { var body = new PhysicsBody { Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI) }; body.set_local_velocity(new Vector3(1f, 0f, 0f)); Assert.True(MathF.Abs(body.Velocity.X + 1f) < 1e-4f, $"Expected Vx≈-1, got {body.Velocity.X}"); Assert.True(MathF.Abs(body.Velocity.Y) < 1e-4f, $"Expected Vy≈0, got {body.Velocity.Y}"); } [Fact] public void set_local_velocity_magnitude_preserved_after_rotation() { var body = new PhysicsBody { Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.23f) }; var localVel = new Vector3(3f, 4f, 0f); body.set_local_velocity(localVel); Assert.Equal(localVel.Length(), body.Velocity.Length(), precision: 4); } // ════════════════════════════════════════════════════════════════════ // set_on_walkable // ════════════════════════════════════════════════════════════════════ [Fact] public void set_on_walkable_true_sets_OnWalkable_flag() { var body = MakeAirborne(); body.set_on_walkable(true); Assert.True(body.OnWalkable); } [Fact] public void set_on_walkable_false_clears_OnWalkable_flag() { var body = MakeGrounded(); body.set_on_walkable(false); Assert.False(body.OnWalkable); } [Fact] public void set_on_walkable_true_also_calls_calc_acceleration_zeroing_accel() { // When Contact + OnWalkable (non-sledding): acceleration should be zeroed. var body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, TransientState = TransientStateFlags.Contact, Acceleration = new Vector3(0f, 0f, -9.8f), }; body.set_on_walkable(true); Assert.Equal(Vector3.Zero, body.Acceleration); } [Fact] public void set_on_walkable_false_allows_gravity_to_apply() { var body = MakeGrounded(); body.set_on_walkable(false); // After clearing OnWalkable, calc_acceleration should apply gravity. Assert.Equal(-9.8f, body.Acceleration.Z, precision: 6); } // ════════════════════════════════════════════════════════════════════ // calc_friction // ════════════════════════════════════════════════════════════════════ [Fact] public void calc_friction_not_on_walkable_does_nothing() { var body = MakeAirborne(); body.Velocity = new Vector3(5f, 0f, 0f); var before = body.Velocity; body.calc_friction(0.1f, body.Velocity.LengthSquared()); Assert.Equal(before, body.Velocity); } [Fact] public void calc_friction_velocity_parallel_to_ground_reduces_magnitude() { // Ground normal = +Z, velocity is horizontal (no inward component), // but if we tilt slightly downward (dot < 0) friction fires. var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; // Give a small downward Z component so dot(normal, vel) < 0 body.Velocity = new Vector3(5f, 0f, -0.1f); float mag2 = body.Velocity.LengthSquared(); body.calc_friction(0.1f, mag2); // Speed should be reduced by friction Assert.True(body.Velocity.Length() < new Vector3(5f, 0f, 0f).Length(), "Friction should reduce velocity magnitude"); } [Fact] public void calc_friction_velocity_moving_away_from_normal_no_change() { // dot(GroundNormal=(0,0,1), velocity=(5,0,1)) = 1 > 0 → no friction var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Velocity = new Vector3(5f, 0f, 1f); // moving up = away from ground var before = body.Velocity; float mag2 = body.Velocity.LengthSquared(); body.calc_friction(0.1f, mag2); Assert.Equal(before, body.Velocity); } [Fact] public void calc_friction_zero_friction_coefficient_no_reduction() { var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Velocity = new Vector3(5f, 0f, -0.01f); body.Friction = 0f; // frictionless surface float mag2 = body.Velocity.LengthSquared(); body.calc_friction(0.1f, mag2); // After removing normal component, velocity magnitude should be ≈ 5 (horizontal) // With friction=0, pow(1-0, dt)=1, so velocity unchanged beyond normal removal Assert.True(body.Velocity.Length() > 4.9f, $"Zero friction: speed {body.Velocity.Length()} should stay near 5"); } [Fact] public void calc_friction_removes_normal_component_from_velocity() { // Velocity = (1, 0, -1), GroundNormal = (0, 0, 1) // dot = -1 → velocity -= (-1) * (0,0,1) = velocity + (0,0,1) → (1, 0, 0) var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Friction = 0f; // no friction to isolate normal-removal behavior body.Velocity = new Vector3(1f, 0f, -1f); float mag2 = body.Velocity.LengthSquared(); body.calc_friction(1.0f, mag2); // After normal removal the Z component should be zero (or very small). Assert.True(MathF.Abs(body.Velocity.Z) < 1e-4f, $"Normal component should be removed; Vz = {body.Velocity.Z}"); Assert.Equal(1f, body.Velocity.X, precision: 4); } // ════════════════════════════════════════════════════════════════════ // AP-7 (Campaign P Slice P2, 2026-07-30): calc_friction's 0.25f threshold // docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1 // ════════════════════════════════════════════════════════════════════ [Fact] public void calc_friction_dot_between_zero_and_quarter_now_engages_friction() { // dot(velocity, groundNormal) = 0.1 — ABOVE the old 0.0 threshold // (no friction pre-fix) but BELOW the new retail 0.25f threshold // (friction now engages). This is exactly the window the 0.0 -> 0.25f // port changes; pinning it here documents the intentional behavior // change the AP-7 register row used to warn about. var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Friction = 0.95f; body.Velocity = new Vector3(5f, 0f, 0.1f); float mag2 = body.Velocity.LengthSquared(); body.calc_friction(1f / 60f, mag2); Assert.True(body.Velocity.Length() < 5f, "Retail's 0.25f threshold means dot=0.1 (below 0.25) engages friction, " + "unlike the old 0.0 threshold which would have returned early here."); } [Fact] public void calc_friction_dot_at_quarter_threshold_returns_early_no_change() { // dot(velocity, groundNormal) = 0.25 exactly -> angle >= 0.25f is true // -> early return, matching ACE's `if (angle >= 0.25f) return;`. var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.Velocity = new Vector3(5f, 0f, 0.25f); var before = body.Velocity; float mag2 = body.Velocity.LengthSquared(); body.calc_friction(1f / 60f, mag2); Assert.Equal(before, body.Velocity); } [Fact] public void GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests() { // 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 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; // Root-motion path's exact per-tick shape: horizontal zeroed, only // the world Z survives (a small residual downward settle velocity). body.Velocity = new Vector3(0f, 0f, -0.05f); body.calc_friction(1f / 60f, body.Velocity.LengthSquared()); Assert.Equal(0f, body.Velocity.X, precision: 5); Assert.Equal(0f, body.Velocity.Y, precision: 5); Assert.True(MathF.Abs(body.Velocity.Z) < 0.05f, $"Root-motion horizontal speed must stay exactly at full (zero) " + $"speed under the new threshold; got Velocity={body.Velocity}"); } [Fact] public void calc_friction_sledding_state_gate_reachable_with_new_threshold() { // The Sledding-gated overrides (1.5625/6.25/near-flat) were already // present but structurally unreachable in production (nothing sets // PhysicsStateFlags.Sledding, see #166 research §3) -- this test only // confirms the branch still behaves once the outer 0.25f gate is // passed, so a future data-authored Sledding toggle lands on tested // code. var body = MakeGrounded(); body.GroundNormal = Vector3.UnitZ; body.State |= PhysicsStateFlags.Sledding; body.Velocity = new Vector3(3f, 0f, -0.5f); // velocityMag2 = 9.25, >= 6.25 float mag2 = body.Velocity.LengthSquared(); body.calc_friction(1f / 60f, mag2); // friction should be 0.2f (near-flat, fast sled) rather than the // default 0.95f -- less decay, so speed should stay closer to 3. Assert.True(body.Velocity.Length() > 2.9f, $"Fast near-flat sledding should use the light 0.2f friction override; " + $"got speed {body.Velocity.Length()}"); } // ════════════════════════════════════════════════════════════════════ // AD-55 (Campaign P final physics slice, 2026-07-30): Sledding // fast-sled slope-flatness constant is cos(10 deg) ~ 0.98480775f // (byte-confirmed, docs/research/2026-07-30-ts4-116-oracle-plan.md // Addendum), NOT the ACE-derived 0.99999536f this port carried // provisionally (cos(0.1745 DEGREES) -- a radians/degrees porting // bug). Both boundary tests put Velocity purely along Y and // GroundNormal's tilt purely in the X-Z plane, so // dot(velocity, groundNormal) is EXACTLY zero -- the outer 0.25f gate // and the normal-component-removal step are inert, isolating the // Sledding-band friction value itself. // ════════════════════════════════════════════════════════════════════ [Fact] public void calc_friction_sledding_fast_override_engages_at_5_degrees_from_flat() { float cos5 = MathF.Cos(5f * MathF.PI / 180f); float sin5 = MathF.Sin(5f * MathF.PI / 180f); var body = MakeGrounded(); body.GroundNormal = new Vector3(sin5, 0f, cos5); // 5 deg tilt -- within retail's real 10 deg band body.State |= PhysicsStateFlags.Sledding; body.Velocity = new Vector3(0f, 3f, 0f); float mag2 = body.Velocity.LengthSquared(); const float dt = 1f / 60f; body.calc_friction(dt, mag2); // friction = 0.2f (light) expected: scalar = (1 - 0.2)^dt. float expectedSpeed = 3f * MathF.Pow(0.8f, dt); Assert.Equal(expectedSpeed, body.Velocity.Length(), precision: 3); } [Fact] public void calc_friction_sledding_fast_override_does_not_engage_at_15_degrees_from_flat() { float cos15 = MathF.Cos(15f * MathF.PI / 180f); float sin15 = MathF.Sin(15f * MathF.PI / 180f); var body = MakeGrounded(); body.GroundNormal = new Vector3(sin15, 0f, cos15); // 15 deg tilt -- past retail's real 10 deg threshold body.State |= PhysicsStateFlags.Sledding; body.Velocity = new Vector3(0f, 3f, 0f); float mag2 = body.Velocity.LengthSquared(); const float dt = 1f / 60f; body.calc_friction(dt, mag2); // friction stays the default 0.95f (heavy): the fast-sled override // must NOT engage past retail's real 10 deg threshold, unlike the // old (buggy) 0.99999536f/~0.175 deg constant which would also // have rejected this case for the wrong reason. float expectedSpeed = 3f * MathF.Pow(1f - PhysicsBody.DefaultFriction, dt); Assert.Equal(expectedSpeed, body.Velocity.Length(), precision: 3); } // ════════════════════════════════════════════════════════════════════ // update_object — per-frame driver // ════════════════════════════════════════════════════════════════════ [Fact] public void update_object_dt_below_min_quantum_accumulates_without_advancing() { var body = MakeAirborne(); body.Velocity = new Vector3(1f, 0f, 0f); body.Acceleration = Vector3.Zero; body.LastUpdateTime = 0.0; // Advance by less than MinQuantum — should be a no-op body.update_object(PhysicsBody.MinQuantum * 0.5); Assert.Equal(Vector3.Zero, body.Position); Assert.Equal(0d, body.LastUpdateTime); } [Fact] public void update_object_dt_above_huge_quantum_consumes_time_without_simulating() { var body = MakeAirborne(); body.Velocity = new Vector3(1f, 0f, 0f); body.Acceleration = Vector3.Zero; body.LastUpdateTime = 0.0; body.update_object(PhysicsBody.HugeQuantum + 0.5); // Time consumed but no physics step — position unchanged Assert.Equal(Vector3.Zero, body.Position); Assert.Equal(PhysicsBody.HugeQuantum + 0.5, body.LastUpdateTime, precision: 10); } [Fact] public void update_object_advances_position_over_valid_dt() { var body = MakeAirborne(); // No friction or gravity interference — just pure horizontal velocity body.State = PhysicsStateFlags.None; // no gravity body.Velocity = new Vector3(10f, 0f, 0f); body.LastUpdateTime = 0.0; double dt = 0.1; body.update_object(dt); // x ≈ 10 * 0.1 = 1.0 (ignoring sub-step rounding) Assert.True(body.Position.X > 0f, "Position should have advanced"); } [Fact] public void update_object_updates_LastUpdateTime() { var body = MakeAirborne(); body.LastUpdateTime = 0.0; body.State = PhysicsStateFlags.None; double t = 0.05; body.update_object(t); Assert.Equal(t, body.LastUpdateTime, precision: 10); } [Fact] public void update_object_micro_fragment_is_consumed() { var body = MakeAirborne(); body.update_object(PhysicsGlobals.EPSILON * 0.5); Assert.Equal(PhysicsGlobals.EPSILON * 0.5, body.LastUpdateTime, precision: 10); Assert.Equal(Vector3.Zero, body.Position); } [Fact] public void update_object_gravity_free_fall_accumulates_downward_velocity() { var body = MakeAirborne(); // Let it fall for one valid quantum body.LastUpdateTime = 0.0; double dt = PhysicsBody.MinQuantum * 2; // > MinQuantum but < HugeQuantum body.update_object(dt); // After one step velocity should be negative Z Assert.True(body.Velocity.Z < 0f, $"Gravity should produce negative Z velocity; got {body.Velocity.Z}"); } }