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>
388 lines
16 KiB
C#
388 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using System.Text;
|
|
using AcDream.Core.Physics;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// R2-Q4 adapter-cutover trace conformance (r2-port-plan.md §3 Q4).
|
|
//
|
|
// These scenarios were CAPTURED against the PRE-cutover adapter (the legacy
|
|
// SetCycle/PlayAction with the fast-path, Fix B, stop-anim fallback, and G17
|
|
// gate) at commit aa65990a, then replayed against the PerformMovement-hosted
|
|
// adapter. Golden strings assert the post-Q4 behavior; every place the cutover
|
|
// INTENTIONALLY changed the outcome carries an `EXPECTED-DIFF(Q4)` comment
|
|
// with the pre-cutover value and the retail rationale. Everything without an
|
|
// annotation is byte-identical across the cutover — that is the parity bar.
|
|
//
|
|
// Snapshot format (Describe): comma-joined node list in queue order, each node
|
|
// `<animIdHex>@<framerate:F1>` with suffix `*` = first_cyclic, `^` = curr_anim;
|
|
// then ` | frame=<F1> vel=(x,y,z F2) om=(x,y,z F2) style=<X8> motion=<X8>
|
|
// mod=<F2>`.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
internal sealed class TraceLoader : IAnimationLoader
|
|
{
|
|
private readonly Dictionary<uint, Animation> _anims = new();
|
|
private readonly Dictionary<Animation, uint> _ids = new();
|
|
|
|
public void Register(uint id, Animation anim)
|
|
{
|
|
_anims[id] = anim;
|
|
_ids[anim] = id;
|
|
}
|
|
|
|
public Animation? LoadAnimation(uint id) =>
|
|
_anims.TryGetValue(id, out var a) ? a : null;
|
|
|
|
public uint IdOf(Animation anim) => _ids.TryGetValue(anim, out var id) ? id : 0;
|
|
}
|
|
|
|
public sealed class AnimationSequencerCutoverTraceTests
|
|
{
|
|
// Styles (FULL command words, as GameWindow passes them).
|
|
private const uint NC = 0x8000003Du; // NonCombat
|
|
private const uint Style2 = 0x8000004Cu; // synthetic second style
|
|
|
|
// Substates / cycles.
|
|
private const uint Ready = 0x41000003u;
|
|
private const uint Walk = 0x45000005u;
|
|
private const uint WalkBack = 0x45000006u;
|
|
private const uint Run = 0x44000007u;
|
|
private const uint Falling = 0x40000015u;
|
|
|
|
// Action / modifier class ids.
|
|
private const uint EmoteAction = 0x10000062u;
|
|
private const uint TurnMod = 0x6500000Du;
|
|
|
|
// Anim resource ids.
|
|
private const uint ReadyAnim = 0x100u; // 4 frames
|
|
private const uint WalkAnim = 0x101u; // 6 frames
|
|
private const uint RunAnim = 0x102u; // 6 frames
|
|
private const uint ReadyToWalkLink = 0x103u; // 2 frames
|
|
private const uint ReadyToRunLink = 0x104u; // 2 frames
|
|
private const uint WalkToReadyLink = 0x105u; // 3 frames
|
|
private const uint RunToReadyLink = 0x108u; // 3 frames
|
|
private const uint TurnModAnim = 0x109u; // 2 frames
|
|
private const uint ReadyToStyle2Link = 0x10Au; // 2 frames
|
|
private const uint ReadyToEmoteLink = 0x10Bu; // 5 frames
|
|
private const uint FallAnim = 0x10Cu; // 4 frames
|
|
private const uint Style2ReadyAnim = 0x107u; // 4 frames
|
|
|
|
private static Animation MakeAnim(int numFrames, int numParts = 1)
|
|
{
|
|
var anim = new Animation();
|
|
for (int f = 0; f < numFrames; f++)
|
|
{
|
|
var pf = new AnimationFrame((uint)numParts);
|
|
for (int p = 0; p < numParts; p++)
|
|
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
|
|
anim.PartFrames.Add(pf);
|
|
}
|
|
return anim;
|
|
}
|
|
|
|
private static MotionData MakeMd(uint animId, float framerate = 30f,
|
|
Vector3? velocity = null, Vector3? omega = null)
|
|
{
|
|
var md = new MotionData();
|
|
QualifiedDataId<Animation> qid = animId;
|
|
md.Anims.Add(new AnimData
|
|
{
|
|
AnimId = qid,
|
|
LowFrame = 0,
|
|
HighFrame = -1,
|
|
Framerate = framerate,
|
|
});
|
|
if (velocity is { } v)
|
|
{
|
|
md.Velocity = v;
|
|
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity;
|
|
}
|
|
if (omega is { } o)
|
|
{
|
|
md.Omega = o;
|
|
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
|
|
}
|
|
return md;
|
|
}
|
|
|
|
private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md)
|
|
{
|
|
int outer = (int)((style << 16) | (from & 0xFFFFFFu));
|
|
if (!mt.Links.TryGetValue(outer, out var cmd))
|
|
{
|
|
cmd = new MotionCommandData();
|
|
mt.Links[outer] = cmd;
|
|
}
|
|
cmd.MotionData[(int)to] = md;
|
|
}
|
|
|
|
private static (Setup, MotionTable, TraceLoader) BuildFixture()
|
|
{
|
|
var setup = new Setup();
|
|
setup.Parts.Add(0x01000000u);
|
|
setup.DefaultScale.Add(Vector3.One);
|
|
|
|
var loader = new TraceLoader();
|
|
loader.Register(ReadyAnim, MakeAnim(4));
|
|
loader.Register(WalkAnim, MakeAnim(6));
|
|
loader.Register(RunAnim, MakeAnim(6));
|
|
loader.Register(ReadyToWalkLink, MakeAnim(2));
|
|
loader.Register(ReadyToRunLink, MakeAnim(2));
|
|
loader.Register(WalkToReadyLink, MakeAnim(3));
|
|
loader.Register(RunToReadyLink, MakeAnim(3));
|
|
loader.Register(TurnModAnim, MakeAnim(2));
|
|
loader.Register(ReadyToStyle2Link, MakeAnim(2));
|
|
loader.Register(ReadyToEmoteLink, MakeAnim(5));
|
|
loader.Register(FallAnim, MakeAnim(4));
|
|
loader.Register(Style2ReadyAnim, MakeAnim(4));
|
|
|
|
var mt = new MotionTable
|
|
{
|
|
DefaultStyle = (DRWMotionCommand)NC,
|
|
};
|
|
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
|
|
mt.StyleDefaults[(DRWMotionCommand)Style2] = (DRWMotionCommand)Ready;
|
|
|
|
int CycleKey(uint style, uint substate) => (int)((style << 16) | (substate & 0xFFFFFFu));
|
|
mt.Cycles[CycleKey(NC, Ready)] = MakeMd(ReadyAnim);
|
|
mt.Cycles[CycleKey(NC, Walk)] = MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f));
|
|
mt.Cycles[CycleKey(NC, Run)] = MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f));
|
|
mt.Cycles[CycleKey(NC, Falling)] = MakeMd(FallAnim);
|
|
mt.Cycles[CycleKey(Style2, Ready)] = MakeMd(Style2ReadyAnim);
|
|
|
|
AddLink(mt, NC, Ready, Walk, MakeMd(ReadyToWalkLink));
|
|
AddLink(mt, NC, Ready, Run, MakeMd(ReadyToRunLink));
|
|
AddLink(mt, NC, Walk, Ready, MakeMd(WalkToReadyLink));
|
|
AddLink(mt, NC, Run, Ready, MakeMd(RunToReadyLink));
|
|
AddLink(mt, NC, Ready, EmoteAction, MakeMd(ReadyToEmoteLink));
|
|
AddLink(mt, NC, Ready, Style2, MakeMd(ReadyToStyle2Link));
|
|
|
|
// Modifier: styled key (styleMasked<<16 | low24).
|
|
int modKey = (int)((NC << 16) | (TurnMod & 0xFFFFFFu));
|
|
mt.Modifiers[modKey] = MakeMd(TurnModAnim, omega: new Vector3(0f, 0f, 1.5f));
|
|
|
|
return (setup, mt, loader);
|
|
}
|
|
|
|
private static string Describe(AnimationSequencer seq, TraceLoader loader)
|
|
{
|
|
var core = seq.Core;
|
|
var sb = new StringBuilder();
|
|
bool first = true;
|
|
for (var n = core.AnimList.First; n is not null; n = n.Next)
|
|
{
|
|
if (!first) sb.Append(',');
|
|
first = false;
|
|
uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim);
|
|
sb.Append($"{id:X}@{n.Value.Framerate:F1}");
|
|
if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*');
|
|
if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^');
|
|
}
|
|
var v = core.Velocity;
|
|
var o = core.Omega;
|
|
sb.Append($" | frame={core.FrameNumber:F1}");
|
|
sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
|
|
sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
|
|
sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static AnimationSequencer NewSeq(out TraceLoader loader)
|
|
{
|
|
var (setup, mt, l) = BuildFixture();
|
|
loader = l;
|
|
return new AnimationSequencer(setup, mt, l);
|
|
}
|
|
|
|
// ── Scenarios ───────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void S1_SpawnIdle()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
Assert.Equal(
|
|
"100@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S2_IdleToWalk_LinkThenCycle()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
Assert.Equal(
|
|
"103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,0.00) style=8000003D motion=45000005 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S2b_LinkDrain_FiresOneAnimDoneSentinel()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
|
|
// Drain the 2-frame link at fr=30: 2 frames / 30fps < 0.1s. Advance
|
|
// enough to cross into the cycle.
|
|
int animDone = 0;
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
seq.Advance(0.02f);
|
|
foreach (var h in seq.ConsumePendingHooks())
|
|
if (h is DatReaderWriter.Types.AnimationDoneHook)
|
|
animDone++;
|
|
}
|
|
Assert.Equal(1, animDone);
|
|
}
|
|
|
|
[Fact]
|
|
public void S3_WalkToRun_CyclicToCyclic()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
seq.SetCycle(NC, Run, 2.0f);
|
|
// EXPECTED-DIFF(Q4): pre-cutover Fix B stripped every link leaving
|
|
// "102@60.0*^". The verbatim GetObjectSequence (Branch 2, no direct
|
|
// Walk->Run link in this fixture) routes the DOUBLE-HOP: Walk->Ready
|
|
// settle (105 at the OLD substate mod) + Ready->Run windup (104 at
|
|
// the new speed) + the Run cycle; the old Ready->Walk link (103)
|
|
// keeps draining first (pending-queue discipline). Rapid same-motion
|
|
// re-issues now collapse via remove_redundant_links (the retail
|
|
// Fix B), not an adapter locomotion special case.
|
|
Assert.Equal(
|
|
"103@30.0^,105@30.0,104@60.0,102@60.0* | frame=0.0 vel=(0.00,8.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S4_RunReSpeed_FastPath()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
seq.SetCycle(NC, Run, 2.0f);
|
|
seq.SetCycle(NC, Run, 2.5f);
|
|
// EXPECTED-DIFF(Q4): inherits S3's double-hop list; the re-speed
|
|
// itself is the verbatim Branch-2 fast path (change_cycle_speed
|
|
// scales ONLY first_cyclic..tail: 102 60->75; links untouched;
|
|
// velocity via subtract_motion(2.0)+combine_motion(2.5)).
|
|
Assert.Equal(
|
|
"103@30.0^,105@30.0,104@60.0,102@75.0* | frame=0.0 vel=(0.00,10.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.50",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S5_WalkBackward_RemapNegativeSpeed()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, WalkBack, 1.0f);
|
|
// Node list BYTE-IDENTICAL to pre-cutover: the reversed-key get_link
|
|
// resolves the Walk->Ready link (0x105) played in reverse (fr=-19.5 =
|
|
// 30 x -0.65 BackwardsFactor); cursor at the reverse starting frame
|
|
// (HighFrame+1 = 3).
|
|
// EXPECTED-DIFF(Q4), mirrors only: CurrentMotion now reads the
|
|
// POST-adjust_motion substate (45000005, was 45000006) and
|
|
// CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns
|
|
// the state and retail's interpreted state is post-adjustment. The
|
|
// vel/om=(-0.00,...) are IEEE negative zero from add_motion's
|
|
// zero-components x negative-speed multiply (numerically equal to 0).
|
|
Assert.Equal(
|
|
"105@-19.5^,101@-19.5* | frame=3.0 vel=(-0.00,-2.03,-0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S6_WalkBackToReady_StopSettleFallback()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, WalkBack, 1.0f);
|
|
seq.SetCycle(NC, Ready, 1.0f);
|
|
// EXPECTED-DIFF(Q4): pre-cutover the stop-anim low-byte fallback
|
|
// re-keyed the settle as 105@30 (forward). The verbatim get_link
|
|
// (SubstateMod=-0.65 routes the REVERSED-key branch) resolves the
|
|
// Ready->Walk windup (103) played in REVERSE (fr=-30) - retail's
|
|
// actual backward-walk settle. The old reversed link (105@-19.5,
|
|
// mid-drain) still drains first, unchanged from pre-cutover.
|
|
Assert.Equal(
|
|
"105@-19.5^,103@-30.0,100@30.0* | frame=3.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S7_EmoteAction_MidReady()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.PlayAction(EmoteAction, 1.0f);
|
|
Assert.Equal(
|
|
"10B@30.0^,100@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S8_LeaveGroundLinkStrip_FallingEngagesInstantly()
|
|
{
|
|
// EXPECTED-DIFF(R3-W4): the K-fix18 skipTransitionLink flag is
|
|
// DELETED. The instant-Falling engage is retail's own mechanism:
|
|
// MotionInterpreter.LeaveGround (0x00528b00) fires the
|
|
// RemoveLinkAnimations seam — bound to this sequencer's
|
|
// RemoveAllLinkAnimations (CPhysicsObj::RemoveLinkAnimations
|
|
// 0x0050fe20 → CSequence::remove_all_link_animations) — after the
|
|
// Falling dispatch. Same final state the flag produced.
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
seq.SetCycle(NC, Falling, 1.0f);
|
|
seq.RemoveAllLinkAnimations(); // = LeaveGround's seam firing
|
|
Assert.Equal(
|
|
"10C@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=40000015 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S9_TurnModifier()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(NC, Walk, 1.0f);
|
|
seq.PlayAction(TurnMod, 1.0f);
|
|
// EXPECTED-DIFF(Q4): pre-cutover PlayAction INSERTED the modifier's
|
|
// anim (109) before the cyclic tail - an acdream invention. Retail
|
|
// Branch 4 is PHYSICS-ONLY combine_motion (the AP-73 mechanism): the
|
|
// walk cycle's frames are untouched, the modifier contributes omega
|
|
// (0,0,1.5) and is tracked on the MotionState modifier stack.
|
|
Assert.Equal(
|
|
"103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,1.50) style=8000003D motion=45000005 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
|
|
[Fact]
|
|
public void S10_StyleChange()
|
|
{
|
|
var seq = NewSeq(out var loader);
|
|
seq.SetCycle(NC, Ready);
|
|
seq.SetCycle(Style2, Ready, 1.0f);
|
|
// EXPECTED-DIFF(Q4): pre-cutover switched cycles bare ("107@30.0*^").
|
|
// GetObjectSequence Branch 1 (style-change) plays the cross-style
|
|
// entry link (10A = Ready->Style2) before the target style's default
|
|
// cycle - retail's stance-transition animation.
|
|
Assert.Equal(
|
|
"10A@30.0^,107@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000004C motion=41000003 mod=1.00",
|
|
Describe(seq, loader));
|
|
}
|
|
}
|