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:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue