feat(physics): port retail complete object frame pipeline

Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View file

@ -142,15 +142,16 @@ public sealed class AnimationSequencer
/// <para>
/// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
/// reads this to integrate position without gapping during stance
/// transitions.
/// last, so SetVelocity's latest call wins). <c>CSequence::apply_physics</c>
/// contributes it directly to the complete root Frame; no separate body-
/// velocity reconstruction is performed from this accessor.
/// </para>
/// </summary>
public Vector3 CurrentVelocity => _core.Velocity;
/// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// Sequence-wide omega. <c>CSequence::apply_physics</c> rotates the same
/// complete root Frame that carries PosFrames and sequence velocity.
/// </summary>
public Vector3 CurrentOmega => _core.Omega;
@ -283,6 +284,12 @@ public sealed class AnimationSequencer
_table = new CMotionTable(motionTable);
_state = new MotionState();
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
// CPartArray::InitDefaults (0x00518980) runs for every setup-backed
// PartArray, before CPhysicsObj::InitDefaults installs its motion
// table. Static is only a later workset-membership decision; it does
// not gate installation of Setup.DefaultAnimation itself.
InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation);
}
/// <summary>
@ -399,38 +406,9 @@ public sealed class AnimationSequencer
if (dispatchResult != MotionTableManagerError.Success)
return;
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
// Humanoid turn MotionData often ships without HasOmega. Until the
// remaining R6 rotation path consumes CSequence's complete Frame
// orientation, the remote ObservedOmega seam needs the retail
// turn-rate fallback. Decompile references:
// FUN_00529210 apply_current_movement (writes Omega)
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
// turns (holtburger confirms the same via motion_resolution.rs).
if (_core.Omega.LengthSquared() < 1e-9f)
{
float zomega = 0f;
uint low = motion & 0xFFu;
switch (low)
{
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
case 0x0E: // TurnLeft — counter-clockwise = +Z.
// adjust_motion above ALREADY remapped 0x0E → 0x0D
// with adjustedSpeed = -speedMod, so the same
// formula as 0x0D applied to the negated speed
// produces the correct +Z (CCW) result. Using a
// different sign here would double-negate and
// animate a left turn as a right turn — that was
// the bug observed before this fix (commit follows).
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
}
if (zomega != 0f)
_core.SetOmega(new Vector3(0f, 0f, zomega));
}
// Rotation stays DAT-authored. Retail MotionData::add_motion
// (0x005224B0) contributes the entry's literal omega to CSequence;
// CSequence::apply_physics emits it through the complete Frame.
}
/// <summary>
@ -453,6 +431,36 @@ public sealed class AnimationSequencer
/// </summary>
public void InitializeState() => EnsureInitialized();
/// <summary>
/// Retail <c>CPartArray::InitDefaults</c> (0x00518980): a Setup
/// <c>DefaultAnimation</c> bypasses the MotionTable, clears the sequence,
/// and appends one direct animation over frames <c>0..-1</c> at 30 fps.
/// Installation is unconditional for every setup-backed PartArray.
/// <c>CPhysicsObj::InitDefaults</c> separately decides whether a Static
/// owner enters <c>CPhysics::static_animating_objects</c>; ordinary motion
/// table initialization may subsequently replace this direct sequence.
/// </summary>
public bool InitializeSetupDefaultAnimation(uint animationId)
{
if (animationId == 0)
return false;
// CPartArray::InitDefaults (0x00518980) calls only
// CSequence::clear_animations (0x00524DC0). Sequence velocity,
// omega, and placement state belong to the surrounding PartArray
// lifetime and survive replacement of the animation list.
_core.ClearAnimations();
_pendingHooks.Clear();
_core.AppendAnimation(new AnimData
{
AnimId = (QualifiedDataId<Animation>)animationId,
LowFrame = 0,
HighFrame = -1,
Framerate = 30f,
});
return _core.CurrAnim is not null;
}
/// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@ -15,15 +16,9 @@ namespace AcDream.Core.Physics;
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
// InterpolationManager::StopInterpolating acclient @ 0x00555950
//
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
// current body position + max-speed from the motion table; we return the
// world-space delta vector to apply to the body for this frame.
//
// Public C# API kept Vector3-based for compatibility with PositionManager and
// GameWindow callsites; retail-spec method names are documented inline. The
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
// queue's purposes — see audit 04-interp-manager.md § 4.
// FIFO Position-waypoint queue (cap 20). The compatibility overload returns
// only its world-space origin, while the production overload carries retail's
// complete relative Frame from Position::subtract2, including orientation.
//
// Bug fixes applied vs prior port (audit § 7):
// #1: progress_quantum accumulates dt (not step magnitude).
@ -38,8 +33,7 @@ namespace AcDream.Core.Physics;
internal sealed class InterpolationNode
{
public Vector3 TargetPosition;
public float Heading;
public bool IsHeadingValid;
public Quaternion TargetOrientation = Quaternion.Identity;
}
/// <summary>
@ -120,6 +114,7 @@ public sealed class InterpolationManager
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
private float _originalDistance = OriginalDistanceSentinel; // original_distance
private int _failCount = 0; // node_fail_counter
private bool _keepHeading; // keep_heading
// ── public API ────────────────────────────────────────────────────────────
@ -167,11 +162,31 @@ public sealed class InterpolationManager
/// Pass <c>null</c> if not available — far/near classification falls back
/// to "near" (no pre-armed blip).
/// </param>
public void Enqueue(
public Quaternion? Enqueue(
Vector3 targetPosition,
float heading,
bool isMovingTo,
Vector3? currentBodyPosition = null)
=> Enqueue(
targetPosition,
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading),
isMovingTo,
currentBodyPosition,
currentBodyOrientation: null);
/// <summary>
/// Complete-frame overload of retail <c>InterpolateTo</c>. The node keeps
/// the target quaternion; a near enqueue assigns
/// <paramref name="isMovingTo"/> to retail's manager-wide
/// <c>keep_heading</c> flag. The far branch deliberately retains the
/// manager's prior flag, matching the retail early return.
/// </summary>
public Quaternion? Enqueue(
Vector3 targetPosition,
Quaternion targetOrientation,
bool isMovingTo,
Vector3? currentBodyPosition = null,
Quaternion? currentBodyOrientation = null)
{
// Retail compares dist against either the tail's stored position
// (if tail exists AND tail->type == 1) or the body's m_position.
@ -195,10 +210,17 @@ public sealed class InterpolationManager
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
if (dist > AutonomyBlipDistance)
{
EnqueueRaw(targetPosition, heading, isMovingTo);
// The far branch does not assign keep_heading from arg3. It uses
// the manager's existing flag when storing this Position.
EnqueueRaw(
targetPosition,
StoreTargetOrientation(
targetOrientation,
currentBodyOrientation,
_keepHeading));
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
_failCount = StallFailCountThreshold + 1;
return;
return null;
}
// Near & already-close branch (retail line 352962):
@ -209,7 +231,14 @@ public sealed class InterpolationManager
if (bodyDist <= DesiredDistance)
{
Clear();
return;
// InterpolateTo 0x00555C08 calls CPhysicsObj::set_heading
// with the target Frame's heading. It does not install the
// target's pitch/roll at this already-close seam.
return isMovingTo
? null
: MoveToMath.SetHeading(
targetOrientation,
MoveToMath.GetHeading(targetOrientation));
}
}
@ -227,19 +256,43 @@ public sealed class InterpolationManager
_queue.RemoveFirst();
// 3. Append.
EnqueueRaw(targetPosition, heading, isMovingTo);
_keepHeading = isMovingTo;
EnqueueRaw(
targetPosition,
StoreTargetOrientation(
targetOrientation,
currentBodyOrientation,
_keepHeading));
return null;
}
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
private void EnqueueRaw(
Vector3 target,
Quaternion targetOrientation)
{
_queue.AddLast(new InterpolationNode
{
TargetPosition = target,
Heading = heading,
IsHeadingValid = isMovingTo,
TargetOrientation = targetOrientation,
});
}
private static Quaternion StoreTargetOrientation(
Quaternion targetOrientation,
Quaternion? currentBodyOrientation,
bool keepHeading)
{
if (!keepHeading || currentBodyOrientation is not { } current)
return targetOrientation;
// InterpolateTo stores the object's current heading into the node
// when keep_heading is active. Frame::set_heading intentionally
// discards the target Position's pitch/roll at this seam.
return MoveToMath.SetHeading(
targetOrientation,
MoveToMath.GetHeading(current));
}
/// <summary>
/// Compute the per-frame world-space correction delta. Combines the retail
/// <c>UseTime</c> blip-check (fail_count &gt; 3 → snap to tail, clear queue)
@ -259,14 +312,74 @@ public sealed class InterpolationManager
/// Max motion-table speed for this entity's current cycle (m/s).
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
/// </param>
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
public Vector3 AdjustOffset(
double dt,
Vector3 currentBodyPosition,
float maxSpeedFromMinterp,
bool inContact = true)
{
if (!inContact)
return Vector3.Zero;
InterpolationStep step = ComputeStep(
dt,
currentBodyPosition,
maxSpeedFromMinterp);
return step.Overwrites ? step.WorldOrigin : Vector3.Zero;
}
/// <summary>
/// Retail <c>InterpolationManager::adjust_offset</c> complete-Frame path
/// (0x00555D30). When interpolation is active it replaces both components
/// of <paramref name="offset"/> with <c>Position::subtract2</c>'s relative
/// target frame, then scales only Origin to the catch-up step. A MoveTo
/// node keeps heading by replacing the relative rotation with identity.
/// When the queue is empty or a node completes, the incoming PartArray
/// frame remains untouched.
/// </summary>
public bool AdjustOffset(
double dt,
Vector3 currentBodyPosition,
Quaternion currentBodyOrientation,
float maxSpeedFromMinterp,
MotionDeltaFrame offset,
bool inContact = true)
{
ArgumentNullException.ThrowIfNull(offset);
if (!inContact)
return false;
InterpolationStep step = ComputeStep(
dt,
currentBodyPosition,
maxSpeedFromMinterp);
if (!step.Overwrites)
return false;
offset.Origin = MoveToMath.GlobalToLocalVec(
currentBodyOrientation,
step.WorldOrigin);
offset.Orientation = _keepHeading
? Quaternion.Identity
: FrameOps.SetRotate(
offset.Origin,
Quaternion.Identity,
Quaternion.Inverse(currentBodyOrientation)
* step.TargetOrientation);
return true;
}
private InterpolationStep ComputeStep(
double dt,
Vector3 currentBodyPosition,
float maxSpeedFromMinterp)
{
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
if (dt <= 0 || double.IsNaN(dt))
return Vector3.Zero;
return default;
if (_queue.First is null)
return Vector3.Zero;
return default;
// Distance to head node (retail line 353083).
var head = _queue.First.Value;
@ -278,7 +391,7 @@ public sealed class InterpolationManager
if (dist <= DesiredDistance)
{
NodeCompleted(popHead: true, currentBodyPosition);
return Vector3.Zero;
return default;
}
// Catch-up speed (retail line 353122 + 353128 fallback).
@ -344,9 +457,13 @@ public sealed class InterpolationManager
// Retail splits this into a separate UseTime call; we collapse it.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
InterpolationNode tail = _queue.Last!.Value;
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
Clear();
return tailPos - currentBodyPosition;
return new InterpolationStep(
true,
tailDelta,
tail.TargetOrientation);
}
// Per-frame step magnitude (retail line 353218).
@ -358,9 +475,17 @@ public sealed class InterpolationManager
// Direction × step.
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
return delta;
return new InterpolationStep(
true,
delta,
head.TargetOrientation);
}
private readonly record struct InterpolationStep(
bool Overwrites,
Vector3 WorldOrigin,
Quaternion TargetOrientation);
/// <summary>
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
/// popHead=false during stall fail (re-baseline only). For our collapsed

View file

@ -25,6 +25,48 @@ public static class FrameOps
/// square against |v|².</summary>
public const float FEpsilon = 0.000199999995f;
/// <summary>
/// Retail <c>Frame::set_rotate</c> (0x00535080). The candidate is
/// normalized in extended precision, then the complete frame is checked
/// by <c>Frame::IsValid</c> (0x00534ED0). If that check fails, retail
/// restores the previous quaternion instead of allowing NaNs to poison
/// the live object transform.
/// </summary>
public static Quaternion SetRotate(
Vector3 frameOrigin,
Quaternion previous,
Quaternion candidate)
{
double lengthSquared =
((double)candidate.W * candidate.W)
+ ((double)candidate.X * candidate.X)
+ ((double)candidate.Y * candidate.Y)
+ ((double)candidate.Z * candidate.Z);
double inverseLength = 1.0 / Math.Sqrt(lengthSquared);
var normalized = new Quaternion(
(float)(candidate.X * inverseLength),
(float)(candidate.Y * inverseLength),
(float)(candidate.Z * inverseLength),
(float)(candidate.W * inverseLength));
if (float.IsNaN(frameOrigin.X)
|| float.IsNaN(frameOrigin.Y)
|| float.IsNaN(frameOrigin.Z)
|| float.IsNaN(normalized.W)
|| float.IsNaN(normalized.X)
|| float.IsNaN(normalized.Y)
|| float.IsNaN(normalized.Z))
{
return previous;
}
float normalizedLengthSquared = normalized.LengthSquared();
return !float.IsNaN(normalizedLengthSquared)
&& MathF.Abs(normalizedLengthSquared - 1f) < FEpsilon * 5f
? normalized
: previous;
}
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
public static void GRotate(Frame frame, Vector3 rotationGlobal)
{
@ -48,7 +90,10 @@ public static class FrameOps
rotationGlobal.Y * s * invMag,
rotationGlobal.Z * s * invMag,
c);
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
Quaternion.Multiply(r, frame.Orientation));
}
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
@ -65,7 +110,10 @@ public static class FrameOps
public static void Combine(Frame frame, Frame pos)
{
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
frame.Orientation * pos.Orientation);
}
/// <summary>
@ -75,7 +123,9 @@ public static class FrameOps
/// </summary>
public static void Subtract1(Frame frame, Frame pos)
{
frame.Orientation = Quaternion.Normalize(
frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
}

View file

@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion;
///
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
/// position delta, in the mover's LOCAL frame after
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
/// (P5 convention, degrees).</para>
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> is the full
/// relative quaternion carried by retail <c>Frame</c>; the heading accessors
/// are narrow helpers for managers that explicitly call
/// <c>Frame::set_heading</c>.</para>
/// </summary>
public sealed class MotionDeltaFrame
{
@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame
/// delta (mover-local frame).</summary>
public Vector3 Origin;
/// <summary>Retail <c>Frame</c> rotation — carries the
/// <c>Frame::set_heading</c> output.</summary>
/// <summary>Retail <c>Frame</c>'s complete relative rotation.</summary>
public Quaternion Orientation = Quaternion.Identity;
/// <summary>
/// Restore retail's identity <c>Frame</c>: zero translation and identity
/// orientation. Object ticks reuse these accumulators to avoid allocating
/// one transform per entity per render frame.
/// </summary>
public void Reset()
{
Origin = Vector3.Zero;
Orientation = Quaternion.Identity;
}
/// <summary>
/// Retail <c>Frame::combine</c> (0x005122E0), specialized for the mutable
/// per-tick delta frame. The incoming translation is expressed in this
/// frame's local coordinates, so the current orientation rotates it before
/// addition; the incoming orientation is then post-multiplied. Translation
/// scaling is the separate <c>m_scale</c> operation performed by
/// <c>CPhysicsObj::UpdatePositionInternal</c>.
/// </summary>
public void Combine(
Vector3 localOrigin,
Quaternion localOrientation,
float originScale = 1f)
{
Origin += Vector3.Transform(localOrigin * originScale, Orientation);
Orientation = FrameOps.SetRotate(
Origin,
Orientation,
Orientation * localOrientation);
}
/// <summary>
/// Compose another complete delta frame using retail
/// <c>Frame::combine</c> semantics.
/// </summary>
public void Combine(MotionDeltaFrame delta, float originScale = 1f)
{
ArgumentNullException.ThrowIfNull(delta);
Combine(delta.Origin, delta.Orientation, originScale);
}
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
public float GetHeading() => MoveToMath.GetHeading(Orientation);

View file

@ -20,52 +20,25 @@ namespace AcDream.Core.Physics.Motion;
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
/// AP-73 row approximated with one cycle.
/// </para>
///
/// <para>
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
/// interim ObservedOmega seam: the App seeds the remote body's angular
/// velocity from the wire turn so rotation starts the same tick (retail
/// rotates the body from the sequence omega inside the per-tick
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
/// the dispatch so a consumer sees the seed even if the dat lacks the
/// modifier entry.
/// </para>
/// </summary>
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
{
private readonly AnimationSequencer _sequencer;
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
/// TurnLeft arrives either as the explicit 0x0E command or as
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
public Action<uint, float>? TurnApplied { get; set; }
/// <summary>Turn-class stop observed.</summary>
public Action? TurnStopped { get; set; }
public MotionTableDispatchSink(AnimationSequencer sequencer)
{
ArgumentNullException.ThrowIfNull(sequencer);
_sequencer = sequencer;
}
private static bool IsTurn(uint motion)
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
public bool ApplyMotion(uint motion, float speed)
{
if (IsTurn(motion))
TurnApplied?.Invoke(motion, speed);
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
return result == MotionTableManagerError.Success;
}
public bool StopMotion(uint motion)
{
if (IsTurn(motion))
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
return result == MotionTableManagerError.Success;
}
@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
/// The manager stops everything and queues its Ready-sentinel
/// pending_animations entry UNCONDITIONALLY — the completable partner
/// of the interp's A9 pending_motions node. A full stop ends any turn
/// cycle, so the ObservedOmega seam is notified like
/// <see cref="StopMotion"/>'s turn-class branch.
/// of the interp's A9 pending_motions node.
/// </summary>
public bool StopCompletely()
{
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
return result == MotionTableManagerError.Success;
}

View file

@ -114,12 +114,8 @@ public static class MoveToMath
/// <summary>
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
/// extracts the compass heading (P5 convention) from a body orientation
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
/// wire-correct at the QUATERNION level but its internal scalar
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
/// SHIFTED convention, not retail's. This method uses the CORRECT
/// scalar bridge derived from acdream's own body convention
/// quaternion. This method uses the scalar bridge derived from acdream's
/// body convention
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
@ -166,11 +162,10 @@ public static class MoveToMath
}
/// <summary>
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
/// faces +X, re-synced into the body quaternion every Update). Same P5
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
/// R4-V5: scalar projection of <see cref="GetHeading"/>. The local
/// player's <c>Yaw</c> property exposes this horizontal projection while
/// its complete body quaternion remains authoritative. Same P5 bridge:
/// <c>heading = (90 - yawDeg) mod 360</c>.
/// </summary>
public static float HeadingFromYaw(float yawRad)
{
@ -181,10 +176,10 @@ public static class MoveToMath
}
/// <summary>
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
/// player's heading snap must write <c>Yaw</c>, NOT the body
/// quaternion, which the controller re-derives from Yaw every frame).
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the scalar
/// bridge used by an explicit <c>Frame::set_heading</c> operation. This
/// operation deliberately replaces pitch/roll; ordinary frame composition
/// does not pass through this projection.
/// Returns radians wrapped to [-π, π] matching the controller's own
/// wrap discipline.
/// </summary>

View file

@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion;
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
///
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
/// route never re-asserted it — status quo preserved (zero-behavior-change
/// slice), not a new deviation.</para>
/// <para><b>Activation.</b> Retail begins
/// <c>MovementManager::PerformMovement</c> (0x005240D0, call at 0x005240D9)
/// with an unconditional <c>CPhysicsObj::set_active(1)</c>, before validating
/// the movement type. <see cref="ActivatePhysicsObject"/> is the host seam for
/// that call. Static objects keep retail's no-op behavior in the host.</para>
/// </summary>
public sealed class MovementManager
{
@ -73,6 +73,14 @@ public sealed class MovementManager
/// <c>CPhysicsObj</c>.</summary>
public Func<MoveToManager>? MoveToFactory { get; set; }
/// <summary>
/// Host seam for retail's unconditional
/// <c>CPhysicsObj::set_active(1)</c> at the head of
/// <see cref="PerformMovement"/>. It is deliberately invoked even for an
/// invalid movement type.
/// </summary>
public Action? ActivatePhysicsObject { get; set; }
public MovementManager(MotionInterpreter minterp)
{
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
@ -102,6 +110,8 @@ public sealed class MovementManager
/// </summary>
public WeenieError PerformMovement(MovementStruct mvs)
{
ActivatePhysicsObject?.Invoke();
switch (mvs.Type)
{
case MovementType.RawCommand:

View file

@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// is set). Register row: releases a "stuck to object" sticky-manager
/// attachment — R5 wires the real StickyManager; until then this is an
/// optional callback the App layer may bind, matching the existing
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
/// <c>Action?</c> seams on this runtime owner.
/// </summary>
public Action? UnstickFromObject { get; set; }

View file

@ -59,7 +59,7 @@ public static class PhysicsObjUpdate
/// must therefore observe any velocity change made by the movement
/// callback; moving that callback after reflection changes the result.
/// </remarks>
public static void CommitSetPositionTransition(
public static bool CommitSetPositionTransition(
PhysicsBody body,
bool inContact,
bool onWalkable,
@ -68,7 +68,9 @@ public static class PhysicsObjUpdate
bool previousContact,
bool previousOnWalkable,
Action? hitGround = null,
Action? leaveGround = null)
Action? leaveGround = null,
Func<bool>? isCurrent = null,
Func<bool>? isVelocityCurrent = null)
{
ArgumentNullException.ThrowIfNull(body);
@ -95,11 +97,26 @@ public static class PhysicsObjUpdate
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (!previousOnWalkable && finalOnWalkable)
{
hitGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
else if (previousOnWalkable && !finalOnWalkable)
{
leaveGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
body.calc_acceleration();
// Position, Vector, and Movement are independently timestamped but
// can all install m_velocityVector. If a later one arrived from a
// callback above, retain its vector and finish the non-overlapping
// contact/pose commit without applying this older collision response.
if (isVelocityCurrent?.Invoke() == false)
return isCurrent?.Invoke() ?? true;
HandleAllCollisions(
body,
collisionNormalValid,
@ -107,6 +124,7 @@ public static class PhysicsObjUpdate
previousContact,
previousOnWalkable,
finalOnWalkable);
return isCurrent?.Invoke() ?? true;
}
/// <summary>

View file

@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult(
Vector3 CollisionNormal,
bool TransitionOk);
/// <summary>
/// Candidate frame produced by UpdatePhysicsInternal and held across retail's
/// process_hooks slot before the outer transition/collision commit.
/// </summary>
public readonly record struct ProjectileQuantumPreparation(
uint CellId,
float Quantum,
bool Simulated,
bool RequiresTransition,
Vector3 BeginPosition,
Quaternion BeginOrientation,
Position BeginCellPosition,
bool BeginInWorld,
ProjectileQuantumDynamics BeginDynamics,
Vector3 CandidatePosition,
Quaternion CandidateOrientation,
ProjectileQuantumDynamics CandidateDynamics,
bool PreviousContact,
bool PreviousOnWalkable);
/// <summary>
/// Mutable kinematic fields produced by the candidate integration. The split
/// App scheduler holds these transactionally across <c>process_hooks</c> so a
/// re-entrant authoritative correction never inherits an abandoned impulse.
/// </summary>
public readonly record struct ProjectileQuantumDynamics(
Vector3 Velocity,
Vector3 CachedVelocity,
Vector3 Acceleration,
Vector3 Omega,
TransientStateFlags TransientState,
double LastUpdateTime);
/// <summary>
/// Core-only port of the retail live-projectile physics driver. It owns no
/// renderer, network, or world-lifetime state: the App controller supplies a
@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper
transitionOk);
}
private void StepQuantum(
/// <summary>
/// Advances exactly one quantum already admitted by the owning
/// <see cref="RetailObjectQuantumClock"/>. This is the live-object path:
/// one CPhysicsObj clock admits PartArray, projectile physics, hooks, and
/// the manager tail together. The absolute-time overload remains for the
/// pure stepper conformance surface.
/// </summary>
public ProjectileAdvanceResult AdvanceQuantum(
PhysicsBody body,
float dt,
ref uint cellId,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId,
ref bool collisionNormalValid,
ref Vector3 collisionNormal,
ref bool transitionOk)
uint designatedTargetId = 0,
bool isParented = false)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
quantum,
cellId,
sphere,
isParented);
return CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
}
public ProjectileQuantumPreparation BeginQuantum(
PhysicsBody body,
float quantum,
uint cellId,
ProjectileCollisionSphere sphere,
bool isParented = false)
{
ArgumentNullException.ThrowIfNull(body);
if (!float.IsFinite(quantum)
|| quantum <= PhysicsGlobals.EPSILON
|| quantum > PhysicsBody.MaxQuantum)
{
throw new ArgumentOutOfRangeException(
nameof(quantum),
quantum,
"An admitted object quantum must be finite and no larger than MaxQuantum.");
}
if (!sphere.IsValid)
return NotSimulated(cellId, quantum);
if (isParented || !body.InWorld
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|| body.State.HasFlag(PhysicsStateFlags.Static)
|| cellId == 0)
{
body.TransientState &= ~TransientStateFlags.Active;
return NotSimulated(cellId, quantum);
}
if (!body.IsActive)
return NotSimulated(cellId, quantum);
Vector3 beginPosition = body.Position;
Quaternion beginOrientation = body.Orientation;
Position beginCellPosition = body.CellPosition;
bool beginInWorld = body.InWorld;
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// Final PhysicsState drives acceleration on every quantum. Network
// acceleration is retained by the protocol parser but not installed.
body.calc_acceleration();
body.UpdatePhysicsInternal(dt);
body.UpdatePhysicsInternal(quantum);
Vector3 candidatePosition = body.Position;
Quaternion candidateOrientation = body.Orientation;
Vector3 displacement = candidatePosition - beginPosition;
bool candidateMoved = displacement != Vector3.Zero;
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
{
candidateOrientation = RetailFrameMath.SetVectorHeading(
@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper
{
body.CachedVelocity = Vector3.Zero;
body.Orientation = candidateOrientation;
return;
body.LastUpdateTime += quantum;
}
else
{
// process_hooks runs against the post-physics candidate while the
// authoritative root remains at the begin frame until transition.
body.Position = beginPosition;
body.Orientation = beginOrientation;
}
// The integrator produces a candidate without committing the root.
// Restore the authoritative start frame before transition setup so
// carried cell-relative position and self-shadow identity remain exact.
body.Position = beginPosition;
body.Orientation = beginOrientation;
ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
RestoreBeginFrame(
body,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld);
ApplyDynamics(body, beginDynamics);
return new ProjectileQuantumPreparation(
cellId,
quantum,
Simulated: true,
RequiresTransition: candidateMoved,
beginPosition,
beginOrientation,
beginCellPosition,
beginInWorld,
beginDynamics,
candidatePosition,
candidateOrientation,
candidateDynamics,
previousContact,
previousOnWalkable);
}
public ProjectileAdvanceResult CompleteQuantum(
PhysicsBody body,
in ProjectileQuantumPreparation preparation,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId = 0)
{
ArgumentNullException.ThrowIfNull(body);
if (!preparation.Simulated)
return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true);
ApplyDynamics(body, preparation.CandidateDynamics);
if (!preparation.RequiresTransition)
{
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true);
}
uint cellId = preparation.CellId;
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
? ObjectInfoState.PathClipped
: ObjectInfoState.None;
var resolved = _physics.ResolveWithTransition(
beginPosition,
candidatePosition,
preparation.BeginPosition,
preparation.CandidatePosition,
cellId,
sphereRadius: sphere.Radius,
sphereHeight: 0f,
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
stepDownHeight: 0f,
isOnGround: previousOnWalkable,
isOnGround: preparation.PreviousOnWalkable,
body: body,
moverFlags: moverFlags,
movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin,
beginOrientation: beginOrientation,
endOrientation: candidateOrientation,
beginOrientation: preparation.BeginOrientation,
endOrientation: preparation.CandidateOrientation,
designatedTargetId: designatedTargetId);
bool collisionNormalValid = false;
Vector3 collisionNormal = default;
bool transitionOk = resolved.Ok;
if (!resolved.Ok)
{
// UpdateObjectInternal 0x005156B0: when transition() returns null,
// retail calls set_frame(candidate), zeros cached_velocity, and
// deliberately skips SetPositionInternal/handle_all_collisions.
// set_frame retains Position.objcell_id even when the candidate
// frame lies beyond the current outdoor cell's canonical range.
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
body.SetFrameInCurrentCell(
preparation.CandidatePosition,
preparation.CandidateOrientation);
body.CachedVelocity = Vector3.Zero;
transitionOk = false;
return;
}
body.CachedVelocity = (resolved.Position - beginPosition) / dt;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? candidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
// SetPositionInternal 0x00515330 commits Contact and OnWalkable as
// distinct facts before handle_all_collisions. A steep surface can be
// contact without becoming walkable ground.
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
else
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
/ preparation.Quantum;
body.Position = resolved.Position;
body.Orientation = resolved.Orientation == default
? preparation.CandidateOrientation
: resolved.Orientation;
if (resolved.CellId != 0)
cellId = resolved.CellId;
PhysicsObjUpdate.ApplySetPositionContact(
body,
resolved.InContact,
resolved.OnWalkable);
PhysicsObjUpdate.HandleAllCollisions(
body,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
preparation.PreviousContact,
preparation.PreviousOnWalkable,
nowOnWalkable: body.OnWalkable);
if (resolved.CollisionNormalValid)
{
collisionNormalValid = true;
collisionNormal = resolved.CollisionNormal;
}
}
body.LastUpdateTime += preparation.Quantum;
return new ProjectileAdvanceResult(
cellId,
1,
true,
collisionNormalValid,
collisionNormal,
transitionOk);
}
private static ProjectileQuantumPreparation NotSimulated(
uint cellId,
float quantum) => new(
CellId: cellId,
Quantum: quantum,
Simulated: false,
RequiresTransition: false,
BeginPosition: default,
BeginOrientation: default,
BeginCellPosition: default,
BeginInWorld: false,
BeginDynamics: default,
CandidatePosition: default,
CandidateOrientation: default,
CandidateDynamics: default,
PreviousContact: false,
PreviousOnWalkable: false);
private static ProjectileQuantumDynamics CaptureDynamics(PhysicsBody body) => new(
body.Velocity,
body.CachedVelocity,
body.Acceleration,
body.Omega,
body.TransientState,
body.LastUpdateTime);
private static void ApplyDynamics(
PhysicsBody body,
in ProjectileQuantumDynamics dynamics)
{
body.Velocity = dynamics.Velocity;
body.CachedVelocity = dynamics.CachedVelocity;
body.Acceleration = dynamics.Acceleration;
body.Omega = dynamics.Omega;
body.TransientState = dynamics.TransientState;
body.LastUpdateTime = dynamics.LastUpdateTime;
}
private static void RestoreBeginFrame(
PhysicsBody body,
Vector3 beginPosition,
Quaternion beginOrientation,
in Position beginCellPosition,
bool beginInWorld)
{
body.Orientation = beginOrientation;
if (beginCellPosition.ObjCellId != 0)
{
body.SnapToCell(
beginCellPosition.ObjCellId,
beginPosition,
beginCellPosition.Frame.Origin);
}
else
{
body.SetFrameInCurrentCell(beginPosition, beginOrientation);
}
body.InWorld = beginInWorld;
}
private void StepQuantum(
PhysicsBody body,
float dt,
ref uint cellId,
ProjectileCollisionSphere sphere,
uint movingEntityId,
uint designatedTargetId,
ref bool collisionNormalValid,
ref Vector3 collisionNormal,
ref bool transitionOk)
{
ProjectileQuantumPreparation preparation = BeginQuantum(
body,
dt,
cellId,
sphere);
ProjectileAdvanceResult result = CompleteQuantum(
body,
preparation,
sphere,
movingEntityId,
designatedTargetId);
if (result.CellId != 0)
cellId = result.CellId;
collisionNormalValid |= result.CollisionNormalValid;
if (result.CollisionNormalValid)
collisionNormal = result.CollisionNormal;
transitionOk &= result.TransitionOk;
}
}

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@ -7,10 +8,10 @@ namespace AcDream.Core.Physics;
/// by <c>CSequence::update</c> + InterpolationManager catch-up correction.
/// Pure function — no side effects or hidden state.
///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
/// Mirrors retail <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30):
/// CPartArray writes one complete local Frame, then PositionManager mutates
/// that same Frame. Active interpolation replaces it with
/// <c>Position::subtract2</c>; later managers receive the result.
///
/// The animation root motion is the complete body-local <c>Frame.Origin</c>
/// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the
@ -30,6 +31,50 @@ namespace AcDream.Core.Physics;
/// </summary>
public sealed class RemoteMotionCombiner
{
/// <summary>
/// Compose retail's complete per-object delta frame. Interpolation, when
/// active, replaces the PartArray frame via
/// <c>Position::subtract2</c>; otherwise the authored root frame remains.
/// </summary>
/// <returns><c>true</c> when interpolation replaced the root frame.</returns>
public bool ComposeOffset(
double dt,
Vector3 currentBodyPosition,
Quaternion ori,
MotionDeltaFrame rootMotionLocalFrame,
InterpolationManager interp,
float maxSpeed,
MotionDeltaFrame output,
Vector3? terrainNormal = null,
bool inContact = true)
{
ArgumentNullException.ThrowIfNull(rootMotionLocalFrame);
ArgumentNullException.ThrowIfNull(interp);
ArgumentNullException.ThrowIfNull(output);
output.Origin = rootMotionLocalFrame.Origin;
output.Orientation = rootMotionLocalFrame.Orientation;
bool interpolationOverwrote = interp.AdjustOffset(
dt,
currentBodyPosition,
ori,
maxSpeed,
output,
inContact);
if (!interpolationOverwrote
&& terrainNormal.HasValue
&& terrainNormal.Value.Z > 0.01f)
{
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
Vector3 normal = terrainNormal.Value;
rootMotionWorld -= normal * Vector3.Dot(rootMotionWorld, normal);
output.Origin = MoveToMath.GlobalToLocalVec(ori, rootMotionWorld);
}
return interpolationOverwrote;
}
/// <summary>
/// Compute the per-frame world-space delta to add to body.Position.
/// </summary>
@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori);
var root = new MotionDeltaFrame
{
Origin = rootMotionLocalDelta,
};
var output = new MotionDeltaFrame();
ComposeOffset(
dt,
currentBodyPosition,
ori,
root,
interp,
maxSpeed,
output,
terrainNormal: null);
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
// Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays

View file

@ -0,0 +1,101 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Ports the lifecycle and 96-world-unit Active gate at the head of retail
/// <c>CPhysicsObj::update_object</c> (0x00515D10) plus the inactive branch of
/// <c>UpdateObjectInternal</c> (0x005156B0).
/// </summary>
public static class RetailObjectActivityGate
{
public const float MaxPhysicsDistance = 96f;
public static RetailObjectActivityResult Evaluate(
RetailObjectQuantumClock clock,
PhysicsBody? body,
bool lifecycleEligible,
bool hasPartArray,
bool isStatic,
Vector3 objectPosition,
Vector3? playerPosition,
double elapsedSeconds)
{
ArgumentNullException.ThrowIfNull(clock);
if (!lifecycleEligible)
{
SetActive(clock, body, active: false);
return RetailObjectActivityResult.Suspended;
}
// Retail performs both the 96-unit decision and set_active(1) only
// while player_object exists. During login/session transitions a
// missing player preserves the prior Active state; it must not wake a
// previously distant object.
if (playerPosition is null)
{
if (clock.IsActive)
return RetailObjectActivityResult.Active;
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
bool withinActiveBubble = !hasPartArray
|| Vector3.Distance(objectPosition, playerPosition.Value)
<= MaxPhysicsDistance;
if (!withinActiveBubble)
{
SetActive(clock, body, active: false);
// Inactive ordinary objects still consume update_time and run
// their particle/script tail. Those owners tick elsewhere in
// acdream; consuming the batch here prevents later catch-up.
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
// CPhysicsObj::set_active(1) (0x0050FC40) is a no-op for Static.
// Static objects that were initialized or removed from visibility
// inactive therefore stay on UpdateObjectInternal's particle/script
// tail instead of entering root physics.
bool reactivated = false;
if (!isStatic)
{
reactivated = clock.Activate();
if (body is not null)
body.TransientState |= TransientStateFlags.Active;
}
if (!clock.IsActive)
{
clock.Advance(elapsedSeconds);
return RetailObjectActivityResult.Inactive;
}
return reactivated
? RetailObjectActivityResult.Reactivated
: RetailObjectActivityResult.Active;
}
private static void SetActive(
RetailObjectQuantumClock clock,
PhysicsBody? body,
bool active)
{
if (active)
clock.Activate();
else
clock.Deactivate();
if (body is not null && !active)
body.TransientState &= ~TransientStateFlags.Active;
}
}
public enum RetailObjectActivityResult
{
Suspended,
Inactive,
Reactivated,
Active,
}

View file

@ -12,6 +12,40 @@ namespace AcDream.Core.Physics;
/// </remarks>
public static class RetailObjectManagerTail
{
/// <summary>
/// Allocation-free production overload for the concrete retail manager
/// owners. DetectionManager is not ported, so its ordered slot is empty.
/// </summary>
public static void Run(
Motion.TargetManager? target,
Motion.MovementManager? movement,
Motion.MotionTableManager? partArray,
Motion.PositionManager? position)
{
target?.HandleTargetting();
movement?.UseTime();
partArray?.UseTime();
position?.UseTime();
}
/// <summary>
/// Allocation-free local-player overload. Its target action and PartArray
/// completion action are cached ownership seams; the other managers are
/// passed as concrete objects rather than allocating bound delegates per
/// quantum.
/// </summary>
public static void Run(
Action? handleTargeting,
Motion.MovementManager? movement,
Action? partArrayHandleMovement,
Motion.PositionManager? position)
{
handleTargeting?.Invoke();
movement?.UseTime();
partArrayHandleMovement?.Invoke();
position?.UseTime();
}
public static void Run(
Action? checkDetection,
Action? handleTargeting,

View file

@ -0,0 +1,122 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Allocation-free port of <c>CPhysicsObj::update_object</c>
/// (0x00515D10). One clock belongs to one logical physics object. It retains
/// sub-minimum elapsed time, subdivides long frames into complete object
/// updates, and discards stale gaps greater than retail's huge quantum.
/// </summary>
public sealed class RetailObjectQuantumClock
{
private double _pending;
public double PendingSeconds => _pending;
public bool IsActive { get; private set; } = true;
public RetailObjectQuantumBatch Advance(double elapsedSeconds)
{
if (double.IsNaN(elapsedSeconds) || elapsedSeconds < 0.0)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
double elapsed = _pending + elapsedSeconds;
if (elapsed <= FrameEpsilon)
{
_pending = 0.0;
return default;
}
if (elapsed > PhysicsBody.HugeQuantum)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
int fullSteps = 0;
while (elapsed > PhysicsBody.MaxQuantum)
{
fullSteps++;
elapsed -= PhysicsBody.MaxQuantum;
}
float remainder = 0f;
if (elapsed > PhysicsBody.MinQuantum)
{
remainder = (float)elapsed;
elapsed = 0.0;
}
_pending = elapsed;
return new RetailObjectQuantumBatch(fullSteps, remainder, Discarded: false);
}
/// <summary>
/// Retail <c>set_active(0)</c>: suppress the full object path without
/// advancing or rebasing <c>update_time</c>.
/// </summary>
public void Deactivate() => IsActive = false;
/// <summary>
/// Retail <c>set_active(1)</c>. An inactive-to-active edge rebases
/// <c>update_time</c> to the current timer, so the reactivation frame does
/// not catch up suppressed time.
/// </summary>
/// <returns>True only when this call performed the reactivation edge.</returns>
public bool Activate()
{
if (IsActive)
return false;
IsActive = true;
_pending = 0.0;
return true;
}
public void Reset() => _pending = 0.0;
/// <summary>
/// Retail <c>CPhysicsObj::prepare_to_enter_world</c> (0x00511FA0): rebase
/// <c>update_time</c> to the current timer and immediately set Active when
/// the object is not Static. A Static object retains its prior Active bit;
/// normal initial entry and re-entry arrive here inactive. The next elapsed
/// frame of a non-Static object is therefore eligible for ordinary object-
/// quantum admission; there is no second activation frame to discard.
/// </summary>
public void ResetForEnterWorld(bool isStatic = false)
{
_pending = 0.0;
if (!isStatic)
IsActive = true;
}
private const double FrameEpsilon = 0.000199999995;
}
/// <summary>
/// How the owning live-object lifecycle treats this render frame before
/// entering retail <c>CPhysicsObj::update_object</c>.
/// </summary>
public enum RetailObjectClockDisposition
{
Advance,
Suspend,
}
/// <summary>Compact result of one retail object-clock admission pass.</summary>
public readonly record struct RetailObjectQuantumBatch(
int FullSteps,
float Remainder,
bool Discarded)
{
public int Count => FullSteps + (Remainder > 0f ? 1 : 0);
public float GetQuantum(int index)
{
if ((uint)index >= (uint)Count)
throw new ArgumentOutOfRangeException(nameof(index));
return index < FullSteps ? PhysicsBody.MaxQuantum : Remainder;
}
}