acdream/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs
Erik 124e046976 fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 08:48:27 +02:00

168 lines
5.8 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) with the TurnApplied/TurnStopped
/// ObservedOmega seam.
/// </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_FiresTurnApplied_NoCycleChange()
{
var seq = MakeSequencer();
uint? turnMotion = null;
float turnSpeed = 0f;
var sink = new MotionTableDispatchSink(seq)
{
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
};
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((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics()
{
var seq = MakeSequencer();
bool stopped = false;
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
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);
}
[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);
}
}