846 lines
42 KiB
C#
846 lines
42 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_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->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 0x60–0x80 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"/> > 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>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 < 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->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 & 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 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();
|
||
}
|
||
|
||
// ── 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 & 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 >= 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 <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 < 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 <= 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;
|
||
}
|
||
}
|