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

@ -0,0 +1,163 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// Conformance coverage for retail <c>Frame::combine</c> (0x005122E0).
/// The production object tick accumulates one or more CSequence frames before
/// composing that delta onto the live body, so translation and orientation
/// must remain one indivisible transform.
/// </summary>
public sealed class MotionDeltaFrameTests
{
[Fact]
public void Combine_MatchesRetailFrameOps_ForTranslationAndOrientation()
{
Quaternion baseRotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
Quaternion deltaRotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 4f);
var expected = new Frame
{
Origin = new Vector3(10f, 20f, 30f),
Orientation = baseRotation,
};
FrameOps.Combine(expected, new Frame
{
Origin = new Vector3(2f, 0f, 1f),
Orientation = deltaRotation,
});
var actual = new MotionDeltaFrame
{
Origin = new Vector3(10f, 20f, 30f),
Orientation = baseRotation,
};
actual.Combine(
new Vector3(2f, 0f, 1f),
deltaRotation);
AssertVectorEqual(expected.Origin, actual.Origin);
AssertQuaternionEquivalent(expected.Orientation, actual.Orientation);
}
[Fact]
public void Combine_SequentialFrames_RotatesLaterTranslationByEarlierTurn()
{
var accumulated = new MotionDeltaFrame();
accumulated.Combine(
Vector3.Zero,
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
accumulated.Combine(
new Vector3(0f, 2f, 0f),
Quaternion.Identity);
AssertVectorEqual(new Vector3(-2f, 0f, 0f), accumulated.Origin);
AssertQuaternionEquivalent(
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f),
accumulated.Orientation);
}
[Fact]
public void Combine_NonCommutingRotations_UsesRetailPostMultiplyOrder()
{
Quaternion aroundX = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f);
Quaternion aroundY = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f);
var accumulated = new MotionDeltaFrame
{
Orientation = aroundX,
};
accumulated.Combine(Vector3.Zero, aroundY);
AssertQuaternionEquivalent(
FrameOps.SetRotate(Vector3.Zero, aroundX, aroundX * aroundY),
accumulated.Orientation);
Assert.True(MathF.Abs(Quaternion.Dot(
accumulated.Orientation,
Quaternion.Normalize(aroundY * aroundX))) < 0.999f);
}
[Theory]
[MemberData(nameof(InvalidRotations))]
public void Combine_InvalidCandidateRotation_RestoresPreviousQuaternion(
Quaternion invalid)
{
Quaternion previous = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.35f);
var accumulated = new MotionDeltaFrame
{
Orientation = previous,
};
accumulated.Combine(Vector3.Zero, invalid);
AssertQuaternionEquivalent(previous, accumulated.Orientation);
}
[Fact]
public void Combine_NearZeroNonzeroQuaternion_NormalizesLikeRetail()
{
var accumulated = new MotionDeltaFrame();
var tiny = new Quaternion(1e-30f, 0f, 0f, 0f);
accumulated.Combine(Vector3.Zero, tiny);
AssertQuaternionEquivalent(
Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI),
accumulated.Orientation);
}
[Fact]
public void Combine_OriginScale_DoesNotScaleRotation()
{
var accumulated = new MotionDeltaFrame();
Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 3f);
accumulated.Combine(new Vector3(0f, 0.5f, 0f), turn, originScale: 2f);
AssertVectorEqual(new Vector3(0f, 1f, 0f), accumulated.Origin);
AssertQuaternionEquivalent(turn, accumulated.Orientation);
}
[Fact]
public void Reset_RestoresIdentityFrame()
{
var frame = new MotionDeltaFrame
{
Origin = Vector3.One,
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.5f),
};
frame.Reset();
Assert.Equal(Vector3.Zero, frame.Origin);
Assert.Equal(Quaternion.Identity, frame.Orientation);
}
private static void AssertVectorEqual(Vector3 expected, Vector3 actual)
{
Assert.Equal(expected.X, actual.X, 5);
Assert.Equal(expected.Y, actual.Y, 5);
Assert.Equal(expected.Z, actual.Z, 5);
}
private static void AssertQuaternionEquivalent(Quaternion expected, Quaternion actual)
{
float alignment = MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(expected),
Quaternion.Normalize(actual)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
}
public static TheoryData<Quaternion> InvalidRotations => new()
{
Quaternion.Zero,
new Quaternion(float.NaN, 0f, 0f, 1f),
new Quaternion(float.PositiveInfinity, 0f, 0f, 1f),
};
}

View file

@ -13,8 +13,7 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// R2-Q5 — <see cref="MotionTableDispatchSink"/>: the funnel's dispatches go
/// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis
/// collection, no priority pick, no fallback chain — GetObjectSequence
/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped
/// ObservedOmega seam.
/// 0x00522860 + is_allowed decide).
/// </summary>
public class MotionTableDispatchSinkTests
{
@ -100,15 +99,10 @@ public class MotionTableDispatchSinkTests
}
[Fact]
public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange()
public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega()
{
var seq = MakeSequencer();
uint? turnMotion = null;
float turnSpeed = 0f;
var sink = new MotionTableDispatchSink(seq)
{
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
};
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f);
@ -116,22 +110,19 @@ public class MotionTableDispatchSinkTests
// The AP-73 mechanism for real: substate cycle untouched, the turn
// is a MotionState modifier with its dat omega combined.
Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics()
public void StopMotion_Turn_UnwindsModifierPhysics()
{
var seq = MakeSequencer();
bool stopped = false;
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f);
sink.StopMotion(TurnRight);
Assert.True(stopped);
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
// omega + modifier unlinked.
Assert.Equal(0f, seq.CurrentOmega.Z, 3);

View file

@ -16,12 +16,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
///
/// <para>
/// <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 wire convention. <see cref="MoveToMath.GetHeading"/> must
/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per
/// <b>The scalar-convention trap (V0-pins §P5 correction):</b>
/// <see cref="MoveToMath.GetHeading"/> must use the correct scalar bridge
/// from acdream yaw (yaw=0 faces +X, per
/// <c>PlayerMovementController.cs:1022-1025</c>): <c>heading = (90 -
/// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>.
/// </para>

View file

@ -164,6 +164,27 @@ public sealed class MovementManagerTests
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_ActivatesBeforeDispatch_EvenWhenTypeIsInvalid()
{
var (mm, _, _) = MakeFacade();
int activations = 0;
mm.ActivatePhysicsObject = () => activations++;
Assert.Equal(WeenieError.None,
mm.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
}));
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
}));
Assert.Equal(2, activations);
}
[Fact]
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
{

View file

@ -21,9 +21,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
// the scalar) and drains pending_motions synthetically, so the two legs where
// the live #170 residual actually lives have ZERO coverage:
//
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink.
// TurnApplied → ObservedOmega → per-tick quaternion integration
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test;
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink
// DAT MotionData.Omega → CSequence's complete root Frame
// Frame::combine → HandleTurnToHeading's HeadingGreater test;
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by
// CSequence AnimDone hooks (link-anim completions), popping
// CMotionInterp.pending_motions via the MotionDoneTarget seam.
@ -31,11 +31,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
// This harness wires a real MotionInterpreter + AnimationSequencer +
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo →
// 10024 get_state_velocity refresh → 10050 manual omega integration →
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
// the retail per-object order: CSequence update → PositionManager adjust →
// complete Frame combine → hook processing → Target/Movement/PartArray/
// Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack
// UMs) replay the exact live sequence captured in launch-drainq.log
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player).
//
@ -118,9 +116,6 @@ internal sealed class RemoteChaseHarness
private readonly CreatureHost _creatureHost;
private readonly TargetHost _playerHost;
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
public Vector3 ObservedOmega;
/// <summary>Scripted player (chase target) world position.</summary>
public Vector3 PlayerPos;
public Vector3 PlayerVelocity;
@ -162,17 +157,7 @@ internal sealed class RemoteChaseHarness
};
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
Sink = new MotionTableDispatchSink(Seq)
{
TurnApplied = (turnMotion, turnSpeed) =>
{
float signed = (turnMotion & 0xFFu) == 0x0E
? -MathF.Abs(turnSpeed)
: turnSpeed;
ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed);
},
TurnStopped = () => ObservedOmega = Vector3.Zero,
};
Sink = new MotionTableDispatchSink(Seq);
Interp.DefaultSink = Sink;
// #174: production binds the seam to Manager.HandleEnterWorld
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
@ -425,64 +410,52 @@ internal sealed class RemoteChaseHarness
// Player (chase target) moves per its scripted velocity.
PlayerPos += PlayerVelocity * Dt;
// 1. Voyeur delivery (player host HandleTargetting → this entity's
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote
// loop, 9697 in-loop).
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo();
// 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade
// relay, MovementManager::UseTime 0x005242f0).
Movement.UseTime();
// 3. get_state_velocity → body velocity (the d2ccc80e refresh,
// GameWindow.cs:10024).
if (Body.OnWalkable)
Body.set_local_velocity(Interp.get_state_velocity(), false);
// 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim).
if (ObservedOmega.LengthSquared() > 1e-8f)
// CPartArray::Update emits one complete local Frame: authored PosFrames
// plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0).
var rootFrame = new Frame
{
float omegaMag = ObservedOmega.Length();
var axis = ObservedOmega / omegaMag;
float angle = omegaMag * Dt;
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle);
Body.Orientation = Quaternion.Normalize(
Quaternion.Multiply(Body.Orientation, deltaRot));
}
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
};
Seq.Advance(Dt, rootFrame);
// 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot
// (retail UpdatePositionInternal PositionManager::adjust_offset
// @0x00512d0e, composed BEFORE the velocity integration). Origin is
// mover-local; the rotation carries a RELATIVE heading (identity =
// untouched = no turn).
var pmDelta = new MotionDeltaFrame();
// PositionManager receives that same Frame and may replace its motion
// (sticky/interpolation) before Frame::combine applies translation with
// the OLD orientation, then post-multiplies the relative orientation.
var pmDelta = new MotionDeltaFrame
{
Origin = rootFrame.Origin,
Orientation = rootFrame.Orientation,
};
Pm.AdjustOffset(pmDelta, Dt);
if (pmDelta.Origin != Vector3.Zero)
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Body.Orientation = MoveToMath.SetHeading(
Body.Orientation,
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
Body.Orientation = Quaternion.Normalize(
Body.Orientation * pmDelta.Orientation);
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded,
// no gravity participation for this scenario).
Body.Position += Body.Velocity * Dt;
// The fixture has no gravity or independent physical velocity, but run
// the same physics primitive so a future fixture cannot accidentally
// bypass the retail slot.
Body.calc_acceleration();
Body.UpdatePhysicsInternal(Dt);
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal
// tail @0x005159b3) — the sticky 1 s lease watchdog.
Pm.UseTime();
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
// hooks into the manager countdown, zero-tick sweep.
Seq.Advance(Dt);
// process_hooks precedes the manager tail. AnimationDone decrements the
// exact pending-animation node that owns the transition.
var hooks = Seq.ConsumePendingHooks();
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
Seq.Manager.AnimationDone(success: true);
}
// UpdateObjectInternal manager tail: Target → Movement → PartArray →
// Position. A movement selected here contributes to next tick's Frame.
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo();
Movement.UseTime();
Seq.Manager.UseTime();
Pm.UseTime();
// ── Observables ──
uint substate = Seq.Manager.State.Substate;
@ -509,7 +482,7 @@ internal sealed class RemoteChaseHarness
public void Snapshot(string tag)
{
string line = FormattableString.Invariant(
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}");
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={Seq.CurrentOmega.Z,6:F3}");
Trace.Add(line);
_log?.WriteLine(line);
}
@ -627,7 +600,11 @@ internal sealed class RemoteChaseHarness
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
// Global (unstyled) TurnRight modifier — physics-only in Branch 4.
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim);
// The signed DAT omega is the rotation source, matching retail's
// Humanoid motion table; no command-derived side channel exists.
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(
TurnAnim,
omega: new Vector3(0f, 0f, -1.5f));
return (setup, mt, loader);
}
@ -646,7 +623,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
/// The core chase cycle: aggro stance change, one mt-6 arm at a target
/// 90° off the creature's facing, 15 m away, stationary. Retail bar: the
/// stance links play out (~1 s), the chase turn starts, completes at the
/// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
/// authored turn rate (90° at 1.5·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
/// installs the forward cycle. Total budget: 4 s of ticks is generous.
/// </summary>
[Theory]
@ -692,7 +669,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
Assert.True(installTick >= 0,
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " +
$"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}");
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.Seq.CurrentOmega.Z:F3}");
Assert.True(installTick <= Seconds(4f),
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
"retail installs within the turn duration (~1-2 s)");