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.
// ────────────────────────────────────────────────────────────────────────────
///
/// State flags stored at struct offset +0xA8 (PhysicsState), verbatim
/// from the retail PhysicsState enum in acclient.h:2815.
///
[Flags]
public enum PhysicsStateFlags : uint
{
None = 0x00000000,
Static = 0x00000001,
///
/// 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.
///
ReservedUnused1 = 0x00000002,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
/// Retail UNNUSED2_PS — reserved, never set. See .
ReservedUnused2 = 0x00002000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
///
/// 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
/// CPhysicsObj::FindObjCollisions 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: PhysicsState.HasPhysicsBSP.
///
HasPhysicsBsp = 0x00010000,
///
/// 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.
///
Inelastic = 0x00020000,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
ReportAsEnvironment = 0x00200000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
}
///
/// 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
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
///
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail WATER_CONTACT_TS
/// (acclient.h:3688). Retail writes it in CPhysicsObj::SetPositionInternal
/// (0x005153e5-0051545f) in the same statement block as ,
/// immediately after, from the transition's local contact_plane_is_water
/// (acdream: ). Produced (mirrored
/// alongside by PhysicsObjUpdate.ApplySetPositionContact,
/// PhysicsObjUpdate.CommitSetPositionTransition, and
/// PhysicsEngine'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.
///
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
}
///
/// 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
// 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;
/// World-space position (frame origin). #145: the setter mirrors its
/// delta into so the cell-relative frame stays exact
/// through integration + the resolve-apply (both write here). Placement uses
/// instead (no delta).
public Vector3 Position
{
get => _position;
set
{
Vector3 delta = value - _position;
_position = value;
SyncCellPositionDelta(delta);
}
}
///
/// Cell-relative position (retail Position): the (cell, local∈[0,192)) pair.
/// #145 Slice 2 — rides alongside , becomes authoritative
/// in later slices. Default (ObjCellId==0) until seeds it.
///
public Position CellPosition { get; private set; }
///
/// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
/// physics_obj->cell != 0 "placed in the world" truth (register
/// row). CMotionInterp's dispatch tails strip link animations for
/// DETACHED objects only (if (cell == 0) RemoveLinkAnimations,
/// raw @305627); the previous proxy — .ObjCellId
/// == 0 — was seeded ONLY by the local player's
/// (#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
/// (local player placement) and by RemoteMotion construction (remotes
/// exist only for world entities). Default false = detached, matching
/// retail's pre-enter_world state.
///
public bool InWorld { get; set; }
///
/// Placement: set the world position AND seed from the
/// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the
/// seed is canonicalized via (retail
/// SetPositionInternal/adjust_to_outside @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. stays authoritative for
/// the world frame; canonicalizing the (cell, local) decomposition leaves the world
/// point unchanged for an in-range local.
///
public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal)
{
StageDormantCellFrame(cellId, worldPos, cellLocal);
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
///
/// 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.
///
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));
}
///
/// Commits only the frame while retaining the current cell identity. This
/// is retail CPhysicsObj::set_frame at 0x00514090, used by
/// UpdateObjectInternal when a transition fails: the integrated
/// candidate frame is kept, but Position.objcell_id is not changed
/// and no outdoor canonicalization is performed.
///
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));
}
}
///
/// Commits the position and full cell returned by a successful transition.
/// Retail CPhysicsObj::SetPositionInternal(CTransition const*)
/// (0x00515330) always commits both
/// sphere_path.curr_pos.objcell_id and its frame, including EnvCells.
/// The ordinary 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.
///
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.
}
/// Orientation quaternion (struct offsets 0x60–0x80 column matrix).
public Quaternion Orientation { get; set; } = Quaternion.Identity;
/// 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
/// > 1 (the "bleed on block").
public Vector3 Velocity { get; set; }
/// Retail cached_velocity — a SEPARATE field from : 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 or the integrator. Kept separate per the verified retail
/// two-velocity model — do not collapse the two.
public Vector3 CachedVelocity { get; set; }
/// 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 when fsf>1 (the airborne-stuck
/// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
/// pc:272625-656; handle_all_collisions pc:282695.
public int FramesStationaryFall { 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;
/// Last wall/object sliding normal (retail transient Sliding state).
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.
/// Whether currently holds a valid plane.
public bool ContactPlaneValid { get; set; }
/// Most recent walkable contact plane (world-space).
/// Updated at the end of every ResolveWithTransition call that found ground.
public System.Numerics.Plane ContactPlane { get; set; }
/// Full 32-bit cell id of the cell that owns .
public uint ContactPlaneCellId { get; set; }
/// Whether the contact plane is a water surface (affects step behavior).
public bool ContactPlaneIsWater { get; set; }
/// Whether the previous walkable polygon is available for edge slide.
public bool WalkablePolygonValid { get; set; }
/// Most recent walkable polygon plane (world-space).
public System.Numerics.Plane WalkablePlane { get; set; }
/// Most recent walkable polygon vertices (world-space).
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 source)
{
if (_walkableVertexStorage is null
|| _walkableVertexStorage.Length != source.Length)
{
_walkableVertexStorage = new Vector3[source.Length];
}
source.CopyTo(_walkableVertexStorage);
WalkableVertices = _walkableVertexStorage;
}
internal Vector3[]? RetainedWalkableVertexStorage
=> _walkableVertexStorage;
/// Up vector used by the most recent walkable polygon probe.
public Vector3 WalkableUp { 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; }
///
/// Retail CPhysicsObj::IsFullyConstrained (0x0050f730), read by
/// CMotionInterp::jump_is_allowed (raw 305524-305525:
/// if (IsFullyConstrained(physics_obj) != 0) return 0x47;) to block
/// a jump while the object is rubber-banding hard against a server
/// position correction.
///
/// 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
/// ConstraintManager::IsFullyConstrained (0x005560d0,
/// constraint_distance_max * 0.9 < constraint_pos_offset) —
/// 's constraint sub-manager. R5-V1
/// ported but did not arm it (#167,
/// former register row TS-35).
///
/// Campaign P P5 (2026-07-30) armed the leash at every current
/// inbound-position acceptance seam
/// (LiveEntityNetworkUpdateController for remotes,
/// PlayerMovementController.SetPosition/BlipPosition for the
/// local player — see docs/research/2026-07-30-constraint-leash-constants.md
/// §2/§3). only holds a
/// reference (no host), so this property stays a
/// plain settable bool; the per-tick pump that already runs
/// PositionManager.AdjustOffset (PlayerMovementController.Update,
/// RuntimeRemotePhysicsUpdater.Tick/TickHidden) is the single
/// owner that pushes PositionManager.IsFullyConstrained() here every
/// tick, so this read is now live, not stubbed.
///
public bool IsFullyConstrained { get; set; }
///
/// R3-W4 — retail CPhysicsObj::last_move_was_autonomous, read by
/// CPhysicsObj::movement_is_autonomous (0x0050eb30, decomp §7a
/// @276443: return this->last_move_was_autonomous;). Gates the
/// A3 dual-dispatch predicate in MotionInterpreter.apply_current_movement/
/// ReportExhaustion/SetWeenieObject/SetPhysicsObject:
/// 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 — PlayerMovementController);
/// left false (the safe default — routes to
/// apply_interpreted_movement) for DR-applied remote updates.
/// also sets this true
/// itself: retail's set_local_velocity(&var_c, 1) call passes
/// the autonomous flag literal 1 (raw @305763-305765).
///
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;
///
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail WATER_CONTACT_TS
/// mirror of onto .
/// Written in lockstep with by
/// ,
/// , and
/// 's per-resolve body-state commit.
///
public bool IsWaterContact => (TransientState & TransientStateFlags.WaterContact) != 0;
// ── 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 & 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 ───────────────────────────────────────────────────────
///
/// 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.
///
///
/// R3-W4: retail's set_local_velocity takes a second
/// autonomous arg (CPhysicsObj::set_local_velocity,
/// stores it to — read by
/// CPhysicsObj::movement_is_autonomous, the A3 dual-dispatch
/// predicate). Defaults to false to preserve every pre-W4 call
/// site's behavior (server/interpreted-driven callers never asserted
/// autonomy); is the one
/// caller that passes true (raw @305763-305765,
/// set_local_velocity(&var_c, 1)).
///
///
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
{
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
LastMoveWasAutonomous = autonomous;
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();
}
// ── CPhysicsObj::calc_friction (0050ee70) ───────────────────────────────
///
/// 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 (CPhysicsObj::calc_friction,
/// pseudo-C:276694-276822, 0050ee70) independently re-confirms the
/// 0.25f threshold (derived twice, once per BN-rendered branch) —
/// the OLD in-code claim that "the decompile uses 0.0" traced to the
/// unnamed, superseded FUN_0050f940 Ghidra chunk at a DIFFERENT
/// address; per CLAUDE.md the named decomp wins. Cross-checked against
/// ACE PhysicsObj.calc_friction
/// (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 if (state & SLEDDING_PS) 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 >= 0.25f → return (moving away fast enough — no friction)
/// velocity -= angle * contactPlane.N (remove inward normal component, unconditional)
/// friction = this->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
/// < 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 CPhysicsObj::calc_friction @ 0x0050ee70's
/// Sledding fast-sled branch (0x0050ef52-0x0050ef6a):
///
/// 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
///
/// This is a genuine fcos opcode over a real 10°-in-radians
/// double literal — not a BN misdecompile of a raw float load. Retail
/// truly computes cos(10°) ≈ 0.9848078 at runtime and compares
/// Normal.Z against it. ACE's 0.99999536f equals
/// cos(0.1745 DEGREES) — the same radian literal evaluated in
/// degree mode, a proven ACE porting error, not a BN artifact.
/// Replaced with the byte-confirmed 0.98480775f (cos 10°).
/// Register row AD-55 retired in the same commit.
///
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 ───────────────────────────────────────────────────────
///
/// 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 & 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 ───────────────────────────────
///
/// 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 (CPhysicsObj::update_object 0x00515D10):
/// if parent-attached (offset +0x40 != 0) → return
/// dVar1 = currentTime - LastUpdateTime
/// if dVar1 <= 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.
///
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;
}
}