acdream/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs
Erik f961d70023 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>
2026-07-20 09:10:31 +02:00

159 lines
5.4 KiB
C#

using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// 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).
/// </summary>
public class MotionTableDispatchSinkTests
{
private const uint NC = 0x8000003Du;
private const uint Ready = 0x41000003u;
private const uint Walk = 0x45000005u;
private const uint TurnRight = 0x6500000Du;
private const uint ReadyAnim = 0x200u;
private const uint WalkAnim = 0x201u;
private sealed class Loader : IAnimationLoader
{
private readonly System.Collections.Generic.Dictionary<uint, Animation> _anims = new();
public void Register(uint id, Animation anim) => _anims[id] = anim;
public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null;
}
private static Animation MakeAnim(int frames)
{
var anim = new Animation();
for (int f = 0; f < frames; f++)
{
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
return anim;
}
private static MotionData MakeMd(uint animId, Vector3? omega = null)
{
var md = new MotionData();
QualifiedDataId<Animation> qid = animId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
if (omega is { } o)
{
md.Omega = o;
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
}
return md;
}
private static AnimationSequencer MakeSequencer(bool withTurnModifier = true)
{
var setup = new Setup();
setup.Parts.Add(0x01000000u);
setup.DefaultScale.Add(Vector3.One);
var loader = new Loader();
loader.Register(ReadyAnim, MakeAnim(4));
loader.Register(WalkAnim, MakeAnim(6));
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(ReadyAnim);
mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(WalkAnim);
if (withTurnModifier)
{
mt.Modifiers[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] =
MakeMd(ReadyAnim, omega: new Vector3(0f, 0f, 1.2f));
}
return new AnimationSequencer(setup, mt, loader);
}
[Fact]
public void ApplyMotion_CycleClass_InstallsSubstate_LazyInitialized()
{
var seq = MakeSequencer();
var sink = new MotionTableDispatchSink(seq);
// Fresh sequencer: the first dispatch lazily runs initialize_state
// (retail lazy-create, r3-motioninterp-decomp §6g) then installs
// the requested substate.
sink.ApplyMotion(Walk, 2.0f);
Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal(2.0f, seq.CurrentSpeedMod);
// add_motion preserves the literal zero MotionData velocity; body
// speed belongs to CMotionInterp, not CSequence.
Assert.Equal(Vector3.Zero, seq.CurrentVelocity);
}
[Fact]
public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega()
{
var seq = MakeSequencer();
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f);
// 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(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void StopMotion_Turn_UnwindsModifierPhysics()
{
var seq = MakeSequencer();
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f);
sink.StopMotion(TurnRight);
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
// omega + modifier unlinked.
Assert.Equal(0f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void StopMotion_CurrentSubstate_ReDrivesToStyleDefault()
{
var seq = MakeSequencer();
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
sink.StopMotion(Walk);
// StopSequenceMotion Case A: stopping the active cycle re-drives
// GetObjectSequence toward the style default (Ready).
Assert.Equal(Ready, seq.CurrentMotion);
}
[Fact]
public void ApplyMotion_MissingEverywhere_NoOp_DefaultKeepsPlaying()
{
var seq = MakeSequencer(withTurnModifier: false);
var sink = new MotionTableDispatchSink(seq);
sink.ApplyMotion(Walk, 1.0f);
// 0x44000007 RunForward: no cycle, no modifier -> dispatch fails,
// sequence + state untouched (no fallback chain — H4/H5).
sink.ApplyMotion(0x44000007u, 2.0f);
Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal(1.0f, seq.CurrentSpeedMod);
}
}