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

@ -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: