Campaign P Slice P2 step 3 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1, §6 Step 5). The named retail decomp (CPhysicsObj::calc_friction, pseudo-C:276694-276822, 0050ee70) independently re-confirms the 0.25f threshold (derived twice, once per BN-rendered branch); the in-code claim that "the decompile uses 0.0" traced to the older, unnamed FUN_0050f940 Ghidra chunk at a different address -- per CLAUDE.md the named decomp wins. calc_friction now reads angle = dot(Velocity, GroundNormal); if (angle >= 0.25f) return; then unconditionally removes the normal-aligned velocity component, then applies the existing (already-present but previously unreachable) PhysicsState.Sledding-gated friction overrides. The BN-rendered "two duplicated branches" around the state check is adopted as a single linear function matching ACE's PhysicsObj.calc_friction shape -- the branch split is most likely a BN decompiler artifact around one `if (state & SLEDDING_PS)` block (ACE-derived, Ghidra-verify; low implementation risk either way since ACE's reading is adopted regardless). Why this doesn't repeat the reverted 2026-04-30 L.3c regression (naive 0.0 -> 0.25f bump dropped forward locomotion 3 -> 0.16 m/s): that test predates the 2026-07-17 R6 "local player animation-owned grounded movement" landing. PlayerMovementController (Runtime/Gameplay, out of this slice's scope) zeroes Velocity.X/Y to exactly zero every tick before calc_friction runs whenever animation root motion drives the walk, so friction has nothing horizontal left to hammer on the production graphical local-player path. Pinned at the PhysicsBody level (the only file this slice may touch) by GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests. The headless/get_state_velocity path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there -- flagged in the retired AP-7 row for future sessions working in Runtime/Gameplay. Left an open, explicitly-flagged discrepancy: the raw decomp's Sledding slope-flatness test computes cos(10 deg) (~0.984808) while ACE's port (and acdream's prior dead code) compares GroundNormal.Z > 0.99999536f (~0.175 deg from flat) -- physically different tests, neither confirmed this pass (Ghidra MCP down). Kept 0.99999536f provisionally (least churn) and filed AD-55 for just that constant rather than silently picking one. Register: AP-7 retired with a corrected citation; AD-55 filed for the cos(10 deg) question. Core.Tests: 3916 passed, 2 skipped (both pre-existing and unrelated), 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
646 lines
26 KiB
C#
646 lines
26 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>CPhysicsObj::calc_friction</c> at 0050ee70).
|
|
/// </summary>
|
|
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
|
|
// 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.
|
|
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()}");
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// 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}");
|
|
}
|
|
}
|