diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs new file mode 100644 index 0000000..06b6781 --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -0,0 +1,373 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +// ──────────────────────────────────────────────────────────────────────────── +// PhysicsBody — C# port of CPhysicsObj's core simulation from acclient.exe. +// +// Source addresses (chunk_00510000.c, chunk_00500000.c): +// FUN_005111d0 UpdatePhysicsInternal — Euler integration +// FUN_00511420 calc_acceleration — gravity / grounded acceleration +// FUN_00511ec0 set_velocity — store + clamp to MaxVelocity +// FUN_00511fa0 set_local_velocity — body→world transform then set_velocity +// FUN_00511de0 set_on_walkable — set/clear OnWalkable transient flag +// FUN_0050f940 calc_friction — ground-contact friction +// FUN_00515020 update_object — per-frame top-level driver +// +// Cross-checked against ACE PhysicsObj.cs and PhysicsGlobals.cs. +// ──────────────────────────────────────────────────────────────────────────── + +/// +/// State flags stored at struct offset +0xA8 (PhysicsState). +/// Only the flags relevant to this simulation layer are included. +/// +[Flags] +public enum PhysicsStateFlags : uint +{ + None = 0, + Static = 0x00000001, // bit 0 — never moves + Ethereal = 0x00000004, // bit 2 — no collision + ReportCollisions = 0x00000010, + Gravity = 0x00000400, // bit 10 — apply downward gravity + Hidden = 0x00001000, + Sledding = 0x00800000, // bit 23 — sledding (modified friction) +} + +/// +/// Transient-state flags stored at struct offset +0xAC (TransientState). +/// These are cleared/set each frame and must not be saved to disk. +/// +[Flags] +public enum TransientStateFlags : uint +{ + None = 0, + Contact = 0x00000001, // bit 0 — touching any surface + OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface + Active = 0x00000080, // bit 7 — object needs per-frame update +} + +/// +/// Port of CPhysicsObj's core simulation state and Euler integration. +/// Holds the fields at the struct offsets documented in acclient_function_map.md +/// and implements the seven methods listed in the task spec. +/// +public sealed class PhysicsBody +{ + // ── constants ────────────────────────────────────────────────────────── + // From PhysicsGlobals.cs / confirmed by DAT_007c78a4 reference in decompiled code. + public const float MaxVelocity = 50.0f; + public const float MaxVelocitySquared = MaxVelocity * MaxVelocity; + public const float Gravity = -9.8f; // DAT_0082223c in FUN_00511420 + public const float SmallVelocity = 0.25f; + public const float SmallVelocitySquared = SmallVelocity * SmallVelocity; + public const float DefaultFriction = 0.95f; + public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s + public const float MaxQuantum = 0.1f; // 10 fps lower bound + public const float HugeQuantum = 2.0f; // discard stale dt + + // ── struct fields ────────────────────────────────────────────────────── + // Offsets from acclient_function_map.md §PhysicsObj Struct Layout. + + /// World-space position (no offset in struct — frame origin). + public Vector3 Position { get; set; } + + /// Orientation quaternion (struct offsets 0x60–0x80 column matrix). + public Quaternion Orientation { get; set; } = Quaternion.Identity; + + /// World-space velocity (+0xE0/E4/E8). + public Vector3 Velocity { get; set; } + + /// World-space acceleration (+0xEC/F0/F4). + public Vector3 Acceleration { get; set; } + + /// Angular velocity in radians/s (+0xF8/FC/100). + public Vector3 Omega { get; set; } + + /// Ground contact-plane normal (+0x130/134/138). + public Vector3 GroundNormal { get; set; } = Vector3.UnitZ; + + /// Elasticity coefficient (+0xB0). + public float Elasticity { get; set; } = 0.05f; + + /// Friction coefficient (0 = frictionless, 1 = instant stop). + public float Friction { get; set; } = DefaultFriction; + + /// Physics state flags (+0xA8). + public PhysicsStateFlags State { get; set; } + = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions; + + /// Transient state flags (+0xAC). Cleared each frame as needed. + public TransientStateFlags TransientState { get; set; } + + /// Last simulation time used to compute dt (+0xD8). + public double LastUpdateTime { get; set; } + + // ── convenience helpers ──────────────────────────────────────────────── + + public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity); + public bool OnWalkable => TransientState.HasFlag(TransientStateFlags.OnWalkable); + public bool IsActive => TransientState.HasFlag(TransientStateFlags.Active); + public bool InContact => TransientState.HasFlag(TransientStateFlags.Contact); + + // ── FUN_00511420 ─────────────────────────────────────────────────────── + + /// + /// Set Acceleration (and Omega) based on current contact state and flags. + /// + /// Decompiled logic (FUN_00511420): + /// If Contact AND OnWalkable AND NOT Sledding → zero everything (grounded, no drift). + /// Else if Gravity flag → Accel = (0, 0, -9.8). + /// Else → zero acceleration. + /// + /// The check order in the decompile is: + /// (TransientState & 1) != 0 → Contact + /// (TransientState & 2) != 0 → OnWalkable + /// (State & 0x800000) == 0 → NOT Sledding + /// + public void calc_acceleration() + { + if (TransientState.HasFlag(TransientStateFlags.Contact) && + TransientState.HasFlag(TransientStateFlags.OnWalkable) && + !State.HasFlag(PhysicsStateFlags.Sledding)) + { + Acceleration = Vector3.Zero; + Omega = Vector3.Zero; + return; + } + + if (State.HasFlag(PhysicsStateFlags.Gravity)) + Acceleration = new Vector3(0f, 0f, Gravity); + else + Acceleration = Vector3.Zero; + } + + // ── FUN_00511ec0 ─────────────────────────────────────────────────────── + + /// + /// Store a new world-space velocity and clamp its magnitude to MaxVelocity. + /// + /// Decompiled logic (FUN_00511ec0): + /// velocity = newVelocity + /// if |velocity|² > MaxVelocity²: + /// normalize then scale by MaxVelocity (FUN_00452440 = normalize + scalar) + /// Set Active transient flag. + /// + public void set_velocity(Vector3 newVelocity) + { + Velocity = newVelocity; + + float mag2 = Velocity.LengthSquared(); + if (mag2 > MaxVelocitySquared) + { + // Normalize then scale — matches the decompile's FUN_00452440 call + // which normalizes the vector then multiplies by _DAT_007c78a4 (MaxVelocity). + Velocity = Vector3.Normalize(Velocity) * MaxVelocity; + } + + // Set Active flag (bit 7 of TransientState, offset +0xAC). + TransientState |= TransientStateFlags.Active; + } + + // ── FUN_00511fa0 ─────────────────────────────────────────────────────── + + /// + /// Transform a body-local velocity vector into world space using the + /// orientation quaternion, then call set_velocity. + /// + /// Decompiled logic (FUN_00511fa0): + /// The orientation is stored as a 3x3 column matrix at offsets 0x60–0x80 + /// (9 floats). The transform is a straightforward matrix×vector multiply: + /// worldX = col0.x*localX + col1.x*localY + col2.x*localZ + /// worldY = col0.y*localX + col1.y*localY + col2.y*localZ + /// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ + /// We replicate this as a Quaternion rotation, which is equivalent. + /// + public void set_local_velocity(Vector3 localVelocity) + { + var worldVelocity = Vector3.Transform(localVelocity, Orientation); + set_velocity(worldVelocity); + } + + // ── FUN_00511de0 ─────────────────────────────────────────────────────── + + /// + /// Set or clear the OnWalkable transient flag (bit 1 of TransientState at + /// +0xAC), then recompute acceleration. + /// + /// Decompiled logic (FUN_00511de0): + /// if param_2 == 0: TransientState &= ~0x02 (clear OnWalkable) + /// else: TransientState |= 0x02 (set OnWalkable) + /// call calc_acceleration() + /// + public void set_on_walkable(bool isOnWalkable) + { + if (isOnWalkable) + TransientState |= TransientStateFlags.OnWalkable; + else + TransientState &= ~TransientStateFlags.OnWalkable; + + calc_acceleration(); + } + + // ── FUN_0050f940 ─────────────────────────────────────────────────────── + + /// + /// Apply friction deceleration to the velocity when the body is standing + /// on a walkable surface. + /// + /// Decompiled logic (FUN_0050f940): + /// if NOT OnWalkable → return + /// fVar1 = dot(groundNormal, velocity) + /// if fVar1 < 0: + /// velocity -= fVar1 * groundNormal (remove inward normal component) + /// scalar = pow(1 - friction, dt) + /// velocity *= scalar + /// + /// The threshold (0.0 from _DAT_007c78a0) means any velocity with a + /// downward component relative to the normal gets friction applied. + /// Positive dot means moving away from the surface — no friction. + /// + /// Cross-checked with ACE PhysicsObj.calc_friction which uses 0.25f as + /// the threshold instead; the decompile uses 0.0. We match the decompile. + /// + public void calc_friction(float dt, float velocityMag2) + { + if (!TransientState.HasFlag(TransientStateFlags.OnWalkable)) + return; + + float dot = Vector3.Dot(GroundNormal, Velocity); + if (dot >= 0f) + return; + + // Remove the component of velocity that presses into the ground normal. + Velocity -= dot * GroundNormal; + + float friction = Friction; + + // Sledding modifies friction thresholds (from ACE cross-check). + if (State.HasFlag(PhysicsStateFlags.Sledding)) + { + if (velocityMag2 < 1.5625f) // 1.25² — slow sled + friction = 1.0f; + else if (velocityMag2 >= 6.25f && GroundNormal.Z > 0.99999536f) // near-flat + friction = 0.2f; + } + + // Exponential decay: vel *= (1 - friction)^dt + float scalar = MathF.Pow(1.0f - friction, dt); + Velocity *= scalar; + } + + // ── FUN_005111d0 ─────────────────────────────────────────────────────── + + /// + /// Euler integration step for one quantum dt. + /// + /// Decompiled logic (FUN_005111d0): + /// velocity_mag2 = |velocity|² + /// if velocity_mag2 == 0: + /// if no MovementManager AND OnWalkable → clear Active flag + /// else: + /// if velocity_mag2 > MaxVelocitySquared: normalize * MaxVelocity + /// calc_friction(dt, velocity_mag2) + /// if velocity_mag2 < SmallVelocitySquared: zero velocity + /// position += velocity * dt + 0.5 * acceleration * dt² + /// velocity += acceleration * dt + /// Apply angular delta: orientation rotated by omega * dt + /// + public void UpdatePhysicsInternal(float dt) + { + float velocityMag2 = Velocity.LengthSquared(); + + if (velocityMag2 <= 0f) + { + // No movement manager equivalent here; just clear Active if grounded. + if (TransientState.HasFlag(TransientStateFlags.OnWalkable)) + TransientState &= ~TransientStateFlags.Active; + } + else + { + // Clamp velocity magnitude to MaxVelocity. + if (velocityMag2 > MaxVelocitySquared) + { + Velocity = Vector3.Normalize(Velocity) * MaxVelocity; + velocityMag2 = MaxVelocitySquared; + } + + calc_friction(dt, velocityMag2); + + // If velocity fell below the "small" threshold after friction, stop. + if (velocityMag2 - SmallVelocitySquared < 0.0002f) + Velocity = Vector3.Zero; + + // Euler integration: position += v*dt + 0.5*a*dt² + Position += Velocity * dt + Acceleration * (0.5f * dt * dt); + } + + // velocity += acceleration * dt (done unconditionally in decompile) + Velocity += Acceleration * dt; + + // Angular integration: apply omega rotation. + // omega * dt gives the angle-axis delta rotation. + float omegaLen = Omega.Length(); + if (omegaLen > 1e-6f) + { + float angle = omegaLen * dt; + Quaternion deltaRot = Quaternion.CreateFromAxisAngle(Omega / omegaLen, angle); + Orientation = Quaternion.Normalize(Quaternion.Multiply(Orientation, deltaRot)); + } + } + + // ── FUN_00515020 ─────────────────────────────────────────────────────── + + /// + /// Per-frame top-level driver. Computes dt from the wall clock versus + /// LastUpdateTime, clamping to [MinQuantum, HugeQuantum], then calls + /// calc_acceleration and UpdatePhysicsInternal. + /// + /// Decompiled logic (FUN_00515020): + /// if parent-attached (offset +0x40 != 0) → return + /// dVar1 = currentTime - LastUpdateTime + /// if dVar1 < MinQuantum → return (too short — skip) + /// if dVar1 > HugeQuantum → update timestamp and return (stale — discard) + /// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract + /// if dVar1 > MinQuantum: simulate remainder + /// LastUpdateTime = currentTime + /// + /// The caller passes currentTime; the object does not read a global clock + /// directly in this port so tests can drive the clock explicitly. + /// + public void update_object(double currentTime) + { + double deltaTime = currentTime - LastUpdateTime; + + // dt too small — nothing to simulate yet + if (deltaTime < MinQuantum) + return; + + // Stale / first frame — just consume the time without simulating + if (deltaTime > HugeQuantum) + { + LastUpdateTime = currentTime; + return; + } + + // Sub-step: break large dt into MaxQuantum chunks + while (deltaTime > MaxQuantum) + { + calc_acceleration(); + UpdatePhysicsInternal(MaxQuantum); + deltaTime -= MaxQuantum; + } + + // Simulate the remainder + if (deltaTime > MinQuantum) + { + calc_acceleration(); + UpdatePhysicsInternal((float)deltaTime); + } + + LastUpdateTime = currentTime; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs new file mode 100644 index 0000000..f5593ed --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs @@ -0,0 +1,493 @@ +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_0050f940, FUN_00515020). +/// +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); + } + + // ════════════════════════════════════════════════════════════════════ + // 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); + } + + // ════════════════════════════════════════════════════════════════════ + // update_object — per-frame driver + // ════════════════════════════════════════════════════════════════════ + + [Fact] + public void update_object_dt_below_min_quantum_does_not_advance() + { + 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); + } + + [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_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}"); + } +} diff --git a/tools/ghidra_project/acclient.gpr b/tools/ghidra_project/acclient.gpr new file mode 100644 index 0000000..e69de29 diff --git a/tools/ghidra_project/acclient.rep/idata/00/00000000.prp b/tools/ghidra_project/acclient.rep/idata/00/00000000.prp new file mode 100644 index 0000000..217768e --- /dev/null +++ b/tools/ghidra_project/acclient.rep/idata/00/00000000.prp @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.3.gbf b/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.3.gbf new file mode 100644 index 0000000..33c81fc Binary files /dev/null and b/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.3.gbf differ diff --git a/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.4.gbf b/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.4.gbf new file mode 100644 index 0000000..245d38d Binary files /dev/null and b/tools/ghidra_project/acclient.rep/idata/00/~00000000.db/db.4.gbf differ diff --git a/tools/ghidra_project/acclient.rep/idata/~index.bak b/tools/ghidra_project/acclient.rep/idata/~index.bak new file mode 100644 index 0000000..4e4d379 --- /dev/null +++ b/tools/ghidra_project/acclient.rep/idata/~index.bak @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162cd88181512443538400 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient.rep/idata/~index.dat b/tools/ghidra_project/acclient.rep/idata/~index.dat new file mode 100644 index 0000000..4e4d379 --- /dev/null +++ b/tools/ghidra_project/acclient.rep/idata/~index.dat @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162cd88181512443538400 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient.rep/project.prp b/tools/ghidra_project/acclient.rep/project.prp new file mode 100644 index 0000000..f18fd43 --- /dev/null +++ b/tools/ghidra_project/acclient.rep/project.prp @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/ghidra_project/acclient.rep/user/~index.dat b/tools/ghidra_project/acclient.rep/user/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient.rep/user/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient.rep/versioned/~index.bak b/tools/ghidra_project/acclient.rep/versioned/~index.bak new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient.rep/versioned/~index.bak @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient.rep/versioned/~index.dat b/tools/ghidra_project/acclient.rep/versioned/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient.rep/versioned/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.1.gbf b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.1.gbf new file mode 100644 index 0000000..67f6d08 Binary files /dev/null and b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.1.gbf differ diff --git a/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.2.gbf b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.2.gbf new file mode 100644 index 0000000..4d927ec Binary files /dev/null and b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/00/~00000000.db/db.2.gbf differ diff --git a/tools/ghidra_project/acclient_full/acclient_full.rep/idata/~index.dat b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/~index.dat new file mode 100644 index 0000000..41c0ff2 --- /dev/null +++ b/tools/ghidra_project/acclient_full/acclient_full.rep/idata/~index.dat @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162ccd0182120605721300 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.gpr b/tools/ghidra_project/acclient_full2/acclient_full2.gpr new file mode 100644 index 0000000..e69de29 diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/00000000.prp b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/00000000.prp new file mode 100644 index 0000000..557f448 --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/00000000.prp @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.1.gbf b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.1.gbf new file mode 100644 index 0000000..7ad0766 Binary files /dev/null and b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.1.gbf differ diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.2.gbf b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.2.gbf new file mode 100644 index 0000000..ab95d21 Binary files /dev/null and b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/00/~00000000.db/db.2.gbf differ diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.bak b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.bak new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.bak @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.dat b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.dat new file mode 100644 index 0000000..480ca80 --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~index.dat @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162d356182196155907400 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~journal.bak b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~journal.bak new file mode 100644 index 0000000..7d0b5b2 --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/idata/~journal.bak @@ -0,0 +1,2 @@ +IADD:00000000:/acclient.exe +IDSET:/acclient.exe:c0a8162d356182196155907400 diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/project.prp b/tools/ghidra_project/acclient_full2/acclient_full2.rep/project.prp new file mode 100644 index 0000000..f18fd43 --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/project.prp @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/user/~index.dat b/tools/ghidra_project/acclient_full2/acclient_full2.rep/user/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/user/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.bak b/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.bak new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.bak @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.dat b/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_full2/acclient_full2.rep/versioned/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.gpr b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.gpr new file mode 100644 index 0000000..e69de29 diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/00000000.prp b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/00000000.prp new file mode 100644 index 0000000..12c9567 --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/00000000.prp @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.4.gbf b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.4.gbf new file mode 100644 index 0000000..19db7af Binary files /dev/null and b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.4.gbf differ diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.5.gbf b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.5.gbf new file mode 100644 index 0000000..7434f9e Binary files /dev/null and b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/00/~00000000.db/db.5.gbf differ diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.bak b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.bak new file mode 100644 index 0000000..96f90c8 --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.bak @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162f0d1181714693391400 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.dat b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.dat new file mode 100644 index 0000000..96f90c8 --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/idata/~index.dat @@ -0,0 +1,5 @@ +VERSION=1 +/ + 00000000:acclient.exe:c0a8162f0d1181714693391400 +NEXT-ID:1 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/project.prp b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/project.prp new file mode 100644 index 0000000..f18fd43 --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/project.prp @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/user/~index.dat b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/user/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/user/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.bak b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.bak new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.bak @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e diff --git a/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.dat b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.dat new file mode 100644 index 0000000..b1e697f --- /dev/null +++ b/tools/ghidra_project/acclient_pyghidra/acclient_pyghidra.rep/versioned/~index.dat @@ -0,0 +1,4 @@ +VERSION=1 +/ +NEXT-ID:0 +MD5:d41d8cd98f00b204e9800998ecf8427e