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>
559 lines
26 KiB
C#
559 lines
26 KiB
C#
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 0x60–0x80 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->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(&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 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.
|
||
///
|
||
/// <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(&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 &= ~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 < 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 < 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 < 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;
|
||
}
|
||
}
|