acdream/src/AcDream.Core/Physics/PhysicsBody.cs

846 lines
42 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_00515020 update_object — per-frame top-level driver
//
// calc_friction is now cited against the NAMED retail decomp instead of the
// older unnamed FUN_0050f940 chunk — see its own doc comment below
// (CPhysicsObj::calc_friction, acclient_2013_pseudo_c.txt:276694, 0050ee70).
//
// Cross-checked against ACE PhysicsObj.cs and PhysicsGlobals.cs.
// ────────────────────────────────────────────────────────────────────────────
/// <summary>
/// State flags stored at struct offset +0xA8 (<c>PhysicsState</c>), verbatim
/// from the retail <c>PhysicsState</c> enum in <c>acclient.h:2815</c>.
/// </summary>
[Flags]
public enum PhysicsStateFlags : uint
{
None = 0x00000000,
Static = 0x00000001,
/// <summary>
/// Retail declares 0x2 and 0x2000 as UNUSED1_PS / UNNUSED2_PS (acclient.h:2818,
/// 2830) — reserved, never set. Named here so neither bit gets repurposed for an
/// acdream-local flag and then collides with a server that starts using it.
/// </summary>
ReservedUnused1 = 0x00000002,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
/// <summary>Retail UNNUSED2_PS — reserved, never set. See <see cref="ReservedUnused1"/>.</summary>
ReservedUnused2 = 0x00002000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
/// <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,
/// <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,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
ReportAsEnvironment = 0x00200000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
}
/// <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
// retail frames_stationary_fall carried across frames: transition() seeds fsf from
// these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf
// into them at the end of the frame (pc:282743/282749/282753).
StationaryFall = 0x00000010, // bit 4 — fsf == 1
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
/// <summary>
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail <c>WATER_CONTACT_TS</c>
/// (acclient.h:3688). Retail writes it in <c>CPhysicsObj::SetPositionInternal</c>
/// (0x005153e5-0051545f) in the same statement block as <see cref="Contact"/>,
/// immediately after, from the transition's local <c>contact_plane_is_water</c>
/// (acdream: <see cref="PhysicsBody.ContactPlaneIsWater"/>). Produced (mirrored
/// alongside <see cref="Contact"/> by <c>PhysicsObjUpdate.ApplySetPositionContact</c>,
/// <c>PhysicsObjUpdate.CommitSetPositionTransition</c>, and
/// <c>PhysicsEngine</c>'s per-resolve body-state commit) since 2026-07-30; no
/// confirmed retail CONSUMER of the bit was found in this pass (a full
/// bit-0x8-read xref scan of the 1.4M-line pseudo-C dump was not attempted —
/// bitmask reads are not text-greppable without high false-positive noise
/// across unrelated 0x8 masks) — see the AP-10 register row.
/// </summary>
WaterContact = 0x00000008, // bit 3 — WATER_CONTACT_TS
// Declared to complete retail's TransientState (acclient.h:3688). Not
// produced or consumed by acdream's transition yet; here so the free
// slot cannot be reused for something else and quietly collide with the wire.
CheckEthereal = 0x00000100, // bit 8 — CHECK_ETHEREAL_TS
}
/// <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
// Matching-client disassembly resolves the named lift's stripped global to
// 0.2 seconds. update_object consumes larger gaps as repeated 0.2 s quanta.
public const float MaxQuantum = 0.2f;
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>
/// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
/// <c>physics_obj-&gt;cell != 0</c> "placed in the world" truth (register
/// row). CMotionInterp's dispatch tails strip link animations for
/// DETACHED objects only (<c>if (cell == 0) RemoveLinkAnimations</c>,
/// raw @305627); the previous proxy — <see cref="CellPosition"/>.<c>ObjCellId
/// == 0</c> — was seeded ONLY by the local player's <see cref="SnapToCell"/>
/// (#145 machinery), so every REMOTE body read "detached" forever and
/// every dispatched transition link (door swings, walk↔run links) was
/// stripped the same tick it was appended. Set by <see cref="SnapToCell"/>
/// (local player placement) and by RemoteMotion construction (remotes
/// exist only for world entities). Default false = detached, matching
/// retail's pre-enter_world state.
/// </summary>
public bool InWorld { get; 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)
{
StageDormantCellFrame(cellId, worldPos, cellLocal);
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
/// <summary>
/// Installs the exact SetPosition cell/frame while the Runtime owner is
/// still dormant. This is the frame half of retail SetPositionInternal;
/// enter_world remains a later explicit publication suffix.
/// </summary>
public void StageDormantCellFrame(
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));
}
/// <summary>
/// Commits only the frame while retaining the current cell identity. This
/// is retail <c>CPhysicsObj::set_frame</c> at <c>0x00514090</c>, used by
/// <c>UpdateObjectInternal</c> when a transition fails: the integrated
/// candidate frame is kept, but <c>Position.objcell_id</c> is not changed
/// and no outdoor canonicalization is performed.
/// </summary>
public void SetFrameInCurrentCell(Vector3 worldPosition, Quaternion orientation)
{
Vector3 delta = worldPosition - _position;
_position = worldPosition;
Orientation = orientation;
if (CellPosition.ObjCellId != 0)
{
CellPosition = new Position(
CellPosition.ObjCellId,
new CellFrame(CellPosition.Frame.Origin + delta, orientation));
}
}
/// <summary>
/// Commits the position and full cell returned by a successful transition.
/// Retail <c>CPhysicsObj::SetPositionInternal(CTransition const*)</c>
/// (<c>0x00515330</c>) always commits both
/// <c>sphere_path.curr_pos.objcell_id</c> and its frame, including EnvCells.
/// The ordinary <see cref="Position"/> setter first carries the accepted world
/// displacement into the landblock-relative frame; this method then adopts the
/// resolver's exact cell identity. Outdoor frames are canonicalized across 24 m
/// cells and 192 m landblock boundaries. Indoor frames retain their carried
/// landblock-relative origin and adopt the resolved EnvCell id.
/// </summary>
public void CommitTransitionPosition(uint resolvedCellId, Vector3 worldPosition)
{
Position = worldPosition;
if (CellPosition.ObjCellId == 0 || resolvedCellId == 0)
return;
uint cell = resolvedCellId;
Vector3 local = CellPosition.Frame.Origin;
if ((cell & 0xFFFFu) is >= 1u and <= 0x40u
&& !LandDefs.AdjustToOutside(ref cell, ref local))
{
// At the map edge retail's transition does not yield a successful
// outside cell. Preserve the carried frame instead of inventing one.
return;
}
CellPosition = new Position(
cell,
new CellFrame(local, CellPosition.Frame.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. A SEEDED indoor body carries the same
// landblock-relative delta but keeps its EnvCell identity until the transition result
// commits the exact destination via CommitTransitionPosition. Unseeded bodies are skipped.
private void SyncCellPositionDelta(Vector3 delta)
{
uint cell = CellPosition.ObjCellId;
if (cell == 0)
return;
Vector3 local = CellPosition.Frame.Origin + delta;
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
{
CellPosition = new Position(
cell,
new CellFrame(local, CellPosition.Frame.Orientation));
return;
}
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) — retail m_velocityVector: the INTENDED
/// velocity that the integrator advances (gravity, friction, MaxVelocity clamp) and that
/// drives the collision sweep. Zeroed by handle_all_collisions when
/// <see cref="FramesStationaryFall"/> &gt; 1 (the "bleed on block").</summary>
public Vector3 Velocity { get; set; }
/// <summary>Retail cached_velocity — a SEPARATE field from <see cref="Velocity"/>: the
/// REALIZED velocity (resolved displacement / dt) written after each transition in
/// UpdateObjectInternal (0x005158cb-005158ff, pc:283693). Read only for reporting /
/// dead-reckoning / camera slope-align (get_velocity 0x005113c0); it is NEVER fed back
/// into <see cref="Velocity"/> or the integrator. Kept separate per the verified retail
/// two-velocity model — do not collapse the two.</summary>
public Vector3 CachedVelocity { get; set; }
/// <summary>Retail collision_info.frames_stationary_fall carried on the body between
/// frames. ValidateTransition increments it (0→1→2→3) when the sphere fails to advance and
/// resets it to 0 when it moves; at fsf≥2 an upward contact plane is manufactured;
/// handle_all_collisions zeros <see cref="Velocity"/> when fsf&gt;1 (the airborne-stuck
/// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
/// pc:272625-656; handle_all_collisions pc:282695.</summary>
public int FramesStationaryFall { 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; }
// Transition publication retains one exact-length backing array. The
// public property remains assignable for snapshot restoration and tests;
// engine writeback uses this separate storage so clearing the logical
// walkable reference does not force a new array on the next grounded
// resolve.
private Vector3[]? _walkableVertexStorage;
internal void SetWalkableVerticesExact(ReadOnlySpan<Vector3> source)
{
if (_walkableVertexStorage is null
|| _walkableVertexStorage.Length != source.Length)
{
_walkableVertexStorage = new Vector3[source.Length];
}
source.CopyTo(_walkableVertexStorage);
WalkableVertices = _walkableVertexStorage;
}
internal Vector3[]? RetainedWalkableVertexStorage
=> _walkableVertexStorage;
/// <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>
/// 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>) to block
/// a jump while the object is rubber-banding hard against a server
/// position correction.
///
/// <para>R3-W3 originally stubbed this as an always-false property under
/// a WRONG mechanism guess (per-cell contact-plane / doorway-jamming).
/// R5-V1 corrected the mechanism: the real retail source is
/// <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0,
/// <c>constraint_distance_max * 0.9 &lt; constraint_pos_offset</c>) —
/// <see cref="Motion.PositionManager"/>'s constraint sub-manager. R5-V1
/// ported <see cref="Motion.ConstraintManager"/> but did not arm it (#167,
/// former register row TS-35).</para>
///
/// <para>Campaign P P5 (2026-07-30) armed the leash at every current
/// inbound-position acceptance seam
/// (<c>LiveEntityNetworkUpdateController</c> for remotes,
/// <c>PlayerMovementController.SetPosition</c>/<c>BlipPosition</c> for the
/// local player — see <c>docs/research/2026-07-30-constraint-leash-constants.md</c>
/// §2/§3). <see cref="MotionInterpreter"/> only holds a
/// <see cref="PhysicsBody"/> reference (no host), so this property stays a
/// plain settable bool; the per-tick pump that already runs
/// <c>PositionManager.AdjustOffset</c> (<c>PlayerMovementController.Update</c>,
/// <c>RuntimeRemotePhysicsUpdater.Tick</c>/<c>TickHidden</c>) is the single
/// owner that pushes <c>PositionManager.IsFullyConstrained()</c> here every
/// tick, so this read is now live, not stubbed.</para>
/// </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 & PhysicsStateFlags.Gravity) != 0;
public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
public bool InContact => (TransientState & TransientStateFlags.Contact) != 0;
/// <summary>
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail <c>WATER_CONTACT_TS</c>
/// mirror of <see cref="ContactPlaneIsWater"/> onto <see cref="TransientState"/>.
/// Written in lockstep with <see cref="InContact"/> by
/// <see cref="PhysicsObjUpdate.ApplySetPositionContact"/>,
/// <see cref="PhysicsObjUpdate.CommitSetPositionTransition"/>, and
/// <see cref="PhysicsEngine"/>'s per-resolve body-state commit.
/// </summary>
public bool IsWaterContact => (TransientState & TransientStateFlags.WaterContact) != 0;
// ── 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 & TransientStateFlags.Contact) != 0 &&
(TransientState & TransientStateFlags.OnWalkable) != 0 &&
(State & PhysicsStateFlags.Sledding) == 0)
{
Acceleration = Vector3.Zero;
Omega = Vector3.Zero;
return;
}
if ((State & PhysicsStateFlags.Gravity) != 0)
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();
}
// ── CPhysicsObj::calc_friction (0050ee70) ───────────────────────────────
/// <summary>
/// Apply friction deceleration to the velocity when the body is standing
/// on a walkable surface.
///
/// AP-7 resolved (Campaign P Slice P2, 2026-07-30,
/// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1).
/// The named retail decomp (<c>CPhysicsObj::calc_friction</c>,
/// pseudo-C:276694-276822, 0050ee70) independently re-confirms the
/// <b>0.25f</b> threshold (derived twice, once per BN-rendered branch) —
/// the OLD in-code claim that "the decompile uses 0.0" traced to the
/// unnamed, superseded <c>FUN_0050f940</c> Ghidra chunk at a DIFFERENT
/// address; per CLAUDE.md the named decomp wins. Cross-checked against
/// ACE <c>PhysicsObj.calc_friction</c>
/// (references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141),
/// which reads as ONE linear function rather than the BN-rendered "two
/// duplicated branches" — the branch split is most likely a BN decompiler
/// artifact around a single <c>if (state &amp; SLEDDING_PS)</c> block
/// (ACE-derived, Ghidra-verify: see the research doc §7 items 1-2 for the
/// still-open BN-artifact-vs-genuine-duplication question; low
/// implementation risk either way since ACE's single-linear-function
/// reading is adopted regardless).
///
/// Decompiled logic (retail, ACE-derived shape):
/// if NOT OnWalkable → return
/// angle = dot(velocity, contactPlane.N)
/// if angle &gt;= 0.25f → return (moving away fast enough — no friction)
/// velocity -= angle * contactPlane.N (remove inward normal component, unconditional)
/// friction = this-&gt;friction (same baseline in every case)
/// if Sledding: velocityMag2-banded override (see below)
/// velocity *= pow(1 - friction, dt)
///
/// L.3c attempt (2026-04-30, REVERTED): a bare 0.25f bump with no other
/// change dropped measured forward locomotion from ~3 m/s to ~0.16 m/s
/// in PlayerMovementControllerTests — friction engaged EVERY tick because
/// flat-ground walking has dot(velocity, groundNormal) ≈ 0, which is
/// &lt; 0.25f. The research pass's math check: DefaultFriction=0.95f (both
/// PhysicsBody.cs:120 and ACE PhysicsGlobals.cs:15 agree — not a divergent
/// constant), so pow(0.05, dt) at 60 Hz over 1s ≈ 0.951^60 ≈ 4.9% velocity
/// remaining — matches the observed hammering almost exactly.
///
/// Why this is safe to land now: the L.3c test predates the 2026-07-17
/// "local player animation-owned grounded movement" landing (R6). Walking
/// displacement comes from the animation Frame delta applied directly to
/// Position, not from integrating Velocity, so ordinary root-motion-driven
/// walking never puts real XY speed into Velocity in the first place
/// (nothing writes it there — see the #265/#166 fix note below). Friction
/// decaying an already-zero horizontal Velocity is a no-op, so the L.3c
/// mechanism does not reproduce on that path. The `else` branch (no
/// animation root motion — headless/test-controller movers using
/// `get_state_velocity`, and remote/NPC movers) DOES still feed real XY
/// speed into Velocity and remains exposed; see
/// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests for
/// the regression pin on the root-motion path specifically.
///
/// #265/#166 RESOLVED (2026-07-30,
/// docs/research/2026-07-30-265-capture-bisect.md §9): until this date,
/// PlayerMovementController.cs's grounded-tick block ALSO hand-zeroed
/// Velocity.X/Y to exactly 0 every tick once OnWalkable for the
/// animation-root-motion case (regardless of why the body was grounded —
/// a fall, not just ordinary walking), discarding any residual landing
/// momentum before this very function ever got a chance to decay it, and
/// GroundNormal (the vector this function dots velocity against) had zero
/// production writers and silently defaulted to Vector3.UnitZ. Both gaps
/// are now closed: the grounded block no longer reconstructs Velocity for
/// the root-motion case, and PhysicsEngine.cs syncs GroundNormal from the
/// committed ContactPlane.Normal after every resolve. This function's
/// 0.25f threshold and Sledding overrides were always correctly ported;
/// they simply had nothing real to operate on until now.
///
/// AD-55 RESOLVED (Campaign P final physics slice, 2026-07-30; byte
/// decode in docs/research/2026-07-30-ts4-116-oracle-plan.md Addendum).
/// Raw bytes of <c>CPhysicsObj::calc_friction @ 0x0050ee70</c>'s
/// Sledding fast-sled branch (0x0050ef52-0x0050ef6a):
/// <code>
/// d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.Normal.Z
/// dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; = 0.17453292519943295 (10 deg in RADIANS)
/// d9 ff fcos ; st0 = cos(10 deg) = 0.984807753
/// de d9 fcompp
/// </code>
/// This is a genuine <c>fcos</c> opcode over a real 10°-in-radians
/// double literal — not a BN misdecompile of a raw float load. Retail
/// truly computes <c>cos(10°) ≈ 0.9848078</c> at runtime and compares
/// <c>Normal.Z</c> against it. ACE's <c>0.99999536f</c> equals
/// <c>cos(0.1745 DEGREES)</c> — the same radian literal evaluated in
/// degree mode, a proven ACE porting error, not a BN artifact.
/// Replaced with the byte-confirmed <c>0.98480775f</c> (cos 10°).
/// Register row AD-55 retired in the same commit.
/// </summary>
public void calc_friction(float dt, float velocityMag2)
{
if ((TransientState & TransientStateFlags.OnWalkable) == 0)
return;
float angle = Vector3.Dot(Velocity, GroundNormal);
if (angle >= 0.25f)
return;
// Remove the component of velocity that presses into the ground normal.
// Unconditional past the threshold check — no separate inner guard.
Velocity -= angle * GroundNormal;
float friction = Friction;
// Sledding modifies friction thresholds (from ACE cross-check).
if ((State & PhysicsStateFlags.Sledding) != 0)
{
if (velocityMag2 < 1.5625f) // 1.25² — slow sled
friction = 1.0f;
else if (velocityMag2 >= 6.25f && GroundNormal.Z > 0.98480775f) // cos(10°), byte-confirmed (AD-55, see doc comment)
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 & TransientStateFlags.OnWalkable) != 0)
TransientState &= ~TransientStateFlags.Active;
}
else
{
// Clamp velocity magnitude to MaxVelocity.
if (velocityMag2 > MaxVelocitySquared)
{
Velocity = Vector3.Normalize(Velocity) * MaxVelocity;
velocityMag2 = MaxVelocitySquared;
}
calc_friction(dt, velocityMag2);
// Retail UpdatePhysicsInternal (0x005107be): zero velocity below 0.25 m/s
// UNCONDITIONALLY — NOT gated on OnWalkable. At the jump apex this zeros the
// residual horizontal drift; the unconditional `Velocity += Acceleration * dt`
// below immediately re-applies gravity, so the fall still accumulates. (The old
// OnWalkable gate was an acdream divergence; the verbatim rebuild removes it.)
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: Frame::grotate receives the WORLD-space
// rotation vector omega*dt and premultiplies its quaternion onto the
// frame (0x005357A0; called at UpdatePhysicsInternal 0x00510935).
// Its F_EPSILON gate is applied to the rotation vector, not omega.
Vector3 rotation = Omega * dt;
float rotationLenSq = rotation.LengthSquared();
if (rotationLenSq >= PhysicsGlobals.EpsilonSq)
{
float angle = MathF.Sqrt(rotationLenSq);
Quaternion deltaRot = Quaternion.CreateFromAxisAngle(rotation / angle, angle);
Orientation = Quaternion.Normalize(Quaternion.Multiply(deltaRot, Orientation));
}
}
// ── CPhysicsObj::update_object 0x00515D10 ───────────────────────────────
/// <summary>
/// Per-frame top-level driver. Computes dt from the wall clock versus
/// LastUpdateTime, discards invalid gaps, and advances the consumed
/// PhysicsTimer time through fixed maximum quanta plus one remainder.
///
/// Decompiled logic (<c>CPhysicsObj::update_object</c> 0x00515D10):
/// if parent-attached (offset +0x40 != 0) → return
/// dVar1 = currentTime - LastUpdateTime
/// if dVar1 &lt;= EPSILON → update timestamp and return
/// if dVar1 > HugeQuantum → update timestamp and return (stale — discard)
/// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract
/// if dVar1 > MinQuantum: simulate remainder
/// LastUpdateTime = consumed PhysicsTimer time
///
/// 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;
// Retail drops only a true micro-fragment. A larger remainder at or
// below MinQuantum is retained by leaving LastUpdateTime unchanged.
if (deltaTime <= PhysicsGlobals.EPSILON)
{
LastUpdateTime = currentTime;
return;
}
// Stale / first frame — just consume the time without simulating
if (deltaTime > HugeQuantum)
{
LastUpdateTime = currentTime;
return;
}
double physicsTime = LastUpdateTime;
// Sub-step: break large dt into MaxQuantum chunks.
while (deltaTime > MaxQuantum)
{
physicsTime += MaxQuantum;
calc_acceleration();
UpdatePhysicsInternal(MaxQuantum);
deltaTime -= MaxQuantum;
}
// Simulate the remainder
if (deltaTime > MinQuantum)
{
physicsTime += deltaTime;
calc_acceleration();
UpdatePhysicsInternal((float)deltaTime);
}
LastUpdateTime = physicsTime;
}
}