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

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