acdream/src/AcDream.Core/Physics/PhysicsBody.cs
Erik e214acdf23 feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:01:31 +02:00

559 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
// ────────────────────────────────────────────────────────────────────────────
/// <summary>
/// State flags stored at struct offset +0xA8 (PhysicsState).
/// Only the flags relevant to this simulation layer are included.
/// </summary>
[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,
/// <summary>
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
/// (acclient.h:2833). When set, the entity exposes a per-Setup
/// BSP collision mesh; retail's
/// <c>CPhysicsObj::FindObjCollisions</c> at
/// acclient_2013_pseudo_c.txt:276861 dispatches the entity's
/// collision queries to the BSP path EXCLUSIVELY for non-PvP,
/// non-missile movers — the foot cylinder and per-Setup spheres
/// are NEVER tested in this case. Closed cottage doors have
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
/// </summary>
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
/// <summary>
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
/// When set, wall-collisions zero the velocity instead of reflecting.
/// Used by spell projectiles and missiles that should embed/explode on
/// impact rather than bounce. The player NEVER has this flag set —
/// player wall-hits use the reflection path with elasticity ~0.05.
/// </summary>
Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
}
/// <summary>
/// Transient-state flags stored at struct offset +0xAC (TransientState).
/// These are cleared/set each frame and must not be saved to disk.
/// </summary>
[Flags]
public enum TransientStateFlags : uint
{
None = 0,
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
Active = 0x00000080, // bit 7 — object needs per-frame update
}
/// <summary>
/// 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.
/// </summary>
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.
private Vector3 _position;
/// <summary>World-space position (frame origin). #145: the setter mirrors its
/// delta into <see cref="CellPosition"/> so the cell-relative frame stays exact
/// through integration + the resolve-apply (both write here). Placement uses
/// <see cref="SnapToCell"/> instead (no delta).</summary>
public Vector3 Position
{
get => _position;
set
{
Vector3 delta = value - _position;
_position = value;
SyncCellPositionDelta(delta);
}
}
/// <summary>
/// Cell-relative position (retail Position): the (cell, local∈[0,192)) pair.
/// #145 Slice 2 — rides alongside <see cref="Position"/>, becomes authoritative
/// in later slices. Default (ObjCellId==0) until <see cref="SnapToCell"/> seeds it.
/// </summary>
public Position CellPosition { get; private set; }
/// <summary>
/// Placement: set the world position AND seed <see cref="CellPosition"/> from the
/// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the
/// seed is canonicalized via <see cref="LandDefs.AdjustToOutside"/> (retail
/// <c>SetPositionInternal</c>/<c>adjust_to_outside</c> @0x00504A40): the cell index
/// is re-derived from the landblock-local position (low word = floor(local/24)) and
/// the origin wrapped into [0,192). This is the #107 protection — never trust a
/// server (cell, pos) pair without re-deriving the cell. Indoor EnvCell claims
/// (low word >= 0x100) are validated by the BSP/spawn-gate path — seeded verbatim.
/// Caller (Slice 2b) supplies the wire (cell, localX/Y/Z) from the inbound position
/// update / teleport arrival. <paramref name="worldPos"/> stays authoritative for
/// the world frame; canonicalizing the (cell, local) decomposition leaves the world
/// point unchanged for an in-range local.
/// </summary>
public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal)
{
_position = worldPos;
uint cell = cellId;
Vector3 local = cellLocal;
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
LandDefs.AdjustToOutside(ref cell, ref local);
CellPosition = new Position(cell, new CellFrame(local, Orientation));
}
// Mirror a world-position translation into the cell-relative frame. Velocity is
// frame-invariant under translation, so the same delta applies to the local origin.
// AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m
// cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor
// membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid +
// [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell;
// unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped.
private void SyncCellPositionDelta(Vector3 delta)
{
uint cell = CellPosition.ObjCellId;
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
return;
Vector3 local = CellPosition.Frame.Origin + delta;
uint adjusted = cell;
if (LandDefs.AdjustToOutside(ref adjusted, ref local))
CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));
// else: map edge (AdjustToOutside failed) — leave CellPosition unchanged.
// Slice 3 owns proper map-edge membership; this slice is behaviour-neutral.
}
/// <summary>Orientation quaternion (struct offsets 0x600x80 column matrix).</summary>
public Quaternion Orientation { get; set; } = Quaternion.Identity;
/// <summary>World-space velocity (+0xE0/E4/E8).</summary>
public Vector3 Velocity { get; set; }
/// <summary>World-space acceleration (+0xEC/F0/F4).</summary>
public Vector3 Acceleration { get; set; }
/// <summary>Angular velocity in radians/s (+0xF8/FC/100).</summary>
public Vector3 Omega { get; set; }
/// <summary>Ground contact-plane normal (+0x130/134/138).</summary>
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
/// <summary>Last wall/object sliding normal (retail transient Sliding state).</summary>
public Vector3 SlidingNormal { get; set; }
// ── persisted contact-plane state (retail PhysicsObj fields) ───────────
//
// Retail's PhysicsObj carries its last contact plane FORWARD across frames.
// When PhysicsObj.transition(oldPos, newPos) creates a new Transition, it
// seeds CollisionInfo.ContactPlane from these fields via InitContactPlane
// (see ACE PhysicsObj.cs:2586-2621 get_object_info). That seed is what lets
// AdjustOffset project horizontal velocity onto the slope surface on the
// first step — without it, a freshly-allocated Transition has no plane,
// so running on a slope proceeds purely horizontally and the sphere
// floats above the terrain (step-down budget is only ~4 cm per tick).
//
// ACE field names: PhysicsObj.ContactPlane / ContactPlaneCellID.
/// <summary>Whether <see cref="ContactPlane"/> currently holds a valid plane.</summary>
public bool ContactPlaneValid { get; set; }
/// <summary>Most recent walkable contact plane (world-space).
/// Updated at the end of every ResolveWithTransition call that found ground.</summary>
public System.Numerics.Plane ContactPlane { get; set; }
/// <summary>Full 32-bit cell id of the cell that owns <see cref="ContactPlane"/>.</summary>
public uint ContactPlaneCellId { get; set; }
/// <summary>Whether the contact plane is a water surface (affects step behavior).</summary>
public bool ContactPlaneIsWater { get; set; }
/// <summary>Whether the previous walkable polygon is available for edge slide.</summary>
public bool WalkablePolygonValid { get; set; }
/// <summary>Most recent walkable polygon plane (world-space).</summary>
public System.Numerics.Plane WalkablePlane { get; set; }
/// <summary>Most recent walkable polygon vertices (world-space).</summary>
public Vector3[]? WalkableVertices { get; set; }
/// <summary>Up vector used by the most recent walkable polygon probe.</summary>
public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
/// <summary>Elasticity coefficient (+0xB0).</summary>
public float Elasticity { get; set; } = 0.05f;
/// <summary>Friction coefficient (0 = frictionless, 1 = instant stop).</summary>
public float Friction { get; set; } = DefaultFriction;
/// <summary>Physics state flags (+0xA8).</summary>
public PhysicsStateFlags State { get; set; }
= PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions;
/// <summary>Transient state flags (+0xAC). Cleared each frame as needed.</summary>
public TransientStateFlags TransientState { get; set; }
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
public double LastUpdateTime { get; set; }
/// <summary>
/// R3-W3 stub for retail <c>CPhysicsObj::IsFullyConstrained</c>
/// (0x0050f730), read by <c>CMotionInterp::jump_is_allowed</c> (raw
/// 305524-305525: <c>if (IsFullyConstrained(physics_obj) != 0) return
/// 0x47;</c>). Retail's body walks per-cell contact-plane constraints
/// (a mover pinned between opposing walkable surfaces / doorway
/// jamming); acdream has no equivalent constraint-tracking yet.
/// Register row: stubbed false (never fires) — a real port needs the
/// per-cell shadow-list contact accounting the physics digest tracks.
/// See docs/architecture/retail-divergence-register.md (added same
/// commit as this field).
/// </summary>
public bool IsFullyConstrained { get; set; }
/// <summary>
/// R3-W4 — retail <c>CPhysicsObj::last_move_was_autonomous</c>, read by
/// <c>CPhysicsObj::movement_is_autonomous</c> (0x0050eb30, decomp §7a
/// @276443: <c>return this-&gt;last_move_was_autonomous;</c>). Gates the
/// A3 dual-dispatch predicate in <c>MotionInterpreter.apply_current_movement</c>/
/// <c>ReportExhaustion</c>/<c>SetWeenieObject</c>/<c>SetPhysicsObject</c>:
/// true means the last motion on this body was locally-simulated
/// (player input / local prediction), false means it was a
/// server-driven dead-reckoning update. Set true at the local-player
/// input chokepoint (App layer — <c>PlayerMovementController</c>);
/// left false (the safe default — routes to
/// <c>apply_interpreted_movement</c>) for DR-applied remote updates.
/// <see cref="MotionInterpreter.LeaveGround"/> also sets this true
/// itself: retail's <c>set_local_velocity(&amp;var_c, 1)</c> call passes
/// the autonomous flag literal <c>1</c> (raw @305763-305765).
/// </summary>
public bool LastMoveWasAutonomous { 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 ───────────────────────────────────────────────────────
/// <summary>
/// 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
/// </summary>
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 ───────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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 ───────────────────────────────────────────────────────
/// <summary>
/// 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 0x600x80
/// (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.
///
/// <para>
/// R3-W4: retail's <c>set_local_velocity</c> takes a second
/// <c>autonomous</c> arg (<c>CPhysicsObj::set_local_velocity</c>,
/// stores it to <see cref="LastMoveWasAutonomous"/> — read by
/// <c>CPhysicsObj::movement_is_autonomous</c>, the A3 dual-dispatch
/// predicate). Defaults to <c>false</c> to preserve every pre-W4 call
/// site's behavior (server/interpreted-driven callers never asserted
/// autonomy); <see cref="MotionInterpreter.LeaveGround"/> is the one
/// caller that passes <c>true</c> (raw @305763-305765,
/// <c>set_local_velocity(&amp;var_c, 1)</c>).
/// </para>
/// </summary>
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
{
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
LastMoveWasAutonomous = autonomous;
set_velocity(worldVelocity);
}
// ── FUN_00511de0 ───────────────────────────────────────────────────────
/// <summary>
/// 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 &amp;= ~0x02 (clear OnWalkable)
/// else: TransientState |= 0x02 (set OnWalkable)
/// call calc_acceleration()
/// </summary>
public void set_on_walkable(bool isOnWalkable)
{
if (isOnWalkable)
TransientState |= TransientStateFlags.OnWalkable;
else
TransientState &= ~TransientStateFlags.OnWalkable;
calc_acceleration();
}
// ── FUN_0050f940 ───────────────────────────────────────────────────────
/// <summary>
/// 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 &lt; 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.
///
/// L.3c attempt (2026-04-30, REVERTED): tried bumping to 0.25f per
/// retail acclient_2013_pseudo_c.txt:276705. Build green but
/// PlayerMovementControllerTests showed forward locomotion dropping
/// from ~3m/s to ~0.16m/s — friction now hammers normal walking.
/// Retail's friction block is gated by an additional state check at
/// line 276702 (`(this->state & ...) == 0`) that we didn't decode
/// fully; locomotion is probably skipped from the friction path
/// while actively walking. Filed as L.3c-followup; keeping the
/// matching-the-decompile-as-read 0.0 threshold for now.
/// </summary>
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 ───────────────────────────────────────────────────────
/// <summary>
/// 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 &lt; SmallVelocitySquared: zero velocity
/// position += velocity * dt + 0.5 * acceleration * dt²
/// velocity += acceleration * dt
/// Apply angular delta: orientation rotated by omega * dt
/// </summary>
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.
// 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))
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 ───────────────────────────────────────────────────────
/// <summary>
/// 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 &lt; 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.
/// </summary>
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;
}
}