CMotionTable (Core/Physics/Motion, 692 lines) wrapping the dat MotionTable, per r2-motiontable-decomp.md + Q0 pins: - GetObjectSequence (0x00522860): entry guards; modifier-class no-op fast path; Branch 1 style-change (exit link + style link + default_style double-hop + re_modify); Branch 2 cycle (UNCONDITIONAL default-style retry per label_522ae6, is_allowed gate, same-substate re-speed fast path = ChangeCycleSpeed + SubtractMotion(old) + CombineMotion(new), clear-modifiers bit0, direct-link vs !SameSign double-hop, A2 signedSpeed, outgoing-modifier re-registration, re_modify, outTicks per A3); Branch 3 action (direct link OR the 4-layer out-and-back with the base cycle re-added at the OLD substate_mod; outTicks WITHOUT ACE's double-count, A4-#1); Branch 4 modifier (PHYSICS-ONLY CombineMotion + AddModifier stop-then-re-add — the AP-73 retirement mechanism). - get_link (A1 pin: either-negative -> swapped keys — the adapter's field-validated port re-homed), is_allowed (Bitfield & 2; A5 CONFIRMED on DatReaderWriter 2.1.7), re_modify (deep-copy snapshot termination bound), StopSequenceMotion, SetDefaultState, DoObjectMotion/StopObjectMotion/StopObjectCompletely (A4-#4 return). - Free functions: AddMotion (UNCONDITIONAL velocity/omega set — the G17 core), CombineMotion/SubtractMotion (physics-only), ChangeCycleSpeed (verbatim incl. the A4-#2 retail gap), SameSign. 44 conformance tests pinning H1/H4/H5/H7/H8/H10-H14 + all Q0 pins, incl. the run-while-turning gated-cycle -> Branch-4 physics-only test (retail's actual turn-blend mechanism) and missing-cycle -> sequence-untouched (retires the HasCycle fallback rationale). Implemented by a dedicated agent against the Q2 spec; diff + key branches reviewed, suite re-verified (3934 green) before commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1045 lines
48 KiB
C#
1045 lines
48 KiB
C#
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
|
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|
|
|
namespace AcDream.Core.Tests.Physics.Motion;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// CMotionTableTests — R2-Q2 conformance harness for the verbatim
|
|
// CMotionTable::GetObjectSequence dispatcher (r2-port-plan.md §3 Q2).
|
|
//
|
|
// Every test is labeled with the gap id(s) it pins per the plan's REQUIRED
|
|
// case list. Fixture convention matches AnimationSequencerTests.cs's
|
|
// Fixtures class (file-local, replicated here per the task's instruction to
|
|
// keep this file self-contained).
|
|
//
|
|
// Command-word constants below are real DatReaderWriter.Enums.MotionCommand
|
|
// values (verified via reflection against Chorizite.DatReaderWriter 2.1.7,
|
|
// 2026-07-02) — NOT synthetic bit patterns — so the class-bit dispatch
|
|
// (cycle/modifier/action/style) exercises the same bit layout retail ships:
|
|
// Ready = 0x41000003 (cycle)
|
|
// WalkForward = 0x45000005 (cycle)
|
|
// RunForward = 0x44000007 (cycle)
|
|
// TurnRight = 0x6500000D (cycle AND modifier — both bits set;
|
|
// the AP-73 run-while-turning mechanism test
|
|
// depends on this real dual-class shape)
|
|
// Jump = 0x2500003B (modifier only)
|
|
// ThrustMed = 0x10000058 (action only)
|
|
// HandCombat = 0x8000003C (style; top bit set)
|
|
// NonCombat = 0x8000003D (style)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
file static class Fixtures
|
|
{
|
|
public static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation)
|
|
{
|
|
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 = origin, Orientation = orientation });
|
|
anim.PartFrames.Add(pf);
|
|
}
|
|
return anim;
|
|
}
|
|
|
|
public static AnimData MakeAnimData(uint animId, float framerate)
|
|
=> new()
|
|
{
|
|
AnimId = (QualifiedDataId<Animation>)animId,
|
|
LowFrame = 0,
|
|
HighFrame = -1,
|
|
Framerate = framerate,
|
|
};
|
|
|
|
public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0,
|
|
Vector3? velocity = null, Vector3? omega = null)
|
|
{
|
|
var md = new MotionData { Bitfield = bitfield };
|
|
md.Anims.Add(MakeAnimData(animId, framerate));
|
|
if (velocity is { } v)
|
|
{
|
|
md.Velocity = v;
|
|
md.Flags |= MotionDataFlags.HasVelocity;
|
|
}
|
|
if (omega is { } w)
|
|
{
|
|
md.Omega = w;
|
|
md.Flags |= MotionDataFlags.HasOmega;
|
|
}
|
|
return md;
|
|
}
|
|
|
|
/// <summary>MotionData with N AnimData entries (for outTicks sum tests, A3).</summary>
|
|
public static MotionData MakeMultiAnimMotionData(uint firstAnimId, int count, float framerate)
|
|
{
|
|
var md = new MotionData();
|
|
for (int i = 0; i < count; i++)
|
|
md.Anims.Add(MakeAnimData(firstAnimId + (uint)i, framerate));
|
|
return md;
|
|
}
|
|
|
|
public static void AddLink(MotionTable mt, uint style, uint fromSubstate, uint toSubstate, MotionData data)
|
|
{
|
|
int outerKey = (int)((style << 16) | (fromSubstate & 0xFFFFFFu));
|
|
if (!mt.Links.TryGetValue(outerKey, out var cmd))
|
|
{
|
|
cmd = new MotionCommandData();
|
|
mt.Links[outerKey] = cmd;
|
|
}
|
|
cmd.MotionData[(int)toSubstate] = data;
|
|
}
|
|
|
|
public static void AddCycle(MotionTable mt, uint style, uint substate, MotionData data)
|
|
{
|
|
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
|
|
mt.Cycles[key] = data;
|
|
}
|
|
|
|
public static void AddModifier(MotionTable mt, uint style, uint modifier, MotionData data, bool styleSpecific = true)
|
|
{
|
|
int key = styleSpecific ? (int)((style << 16) | (modifier & 0xFFFFFFu)) : (int)(modifier & 0xFFFFFFu);
|
|
mt.Modifiers[key] = data;
|
|
}
|
|
}
|
|
|
|
public sealed class CMotionTableTests
|
|
{
|
|
// Real retail command words (DatReaderWriter.Enums.MotionCommand).
|
|
private const uint Ready = 0x41000003u;
|
|
private const uint WalkForward = 0x45000005u;
|
|
private const uint RunForward = 0x44000007u;
|
|
private const uint TurnRight = 0x6500000Du; // cycle AND modifier class
|
|
private const uint Jump = 0x2500003Bu; // modifier only
|
|
private const uint ThrustMed = 0x10000058u; // action only
|
|
|
|
private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set
|
|
private const uint NonCombatStyle = 0x8000003Du; // style, top bit set
|
|
|
|
// ── Ready→Walk link+cycle chain shape (H1, base dispatcher) ────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ReadyToWalk_BuildsLinkThenCycleChain()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint readyAnim = 0x03000001u, walkAnim = 0x03000002u, linkAnim = 0x03000003u;
|
|
loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
|
|
var readyCycle = Fixtures.MakeMotionData(readyAnim, 30f);
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, readyCycle);
|
|
|
|
var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f);
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle);
|
|
|
|
var link = Fixtures.MakeMotionData(linkAnim, 30f);
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link);
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(WalkForward, state.Substate);
|
|
Assert.Equal(NonCombatStyle, state.Style);
|
|
Assert.Equal(1f, state.SubstateMod);
|
|
// list shape: link then cycle (2 nodes total).
|
|
Assert.Equal(2, seq.Count);
|
|
// A3: outTicks = link.num_anims + cycle.num_anims - 1 = 1 + 1 - 1 = 1
|
|
Assert.Equal(1u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ReadyToWalk_FirstCyclicIsTheCycleNotTheLink()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint linkAnim = 0x03000010u, cycleAnim = 0x03000011u;
|
|
loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(0x03000012u, 30f));
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(cycleAnim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 1f, out _, stopCall: false));
|
|
|
|
Assert.NotNull(seq.FirstCyclic);
|
|
Assert.NotNull(seq.CurrAnim);
|
|
// The cyclic tail is the LAST appended node (append_animation slides
|
|
// first_cyclic on every call, R1 G10) — that's the cycle, not the link.
|
|
Assert.Equal(2, seq.Count);
|
|
}
|
|
|
|
// ── walk↔run same-substate re-speed fast path (H8) ─────────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_SameSubstateSameSignFasterSpeed_TakesReSpeedFastPath_NoListChange()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint walkAnim = 0x03000020u;
|
|
loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f, velocity: new Vector3(0, 3.12f, 0));
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle);
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
// Seed the sequence as if WalkForward is already playing at speed 1.0.
|
|
Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _));
|
|
int countBefore = seq.Count;
|
|
var firstCyclicBefore = seq.FirstCyclic;
|
|
var velBefore = seq.Velocity;
|
|
|
|
// Re-issue WalkForward at speed 2.0 (same substate, same sign).
|
|
bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 2f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(2f, state.SubstateMod);
|
|
// NO list change — fast path never calls remove_cyclic_anims/add_motion chain.
|
|
Assert.Equal(countBefore, seq.Count);
|
|
Assert.Same(firstCyclicBefore, seq.FirstCyclic);
|
|
// velocity = subtract-old(1x) + combine-new(2x) => net velocity scales to 2x the base.
|
|
Assert.Equal(velBefore * 2f, seq.Velocity);
|
|
// outTicks stays 0 on the fast path (retail: *arg6=0 at entry, never reassigned
|
|
// before the early `return 1`).
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_SameSubstateSameSignFastPath_RescalesFramerates()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint walkAnim = 0x03000021u;
|
|
loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 10f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _));
|
|
double frBefore = seq.CurrAnim!.Framerate;
|
|
|
|
Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 2.5f, out _, stopCall: false));
|
|
|
|
double frAfter = seq.CurrAnim!.Framerate;
|
|
Assert.Equal(frBefore * 2.5, frAfter, 3);
|
|
}
|
|
|
|
// ── sign-flip Walk(+)→Walk(-) routes the style-default double-hop (A2) ──
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_SignFlipSameSubstate_RoutesStyleDefaultDoubleHop()
|
|
{
|
|
// Walk(+1.0) -> Walk(-1.0): same substate id, OPPOSITE sign. This must
|
|
// NOT take the re-speed fast path (same_sign gate fails) and must NOT
|
|
// take the direct-link path (no link registered substate->substate);
|
|
// it routes via the style-default double-hop:
|
|
// hop1: get_link(style, substate, oldSpeed, styleDefaultSubstate, 1f)
|
|
// hop2: get_link(style, styleDefaultSubstate, 1f, substate, newSpeed)
|
|
var loader = new FakeLoader();
|
|
uint walkAnim = 0x03000030u, hop1Anim = 0x03000031u, hop2Anim = 0x03000032u;
|
|
loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(hop1Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(hop2Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f));
|
|
// hop1: WalkForward -> Ready (style default), hop2: Ready -> WalkForward
|
|
Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(hop1Anim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(hop2Anim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
// Seed state DIRECTLY (bypassing GetObjectSequence) so state.Substate
|
|
// starts at WalkForward WITHOUT the seeding call itself tripping the
|
|
// WalkForward->WalkForward double-hop routing this fixture's own
|
|
// links would otherwise trigger (both hop links target/originate at
|
|
// WalkForward, which would also satisfy a same-substate rebuild).
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(walkAnim, 30f), 1f);
|
|
Assert.Equal(1, seq.Count);
|
|
|
|
bool ok = cmt.GetObjectSequence(WalkForward, state, seq, -1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(-1f, state.SubstateMod);
|
|
Assert.Equal(WalkForward, state.Substate);
|
|
// Rebuild happened (not the fast path): list = hop1(1) + hop2(1) + cycle(1) = 3 nodes.
|
|
Assert.Equal(3, seq.Count);
|
|
}
|
|
|
|
// ── stance change → Branch 1 chain (H14) ────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_StyleChange_BuildsExitLinkPlusStyleLinkPlusNewCycle()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint exitAnim = 0x03000040u, styleLinkAnim = 0x03000041u, newCycleAnim = 0x03000042u;
|
|
loader.Register(exitAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(styleLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(newCycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
mt.StyleDefaults[(DRWMotionCommand)HandCombatStyle] = (DRWMotionCommand)Ready;
|
|
|
|
// Current: NonCombat, playing WalkForward (NOT the style default).
|
|
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(0x03000043u, 30f));
|
|
// exit-link: WalkForward -> NonCombat's default (Ready)
|
|
Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(exitAnim, 30f));
|
|
// style-to-style link: NonCombat's default (Ready) -> HandCombat's default (Ready)
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, HandCombatStyle, Fixtures.MakeMotionData(styleLinkAnim, 30f));
|
|
// HandCombat's default cycle (Ready)
|
|
Fixtures.AddCycle(mt, HandCombatStyle, Ready, Fixtures.MakeMotionData(newCycleAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _));
|
|
|
|
bool ok = cmt.GetObjectSequence(HandCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(HandCombatStyle, state.Style);
|
|
Assert.Equal(Ready, state.Substate); // committed to the TARGET style's default substate
|
|
// exit-link(1) + style-link(1) + new cycle(1) = 3 nodes
|
|
Assert.Equal(3, seq.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_StyleChange_AlreadyInTargetStyle_IsNoOp()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.GetObjectSequence(NonCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
Assert.Equal(0, seq.Count); // untouched
|
|
}
|
|
|
|
// ── emote-while-running Branch 3 out-and-back (A4-#1) ───────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ActionDirectLink_QueuesActionAndReAddsBaseCycle()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint cycleAnim = 0x03000050u, actionAnim = 0x03000051u;
|
|
loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, RunForward, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
|
|
|
|
bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
// Direct-link action path: action(1) + re-added base cycle(1) = 2 nodes.
|
|
Assert.Equal(2, seq.Count);
|
|
// Action queued onto the FIFO.
|
|
Assert.Single(state.Actions);
|
|
Assert.Equal(ThrustMed, state.Actions.First().Motion);
|
|
// Base substate is NOT changed by an action (state.Substate stays RunForward).
|
|
Assert.Equal(RunForward, state.Substate);
|
|
// A3: outTicks = action's num_anims (1) only, direct-link path.
|
|
Assert.Equal(1u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ActionNoDirectLink_FourLayerOutAndBack_BaseCycleAtOldSubstateMod()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint cycleAnim = 0x03000060u, outHopAnim = 0x03000061u, actionAnim = 0x03000062u, returnHopAnim = 0x03000063u;
|
|
loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(outHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(returnHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f));
|
|
// NO direct RunForward -> ThrustMed link. Route via style default (Ready).
|
|
Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(outHopAnim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, RunForward, Fixtures.MakeMotionData(returnHopAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
// Running at a NON-default speed so we can assert the base cycle is
|
|
// re-added at the OLD substate_mod (A4-#1), not the action's speed.
|
|
// Seed state DIRECTLY (bypassing GetObjectSequence) — this fixture's
|
|
// RunForward<->Ready links would otherwise make a DoObjectMotion(RunForward)
|
|
// seeding call itself take the double-hop rebuild path (RunForward
|
|
// requested while already at RunForward, no direct self-link, empty
|
|
// sequence so no fast-path) and pre-populate 3 nodes before the real
|
|
// action call under test even runs.
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1.5f };
|
|
var seq = new CSequence(loader);
|
|
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1.5f);
|
|
Assert.Equal(1, seq.Count);
|
|
|
|
bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
// outHop(1) + action(1) + returnHop(1) + base cycle(1) = 4 nodes.
|
|
Assert.Equal(4, seq.Count);
|
|
Assert.Equal(RunForward, state.Substate); // action doesn't change base substate
|
|
Assert.Equal(1.5f, state.SubstateMod); // untouched — base cycle re-added at OLD speed
|
|
|
|
// Base cycle (the last-appended / cyclic tail node) keeps the OLD
|
|
// substate_mod's framerate scale (1.5x of the dat's 30f == 45f).
|
|
Assert.NotNull(seq.FirstCyclic);
|
|
Assert.Equal(45.0, seq.FirstCyclic!.Framerate, 3);
|
|
|
|
// A4-#1: outTicks = outHop.num_anims + action.num_anims + returnHop.num_anims
|
|
// (NEVER the base cycle, NEVER double-counted — ACE's bug, not retail's).
|
|
Assert.Equal(3u, outTicks);
|
|
}
|
|
|
|
// ── turn-in-place (Branch 2 cycle) ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_TurnInPlace_ResolvesAsCycleFromReady()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint readyAnim = 0x03000070u, turnAnim = 0x03000071u, linkAnim = 0x03000072u;
|
|
loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f));
|
|
// TurnRight is NOT gated (bitfield=0) in this fixture, isolating the
|
|
// "resolves as a normal Branch-2 cycle" behavior from is_allowed gating
|
|
// (that's the SEPARATE run-while-turning test below).
|
|
Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 0));
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, TurnRight, Fixtures.MakeMotionData(linkAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(TurnRight, state.Substate);
|
|
Assert.Equal(2, seq.Count); // link + cycle
|
|
}
|
|
|
|
// ── run-while-turning: is_allowed rejects gated turn cycle → Branch 4 ──
|
|
// (AP-73 mechanism test, gap H4/H13)
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_RunWhileTurning_GatedTurnCycleRejected_FallsToModifierPhysicsOnlyCombine()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint runAnim = 0x03000080u, turnAnim = 0x03000081u;
|
|
loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
|
|
var runVelocity = new Vector3(0, 4.0f, 0);
|
|
Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f, velocity: runVelocity));
|
|
|
|
// TurnRight AS A CYCLE is bitfield&2 gated (substate-checked) —
|
|
// is_allowed will reject it because current substate (RunForward) !=
|
|
// TurnRight and != the style default.
|
|
Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 2));
|
|
// TurnRight AS A MODIFIER: physics-only turn omega, resolved from the
|
|
// Modifiers dict once Branch 2 falls through.
|
|
var turnOmega = new Vector3(0, 0, -(MathF.PI / 2f));
|
|
Fixtures.AddModifier(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(0, 0f, omega: turnOmega));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
|
|
int countBefore = seq.Count;
|
|
var cyclicBefore = seq.FirstCyclic;
|
|
var currBefore = seq.CurrAnim;
|
|
|
|
bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
// Run anims UNTOUCHED — Branch 4 is physics-only, no add/remove of anim nodes.
|
|
Assert.Equal(countBefore, seq.Count);
|
|
Assert.Equal(cyclicBefore, seq.FirstCyclic);
|
|
Assert.Equal(currBefore, seq.CurrAnim);
|
|
// Base substate stays RunForward (a modifier doesn't replace the cycle).
|
|
Assert.Equal(RunForward, state.Substate);
|
|
// TurnRight now tracked as an active modifier.
|
|
Assert.Contains(state.Modifiers, m => m.Motion == TurnRight);
|
|
// Physics combined: velocity untouched (turn modifier carries no
|
|
// velocity), omega now includes the turn contribution.
|
|
Assert.Equal(runVelocity, seq.Velocity);
|
|
Assert.Equal(turnOmega, seq.Omega);
|
|
}
|
|
|
|
// ── modifier stop = subtract + unlink ───────────────────────────────
|
|
|
|
[Fact]
|
|
public void StopSequenceMotion_Modifier_SubtractsPhysicsAndUnlinksFromModifierChain()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
var jumpOmega = new Vector3(0, 0, 5f);
|
|
Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
// Activate the modifier directly (bypassing GetObjectSequence's gate logic).
|
|
state.AddModifierNoCheck(Jump, 1f);
|
|
seq.CombinePhysics(Vector3.Zero, jumpOmega);
|
|
Assert.Equal(jumpOmega, seq.Omega);
|
|
|
|
bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(Vector3.Zero, seq.Omega); // subtracted back out
|
|
Assert.DoesNotContain(state.Modifiers, m => m.Motion == Jump); // unlinked
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void StopSequenceMotion_UnknownModifier_ReturnsFalse_NoOp()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
// ── StopObjectCompletely drains modifiers then re-drives to style default (A4-#4) ──
|
|
|
|
[Fact]
|
|
public void StopObjectCompletely_DrainsAllModifiers_ThenRedrivesToStyleDefault()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint runAnim = 0x03000090u, readyAnim = 0x03000091u, exitLinkAnim = 0x03000092u;
|
|
loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f));
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f));
|
|
Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f));
|
|
|
|
var jumpOmega = new Vector3(1, 0, 0);
|
|
Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
|
|
state.AddModifierNoCheck(Jump, 1f);
|
|
seq.CombinePhysics(Vector3.Zero, jumpOmega);
|
|
|
|
bool ok = cmt.StopObjectCompletely(state, seq, out _);
|
|
|
|
Assert.True(ok);
|
|
Assert.Empty(state.Modifiers); // drained
|
|
Assert.Equal(Ready, state.Substate); // re-driven to style default
|
|
// The turn/jump physics contribution is gone (subtracted during drain).
|
|
Assert.Equal(Vector3.Zero, seq.Omega);
|
|
}
|
|
|
|
// ── missing cycle → return false, sequence UNTOUCHED (H5) ───────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_MissingCycle_ReturnsFalse_SequenceUntouched()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint readyAnim = 0x030000A0u;
|
|
loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f));
|
|
// Deliberately NO RunForward cycle anywhere (not under NonCombatStyle,
|
|
// not under DefaultStyle) — the default_style retry also misses.
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(Ready, state, seq, 1f, out _));
|
|
int countBefore = seq.Count;
|
|
var velBefore = seq.Velocity;
|
|
var omegaBefore = seq.Omega;
|
|
uint substateBefore = state.Substate;
|
|
|
|
bool ok = cmt.GetObjectSequence(RunForward, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
Assert.Equal(countBefore, seq.Count); // list untouched
|
|
Assert.Equal(velBefore, seq.Velocity);
|
|
Assert.Equal(omegaBefore, seq.Omega);
|
|
Assert.Equal(substateBefore, state.Substate); // state untouched
|
|
}
|
|
|
|
// ── entry guards ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ZeroStyle_ReturnsFalse()
|
|
{
|
|
var mt = new MotionTable();
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState(); // Style=0, Substate=0 (default ctor)
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ZeroSubstate_ReturnsFalse()
|
|
{
|
|
var mt = new MotionTable();
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = 0 };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
// ── modifier-class no-op fast path ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetObjectSequence_ModifierClassTargetEqualsStyleDefault_NotStopCall_IsNoOp()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Jump; // contrived: style default == Jump (modifier-class id)
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Jump, SubstateMod = 1f };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.GetObjectSequence(Jump, state, seq, 1f, out uint outTicks, stopCall: false);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
Assert.Equal(0, seq.Count); // no-op, nothing built
|
|
}
|
|
|
|
// ── re_modify replays the modifier stack after a substate change ────
|
|
|
|
[Fact]
|
|
public void ReModify_ReplaysActiveModifiers_ThroughGetObjectSequence()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint runAnim = 0x030000B0u;
|
|
loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f));
|
|
var jumpOmega = new Vector3(0, 0, 9f);
|
|
Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
|
|
|
|
// Active modifier BEFORE the transition that calls re_modify.
|
|
state.AddModifierNoCheck(Jump, 1f);
|
|
seq.ClearPhysics(); // simulate a fresh rebuild wiping physics
|
|
|
|
cmt.ReModify(seq, state);
|
|
|
|
// re_modify replayed Jump through GetObjectSequence -> combine_motion
|
|
// re-applies its omega contribution.
|
|
Assert.Equal(jumpOmega, seq.Omega);
|
|
// Modifier chain popped-and-readded during replay; net membership unchanged.
|
|
Assert.Contains(state.Modifiers, m => m.Motion == Jump);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReModify_EmptyModifierChain_IsNoOp()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
cmt.ReModify(seq, state); // must not throw; no modifiers to replay
|
|
|
|
Assert.Empty(state.Modifiers);
|
|
Assert.Equal(0, seq.Count);
|
|
}
|
|
|
|
// ── free-function unit coverage ─────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(1f, 1f, true)]
|
|
[InlineData(-1f, -1f, true)]
|
|
[InlineData(1f, -1f, false)]
|
|
[InlineData(-1f, 1f, false)]
|
|
[InlineData(0f, 1f, true)]
|
|
[InlineData(0f, -1f, false)]
|
|
public void SameSign_MatchesRetailSemantics(float a, float b, bool expected)
|
|
{
|
|
Assert.Equal(expected, CMotionTable.SameSign(a, b));
|
|
}
|
|
|
|
[Fact]
|
|
public void ChangeCycleSpeed_RescalesFramerate_ByRatio()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint animId = 0x030000C0u;
|
|
loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
var seq = new CSequence(loader);
|
|
seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f));
|
|
|
|
var md = Fixtures.MakeMotionData(animId, 10f);
|
|
CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 1f, newSpeed: 2f);
|
|
|
|
Assert.Equal(20.0, seq.CurrAnim!.Framerate, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNearZero_ZeroesFramerate()
|
|
{
|
|
// A4-#2: retail's own gap, ported verbatim — when BOTH old and new
|
|
// speed are ~0, the framerate is explicitly zeroed.
|
|
var loader = new FakeLoader();
|
|
uint animId = 0x030000C1u;
|
|
loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
var seq = new CSequence(loader);
|
|
seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f));
|
|
|
|
var md = Fixtures.MakeMotionData(animId, 10f);
|
|
CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 0.0001f);
|
|
|
|
Assert.Equal(0.0, seq.CurrAnim!.Framerate, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNonZero_SilentNoOp_A4Gap()
|
|
{
|
|
// A4-#2 gap, PORTED VERBATIM (retail bug, not ours to fix): when
|
|
// old speed ~0 but new speed is NOT ~0, retail's fabsl(arg4) branch
|
|
// structure suppresses the rescale entirely — framerate is left
|
|
// exactly as-is (no zeroing, no rescale). Leave this test failing
|
|
// rather than fudge the assertion if the decomp's fall-through is
|
|
// ever re-read differently.
|
|
var loader = new FakeLoader();
|
|
uint animId = 0x030000C2u;
|
|
loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
var seq = new CSequence(loader);
|
|
seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f));
|
|
|
|
var md = Fixtures.MakeMotionData(animId, 10f);
|
|
CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 5f);
|
|
|
|
Assert.Equal(10.0, seq.CurrAnim!.Framerate, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddMotion_NullMotionData_IsNoOp()
|
|
{
|
|
var seq = new CSequence(new NullLoader());
|
|
CMotionTable.AddMotion(seq, null, 1f);
|
|
Assert.Equal(0, seq.Count);
|
|
Assert.Equal(Vector3.Zero, seq.Velocity);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddMotion_SetsVelocityOmega_Unconditionally_EvenWhenZero()
|
|
{
|
|
// G17 core: add_motion is UNCONDITIONAL (retail 0x005224b0) — no
|
|
// HasVelocity/HasOmega gate. A MotionData with a non-zero existing
|
|
// sequence velocity gets overwritten with the dat-silent zero.
|
|
var loader = new FakeLoader();
|
|
uint animId = 0x030000D0u;
|
|
loader.Register(animId, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
var seq = new CSequence(loader);
|
|
seq.SetVelocity(new Vector3(9, 9, 9));
|
|
|
|
var md = Fixtures.MakeMotionData(animId, 10f); // no HasVelocity flag -> Velocity field is default(Vector3.Zero)
|
|
CMotionTable.AddMotion(seq, md, 1f);
|
|
|
|
Assert.Equal(Vector3.Zero, seq.Velocity); // overwritten with zero, not left at (9,9,9)
|
|
}
|
|
|
|
[Fact]
|
|
public void CombineMotion_AddsToExistingPhysics_AnimsUntouched()
|
|
{
|
|
var seq = new CSequence(new NullLoader());
|
|
seq.SetVelocity(new Vector3(1, 0, 0));
|
|
var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0));
|
|
|
|
CMotionTable.CombineMotion(seq, md, 1f);
|
|
|
|
Assert.Equal(new Vector3(3, 0, 0), seq.Velocity);
|
|
Assert.Equal(0, seq.Count); // combine_motion never touches anims
|
|
}
|
|
|
|
[Fact]
|
|
public void SubtractMotion_RemovesFromExistingPhysics_AnimsUntouched()
|
|
{
|
|
var seq = new CSequence(new NullLoader());
|
|
seq.SetVelocity(new Vector3(5, 0, 0));
|
|
var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0));
|
|
|
|
CMotionTable.SubtractMotion(seq, md, 1f);
|
|
|
|
Assert.Equal(new Vector3(3, 0, 0), seq.Velocity);
|
|
Assert.Equal(0, seq.Count);
|
|
}
|
|
|
|
// ── GetLink / IsAllowed direct unit coverage ────────────────────────
|
|
|
|
[Fact]
|
|
public void GetLink_ForwardDirection_LooksUpBySubstateThenMotion()
|
|
{
|
|
var mt = new MotionTable();
|
|
var link = Fixtures.MakeMotionData(0x030000E0u, 30f);
|
|
Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link);
|
|
var cmt = new CMotionTable(mt);
|
|
|
|
var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, 1f);
|
|
|
|
Assert.Same(link, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLink_ReversedDirection_EitherSpeedNegative_SwapsKeys()
|
|
{
|
|
// A1 pin: EITHER speed negative -> swapped-key branch (link stored
|
|
// FROM motion TO substate).
|
|
var mt = new MotionTable();
|
|
var reversedLink = Fixtures.MakeMotionData(0x030000E1u, 30f);
|
|
Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, reversedLink);
|
|
var cmt = new CMotionTable(mt);
|
|
|
|
// fromSubstate=Ready(+1), toSubstate=WalkForward but NEGATIVE speed.
|
|
var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, -1f);
|
|
|
|
Assert.Same(reversedLink, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAllowed_UngatedMotionData_AlwaysAllowed()
|
|
{
|
|
var mt = new MotionTable();
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready };
|
|
var md = Fixtures.MakeMotionData(0x030000F0u, 30f, bitfield: 0);
|
|
|
|
Assert.True(cmt.IsAllowed(WalkForward, md, state));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAllowed_GatedMotionData_CandidateEqualsCurrentSubstate_Allowed()
|
|
{
|
|
var mt = new MotionTable();
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = TurnRight };
|
|
var md = Fixtures.MakeMotionData(0x030000F1u, 30f, bitfield: 2);
|
|
|
|
Assert.True(cmt.IsAllowed(TurnRight, md, state));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAllowed_GatedMotionData_CurrentSubstateIsStyleDefault_Allowed()
|
|
{
|
|
var mt = new MotionTable();
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready };
|
|
var md = Fixtures.MakeMotionData(0x030000F2u, 30f, bitfield: 2);
|
|
|
|
Assert.True(cmt.IsAllowed(TurnRight, md, state));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAllowed_GatedMotionData_CurrentSubstateMismatch_Rejected()
|
|
{
|
|
var mt = new MotionTable();
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward }; // neither TurnRight nor Ready
|
|
var md = Fixtures.MakeMotionData(0x030000F3u, 30f, bitfield: 2);
|
|
|
|
Assert.False(cmt.IsAllowed(TurnRight, md, state));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAllowed_NullMotionData_Rejected()
|
|
{
|
|
var mt = new MotionTable();
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready };
|
|
|
|
Assert.False(cmt.IsAllowed(TurnRight, null, state));
|
|
}
|
|
|
|
// ── SetDefaultState (hard reset) ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void SetDefaultState_ResetsToTableDefaultStyleAndSubstate_ClearAnimationsHardReset()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint readyAnim = 0x03000100u, junkAnim = 0x03000101u;
|
|
loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
loader.Register(junkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = HandCombatStyle, Substate = RunForward, SubstateMod = 3f };
|
|
state.AddModifierNoCheck(Jump, 1f);
|
|
state.AddAction(ThrustMed, 1f);
|
|
|
|
var seq = new CSequence(loader);
|
|
seq.AppendAnimation(Fixtures.MakeAnimData(junkAnim, 5f)); // stale junk node
|
|
|
|
bool ok = cmt.SetDefaultState(state, seq, out uint outTicks);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(NonCombatStyle, state.Style);
|
|
Assert.Equal(Ready, state.Substate);
|
|
Assert.Equal(1f, state.SubstateMod);
|
|
Assert.Empty(state.Modifiers); // clear_modifiers
|
|
Assert.Empty(state.Actions); // clear_actions
|
|
// clear_animations hard reset: the stale junk node is gone; only the
|
|
// fresh Ready cycle remains.
|
|
Assert.Equal(1, seq.Count);
|
|
// outTicks = cyclic.num_anims - 1 = 1 - 1 = 0 (decomp §8: *arg4 = node->motionData->num_anims - 1).
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetDefaultState_TableHasNoDefaultStyleEntry_ReturnsFalse()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
// No StyleDefaults entry registered for NonCombatStyle.
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState();
|
|
var seq = new CSequence(new NullLoader());
|
|
|
|
bool ok = cmt.SetDefaultState(state, seq, out uint outTicks);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal(0u, outTicks);
|
|
}
|
|
|
|
// ── DoObjectMotion / StopObjectMotion thin-wrapper coverage ──────────
|
|
|
|
[Fact]
|
|
public void DoObjectMotion_ForcesStopFlagFalse_DelegatesToGetObjectSequence()
|
|
{
|
|
var loader = new FakeLoader();
|
|
uint animId = 0x03000110u;
|
|
loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
|
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready;
|
|
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(animId, 30f));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
var seq = new CSequence(loader);
|
|
|
|
bool ok = cmt.DoObjectMotion(Ready, state, seq, 1f, out _);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(1, seq.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void StopObjectMotion_DelegatesToStopSequenceMotion()
|
|
{
|
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
|
|
var jumpOmega = new Vector3(0, 0, 3f);
|
|
Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega));
|
|
|
|
var cmt = new CMotionTable(mt);
|
|
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
|
|
state.AddModifierNoCheck(Jump, 1f);
|
|
var seq = new CSequence(new NullLoader());
|
|
seq.CombinePhysics(Vector3.Zero, jumpOmega);
|
|
|
|
bool ok = cmt.StopObjectMotion(Jump, 1f, state, seq, out _);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(Vector3.Zero, seq.Omega);
|
|
}
|
|
}
|
|
|
|
/// <summary>In-memory <see cref="IAnimationLoader"/> that never resolves.</summary>
|
|
file sealed class NullLoader : IAnimationLoader
|
|
{
|
|
public Animation? LoadAnimation(uint id) => null;
|
|
}
|
|
|
|
/// <summary>In-memory <see cref="IAnimationLoader"/> test double.</summary>
|
|
file sealed class FakeLoader : 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;
|
|
}
|