diff --git a/src/AcDream.Core/Physics/Motion/CMotionTable.cs b/src/AcDream.Core/Physics/Motion/CMotionTable.cs new file mode 100644 index 00000000..02b63172 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/CMotionTable.cs @@ -0,0 +1,692 @@ +using System; +using System.Linq; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +// Alias the DatReaderWriter enum so it doesn't clash with +// AcDream.Core.Physics.MotionCommand (a static class of uint constants) — +// same convention as AnimationSequencerTests.cs. +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Physics.Motion; + +// ───────────────────────────────────────────────────────────────────────────── +// R2-Q2 — verbatim port of retail's CMotionTable (the motion-selection +// dispatcher). Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md +// (all §-references below point there); ambiguity pins: +// docs/research/2026-07-02-r2-motiontable/Q0-pins.md (A1-A5). +// +// Scope (r2-port-plan.md §3 Q2): pure motion-SELECTION logic — resolves which +// MotionData(s) to append to a CSequence and updates a MotionState in place. +// Does NOT own a pending-animation queue (that is MotionTableManager, Q3) and +// does NOT drive per-tick advance (CSequence.Update, R1). This file re-homes +// the adapter's already-field-validated GetLink (AnimationSequencer.cs +// :953-997) verbatim per the keep-list (r2-port-plan.md §2) and adds every +// other CMotionTable/free-function member the decomp documents. +// +// Command-word class bits (decomp §1 "Motion-id class bits", §15): +// 0x80000000 style-class (top bit set; the low 31 bits + top bit read as +// a NEGATIVE int32 — retail's `(int32_t)ebx_1 < 0` test) +// 0x40000000 cycle-class (cyclic/looping base state, `this->cycles`) +// 0x20000000 modifier-class (physics-only overlay, `this->modifiers`) +// 0x10000000 action-class (one-shot, `add_action` FIFO) +// 0x41000003 Ready — the "stop / default state" sentinel (decomp §15) +// +// DatReaderWriter.DBObjs.MotionTable field map (verified via reflection, +// package Chorizite.DatReaderWriter 2.1.7, 2026-07-02): +// DefaultStyle : MotionCommand → this->default_style +// StyleDefaults : Dictionary → this->style_defaults +// Cycles : Dictionary → this->cycles +// Modifiers : Dictionary → this->modifiers +// Links : Dictionary → this->links +// (MotionCommandData.MotionData : Dictionary = the inner +// per-target-substate hash) +// MotionData.Bitfield : byte (A5 CONFIRMED present — bit0=clear-mods-on- +// entry, bit1=substate-gated for is_allowed) +// MotionData.Anims : List — retail's `num_anims`/`anims[]` pair; +// Anims.Count IS num_anims (A3 — no separate packed-byte field exists on +// the managed type; the decomp's "packed byte at MotionData+0x10" is +// this same count, just read through the raw struct layout). +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Retail's CMotionTable (0x00522xxx-0x00523xxx region) — the +/// motion-selection dispatcher. Wraps a loaded DBObj +/// and resolves (style, substate, speed) requests into MotionData +/// chains appended to a , mutating a +/// in place. +/// +public sealed class CMotionTable +{ + private readonly MotionTable _table; + + public CMotionTable(MotionTable table) + { + ArgumentNullException.ThrowIfNull(table); + _table = table; + } + + // ── free functions (decomp §2) ────────────────────────────────────── + + /// + /// same_sign 0x00522260 (@298253). True when + /// and are on the same side of zero (0 counts as + /// non-negative, matching the decomp's >= 0f reading). + /// + internal static bool SameSign(float a, float b) => (a >= 0f) == (b >= 0f); + + /// + /// change_cycle_speed 0x00522290 (@298276): rescale + /// 's cyclic-tail framerate by + /// newSpeed/oldSpeed. Ported VERBATIM including the A4-#2 gap: when + /// is ~0 and is + /// NOT ~0, retail's fabsl branch structure suppresses the rescale + /// entirely (no zeroing, no scaling — a silent no-op). Only when BOTH are + /// ~0 does retail explicitly zero the framerate. + /// + internal static void ChangeCycleSpeed(CSequence sequence, MotionData? cyclic, float oldSpeed, float newSpeed) + { + const float Epsilon = 0.000199999995f; // ~0.0002f, verbatim retail constant + + if (MathF.Abs(oldSpeed) > Epsilon) + { + sequence.MultiplyCyclicAnimationFramerate(newSpeed / oldSpeed); + return; + } + + // oldSpeed ~ 0: only the "newSpeed also ~0" leg does anything (zero + // the framerate). The "newSpeed NOT ~0" leg is a silent no-op — + // retail's own gap, kept verbatim (Q0-pins A4-#2). + if (MathF.Abs(newSpeed) <= Epsilon) + { + sequence.MultiplyCyclicAnimationFramerate(0f); + } + } + + /// + /// add_motion 0x005224b0 (@298437): UNCONDITIONALLY sets + /// 's velocity/omega to + /// motion.Velocity/Omega * speedMod (replace, not accumulate — a + /// dat-silent MotionData carries a zero Vector3, so "unconditional + /// replace" and "replace with zero" are the same call; G17 core), then + /// appends every AnimData in speed-scaled + /// (framerate only, retail AnimData::operator*). + /// + internal static void AddMotion(CSequence sequence, MotionData? motion, float speedMod) + { + if (motion is null) + return; + + sequence.SetVelocity(motion.Velocity * speedMod); + sequence.SetOmega(motion.Omega * speedMod); + + foreach (var ad in motion.Anims) + { + sequence.AppendAnimation(new AnimData + { + AnimId = ad.AnimId, + LowFrame = ad.LowFrame, + HighFrame = ad.HighFrame, + Framerate = ad.Framerate * speedMod, + }); + } + } + + /// + /// combine_motion 0x00522580 (@298472): ADDS + /// 's velocity/omega (scaled by + /// ) onto the sequence's existing physics. + /// Never touches anims — used for modifier overlays where the base + /// cycle's animation frames stay untouched. + /// + internal static void CombineMotion(CSequence sequence, MotionData? motion, float speedMod) + { + if (motion is null) + return; + + sequence.CombinePhysics(motion.Velocity * speedMod, motion.Omega * speedMod); + } + + /// + /// subtract_motion 0x00522600 (@298492): inverse of + /// — used when REMOVING a modifier's + /// physics contribution. + /// + internal static void SubtractMotion(CSequence sequence, MotionData? motion, float speedMod) + { + if (motion is null) + return; + + sequence.SubtractPhysics(motion.Velocity * speedMod, motion.Omega * speedMod); + } + + // ── members ────────────────────────────────────────────────────────── + + /// + /// is_allowed 0x005226c0 (@298526): a bitfield-bit1 ("gated") + /// cycle is only reusable when the current substate matches the + /// candidate, OR the current substate already equals the owning style's + /// default substate. Null is never allowed. + /// + public bool IsAllowed(uint candidateSubstate, MotionData? candidate, MotionState state) + { + if (candidate is null) + return false; + + if ((candidate.Bitfield & 2) != 0) + { + uint substate = state.Substate; + if (candidateSubstate != substate) + { + uint defaultSubstate = LookupStyleDefault(state.Style); + return defaultSubstate == substate; + } + } + + return true; + } + + /// + /// get_link 0x00522710 (@298552). Re-homed verbatim from the + /// working adapter (AnimationSequencer.GetLink, field-validated — + /// the reversed-key branch fixed the Ready→WalkBackward "left leg + /// twitches" glitch) per Q0-pins A1: EITHER speed negative routes through + /// the swapped-key branch (link stored FROM + /// TO ), falling back to the style's + /// default-substate hop; otherwise the forward branch (link FROM + /// TO ), + /// falling back to the style-level catch-all (unstyled outer key). + /// + public MotionData? GetLink(uint fromStyle, uint fromSubstate, float fromSubstateMod, uint toSubstate, float toSubstateMod) + { + if (toSubstateMod < 0f || fromSubstateMod < 0f) + { + // Reversed-direction path: link FROM toSubstate TO fromSubstate. + int reversedKey = (int)((fromStyle << 16) | (toSubstate & 0xFFFFFFu)); + if (_table.Links.TryGetValue(reversedKey, out var revLink) + && revLink.MotionData.TryGetValue((int)fromSubstate, out var revResult)) + { + return revResult; + } + + // Style-defaults fallback (decomp §4 second predicate block, "else" branch). + uint defaultSubstate = LookupStyleDefault(fromStyle); + int subKey = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu)); + if (_table.Links.TryGetValue(subKey, out var subLink) + && subLink.MotionData.TryGetValue((int)defaultSubstate, out var subResult)) + { + return subResult; + } + return null; + } + + // Forward-direction path: link FROM fromSubstate TO toSubstate. + int outerKey1 = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu)); + if (_table.Links.TryGetValue(outerKey1, out var cmd1) + && cmd1.MotionData.TryGetValue((int)toSubstate, out var result1)) + { + return result1; + } + + // Fallback: style-level catch-all (unstyled outer key, decomp §4 "else if" branch). + int outerKey2 = (int)(fromStyle << 16); + if (_table.Links.TryGetValue(outerKey2, out var cmd2) + && cmd2.MotionData.TryGetValue((int)toSubstate, out var result2)) + { + return result2; + } + + return null; + } + + /// + /// GetObjectSequence 0x00522860 (@298636) — the FULL dispatcher. + /// See decomp §5 for the annotated retail source; the branch structure + /// below mirrors it 1:1 (comments cite the corresponding decomp block). + /// + /// Requested target substate/motion id. + /// In/out current motion state. + /// Sequence to build/mutate. + /// Requested speed_mod for the new substate. + /// OUT: tick count of appended animation, minus 1. + /// false = normal "do motion"; true = re-invoked + /// from . + public bool GetObjectSequence(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks, bool stopCall) + { + outTicks = 0; + + uint style = state.Style; + if (style == 0) + return false; + + uint substate = state.Substate; + if (substate == 0) + return false; + + uint styleDefault = LookupStyleDefault(style); // var_c + + uint target = motion; // ebx_1 working copy + + // ---- FAST PATH: requesting the style default while already a + // modifier-class substate, not a stop-call. ---- + if (target == styleDefault && !stopCall && (substate & 0x20000000) != 0) + return true; + + float requestedSpeed = speed; // ebp_1 + + // ================================================================ + // BRANCH 1: target interpreted as NEGATIVE int32 — style-change request. + // ================================================================ + if ((int)target < 0) + { + if (style == target) + return true; // already in that style + + uint currentStyleDefault = LookupStyleDefault(style); // eax_1 + + MotionData? exitLink = null; // var_4_1 + if (substate != currentStyleDefault) + { + exitLink = GetLink(style, substate, state.SubstateMod, currentStyleDefault, requestedSpeed); + } + + uint targetStyleDefault = LookupStyleDefault(target); // arg7_style_default + if (_table.StyleDefaults.ContainsKey((DRWMotionCommand)target)) + { + MotionData? newCycle = LookupCycle(target, targetStyleDefault); // eax_5 + + if (newCycle is not null) + { + if ((newCycle.Bitfield & 1) != 0) + state.ClearModifiers(); + + // link FROM current style's default substate TO target style's default substate + MotionData? directOrHop1 = GetLink(style, styleDefault, state.SubstateMod, target, requestedSpeed); // arg2 + MotionData? hop2 = null; // var_10_1 + + if (directOrHop1 is null && target != style) + { + // DOUBLE-HOP VIA default_style. + directOrHop1 = GetLink(style, styleDefault, 1f, (uint)_table.DefaultStyle, 1f); + uint defaultStyleDefaultSubstate = LookupStyleDefault((uint)_table.DefaultStyle); + hop2 = GetLink((uint)_table.DefaultStyle, defaultStyleDefaultSubstate, 1f, target, 1f); + } + + sequence.ClearPhysics(); + sequence.RemoveCyclicAnims(); + AddMotion(sequence, exitLink, requestedSpeed); + AddMotion(sequence, directOrHop1, requestedSpeed); + AddMotion(sequence, hop2, requestedSpeed); + AddMotion(sequence, newCycle, requestedSpeed); + + state.Substate = targetStyleDefault; + state.Style = target; + state.SubstateMod = speed; + ReModify(sequence, state); + + uint numAnims2 = (uint)(exitLink?.Anims.Count ?? 0); + uint ecx20 = (uint)(directOrHop1?.Anims.Count ?? 0); + uint numAnims1 = (uint)(hop2?.Anims.Count ?? 0); + outTicks = (uint)newCycle.Anims.Count + numAnims1 + ecx20 + numAnims2 - 1; + return true; + } + } + // else: fall through to the class-bit blocks below with target still negative. + } + + // ================================================================ + // BRANCH 2: target has the CYCLE-CLASS bit (0x40000000) set. + // ================================================================ + if ((target & 0x40000000) != 0) + { + uint substateId = target & 0xFFFFFFu; + MotionData? cyclic = LookupCycle(style, substateId); // eax_24 + + if (cyclic is null) + { + // No cycle for THIS style at that id -> retry under the + // table-wide default_style (decomp §5 "label_522ae6" retry — + // unconditional, no style==0 guard in retail). + cyclic = LookupCycle((uint)_table.DefaultStyle, substateId); + } + + if (cyclic is not null && IsAllowed(target, cyclic, state)) + { + + // ---- FAST RE-SPEED PATH ---- + if (target == substate + && SameSign(requestedSpeed, state.SubstateMod) + && sequence.HasAnims()) + { + ChangeCycleSpeed(sequence, cyclic, state.SubstateMod, requestedSpeed); + SubtractMotion(sequence, cyclic, state.SubstateMod); + CombineMotion(sequence, cyclic, requestedSpeed); + state.SubstateMod = speed; + return true; + } + + if ((cyclic.Bitfield & 1) != 0) + state.ClearModifiers(); + + MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_34 + bool sameSignDirect = directLink is not null && SameSign(requestedSpeed, state.SubstateMod); + + MotionData? hop2 = null; // var_10_1 + MotionData? linkOrHop1 = directLink; // arg2 + + if (directLink is null || !sameSignDirect) + { + uint styleDefaultSubstate = LookupStyleDefault(style); + linkOrHop1 = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); + hop2 = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); + } + + sequence.ClearPhysics(); + sequence.RemoveCyclicAnims(); + + if (hop2 is null) + { + float signedSpeed = (state.SubstateMod == 0f || SameSign(state.SubstateMod, speed)) + ? speed : -speed; + AddMotion(sequence, linkOrHop1, signedSpeed); + } + else + { + AddMotion(sequence, linkOrHop1, state.SubstateMod); + AddMotion(sequence, hop2, requestedSpeed); + } + + AddMotion(sequence, cyclic, requestedSpeed); + + // Leaving a modifier-class substate for something else: re-register + // the OLD substate as a still-active modifier unless the style's + // default substate IS the new target. + uint oldSubstate = state.Substate; + if (oldSubstate != target && (oldSubstate & 0x20000000) != 0) + { + uint styleDefaultSubstate2 = LookupStyleDefault(style); + if (styleDefaultSubstate2 != target) + state.AddModifierNoCheck(oldSubstate, state.SubstateMod); + } + + state.SubstateMod = speed; + state.Substate = target; + ReModify(sequence, state); + + uint ecx45 = (uint)(linkOrHop1?.Anims.Count ?? 0); + uint numAnims1b = (uint)(hop2?.Anims.Count ?? 0); + outTicks = (uint)cyclic.Anims.Count + numAnims1b + ecx45 - 1; + return true; + } + // is_allowed rejected (or no cyclic resolved) -> fall through. + } + + // ================================================================ + // BRANCH 3: target has the ACTION-CLASS bit (0x10000000) set. + // ================================================================ + if ((target & 0x10000000) != 0) + { + MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_57 + if (baseCycle is not null) + { + MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_60 + if (directLink is not null) + { + state.AddAction(target, requestedSpeed); + sequence.ClearPhysics(); + sequence.RemoveCyclicAnims(); + AddMotion(sequence, directLink, requestedSpeed); + AddMotion(sequence, baseCycle, state.SubstateMod); + ReModify(sequence, state); + outTicks = (uint)directLink.Anims.Count; + return true; + } + + // No direct link -> route through the style default (double-hop out-and-back). + uint styleDefaultSubstate = LookupStyleDefault(style); + MotionData? outHop = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); // eax_66 + if (outHop is not null) + { + MotionData? actionLink = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); // eax_68 + if (actionLink is not null) + { + MotionData? baseCycleRefetch = LookupCycle(style, substate & 0xFFFFFFu); // eax_69 (same key, re-fetched) + if (baseCycleRefetch is not null) + { + MotionData? returnHop = GetLink(style, styleDefaultSubstate, 1f, substate, state.SubstateMod); + + state.AddAction(target, requestedSpeed); + sequence.ClearPhysics(); + sequence.RemoveCyclicAnims(); + AddMotion(sequence, outHop, 1f); + AddMotion(sequence, actionLink, requestedSpeed); + AddMotion(sequence, returnHop, 1f); + AddMotion(sequence, baseCycleRefetch, state.SubstateMod); + ReModify(sequence, state); + + // A4-#1: outTicks = outHop + actionLink [+ returnHop] ONLY — + // never the base cycle, never double-counted (ACE's bug, not retail's). + uint ticks = (uint)outHop.Anims.Count + (uint)actionLink.Anims.Count; + if (returnHop is not null) + ticks += (uint)returnHop.Anims.Count; + outTicks = ticks; + return true; + } + } + } + } + } + + // ================================================================ + // BRANCH 4: target has the MODIFIER-CLASS bit (0x20000000) set. + // ================================================================ + if ((target & 0x20000000) != 0) + { + MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_81 + + if (baseCycle is not null && (baseCycle.Bitfield & 1) == 0) + { + uint modKey = target & 0xFFFFFFu; + MotionData? modifierStyled = LookupModifier(style, modKey, styleSpecific: true); // eax_85 + MotionData? modifierGlobal = null; // eax_87 + if (modifierStyled is null) + modifierGlobal = LookupModifier(style, modKey, styleSpecific: false); + MotionData? modifierData = modifierStyled ?? modifierGlobal; + + if (modifierStyled is not null || modifierGlobal is not null) + { + bool added = state.AddModifier(target, requestedSpeed); + if (!added) + { + // Toggle: already active (or == current substate) -> stop then re-add. + StopSequenceMotion(target, 1f, state, sequence, out _); + added = state.AddModifier(target, requestedSpeed); + } + if (added) + { + CombineMotion(sequence, modifierData, requestedSpeed); + return true; + } + } + } + } + + return false; + } + + /// + /// re_modify 0x005222e0 (@298300): after installing a new base + /// cycle/substate, replay every previously-active modifier back onto the + /// sequence by re-invoking for each one. + /// Q0-pins A4-#5: the retail copy-ctor snapshot exists ONLY as a + /// loop-termination bound (both the live list and the snapshot pop one + /// entry per iteration; the loop ends when the snapshot empties) — the + /// C# port deep-copies via MotionState(MotionState other), pops + /// both in lockstep, and terminates on the snapshot's emptiness. + /// + public void ReModify(CSequence sequence, MotionState state) + { + if (!state.Modifiers.Any()) + return; + + var snapshot = new MotionState(state); + + do + { + var head = state.Modifiers.First(); + uint motion = head.Motion; + float speedMod = head.SpeedMod; + state.RemoveModifier(head); + + var snapshotHead = snapshot.Modifiers.First(); + snapshot.RemoveModifier(snapshotHead); + + GetObjectSequence(motion, state, sequence, speedMod, out _, stopCall: false); + } while (snapshot.Modifiers.Any()); + } + + /// + /// StopSequenceMotion 0x00522fc0 (@298954): two independent stop + /// mechanisms. Stopping the active cycle re-drives + /// toward the style's default substate + /// (i.e. "return to idle"). Stopping a modifier just unwinds its physics + /// contribution and removes it from the chain — no animation + /// re-sequencing needed since modifiers don't touch anims[]. + /// Action-class ids are NOT handled here (decomp §7 note — actions + /// complete via the MotionTableManager tick-countdown, Q3 scope). + /// + public bool StopSequenceMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks) + { + outTicks = 0; + + // Case A: stopping the CYCLE-class substate we are currently in. + if ((motion & 0x40000000) != 0 && motion == state.Substate) + { + uint styleDefaultSubstate = LookupStyleDefault(state.Style); + GetObjectSequence(styleDefaultSubstate, state, sequence, 1f, out outTicks, stopCall: true); + return true; + } + + // Case B: stopping a MODIFIER-class id. + if ((motion & 0x20000000) != 0) + { + foreach (var node in state.Modifiers) + { + if (node.Motion != motion) + continue; + + uint modKey = motion & 0xFFFFFFu; + MotionData? modData = LookupModifier(state.Style, modKey, styleSpecific: true); + modData ??= LookupModifier(state.Style, modKey, styleSpecific: false); + + if (modData is not null) + { + SubtractMotion(sequence, modData, node.SpeedMod); + state.RemoveModifier(node); + return true; + } + + break; // matching motion id found but no MotionData anywhere -> give up + } + } + + return false; + } + + /// + /// SetDefaultState 0x005230a0 (@299004): reset a MotionState to + /// the motion table's baseline (default_style + that style's + /// default substate), clearing all modifiers/actions and installing the + /// base cyclic animation for that (style,substate) fresh via + /// (hard reset, not just + /// RemoveCyclicAnims). + /// + public bool SetDefaultState(MotionState state, CSequence sequence, out uint outTicks) + { + outTicks = 0; + + if (!_table.StyleDefaults.TryGetValue(_table.DefaultStyle, out var defaultSubstateCmd)) + return false; + + uint defaultSubstate = (uint)defaultSubstateCmd; + + state.ClearModifiers(); + state.ClearActions(); + + MotionData? cyclic = LookupCycle((uint)_table.DefaultStyle, defaultSubstate); + if (cyclic is null) + return false; + + state.Style = (uint)_table.DefaultStyle; + state.Substate = defaultSubstate; + state.SubstateMod = 1f; + outTicks = (uint)cyclic.Anims.Count - 1; + + sequence.ClearPhysics(); + sequence.ClearAnimations(); + AddMotion(sequence, cyclic, state.SubstateMod); + + return true; + } + + /// DoObjectMotion 0x00523e90 (@300045): thin wrapper — "do" == + /// with the stop-flag forced to false. + public bool DoObjectMotion(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks) + => GetObjectSequence(motion, state, sequence, speed, out outTicks, stopCall: false); + + /// StopObjectMotion 0x00523ec0 (@300053): thin wrapper — + /// tailcall straight into . + public bool StopObjectMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks) + => StopSequenceMotion(motion, speed, state, sequence, out outTicks); + + /// + /// StopObjectCompletely 0x00523ed0 (@300062): the "return to idle + /// from everything" call — strips every active modifier first (each one + /// individually going through the same subtract-and-unlink path as a + /// single call), then stops the base + /// cycle/substate itself (which re-drives + /// toward the style's default substate). Q0-pins A4-#4: return = + /// finalStopOk ? true : anyModifierStopOk, ported verbatim. + /// + public bool StopObjectCompletely(MotionState state, CSequence sequence, out uint outTicks) + { + outTicks = 0; + bool anyModifierStopOk = false; + + // Stop EVERY currently-active modifier first (list shrinks each + // iteration because StopSequenceMotion unlinks the node it stops). + while (state.Modifiers.Any()) + { + var node = state.Modifiers.First(); + float speedMod = node.SpeedMod; + if (StopSequenceMotion(node.Motion, speedMod, state, sequence, out outTicks)) + anyModifierStopOk = true; + else + break; // defensive: avoid infinite loop if a stop can't unlink (shouldn't happen) + } + + // Finally stop the base cycle/substate itself. + if (StopSequenceMotion(state.Substate, state.SubstateMod, state, sequence, out outTicks)) + return true; + + return anyModifierStopOk; + } + + // ── private lookup helpers ────────────────────────────────────────── + + private uint LookupStyleDefault(uint style) + => _table.StyleDefaults.TryGetValue((DRWMotionCommand)style, out var def) ? (uint)def : 0u; + + private MotionData? LookupCycle(uint style, uint substate) + { + int key = (int)((style << 16) | (substate & 0xFFFFFFu)); + return _table.Cycles.TryGetValue(key, out var data) ? data : null; + } + + private MotionData? LookupModifier(uint style, uint modKey, bool styleSpecific) + { + int key = styleSpecific ? (int)((style << 16) | modKey) : (int)modKey; + return _table.Modifiers.TryGetValue(key, out var data) ? data : null; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs new file mode 100644 index 00000000..e123e3a1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs @@ -0,0 +1,1045 @@ +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)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; + } + + /// MotionData with N AnimData entries (for outTicks sum tests, A3). + 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); + } +} + +/// In-memory that never resolves. +file sealed class NullLoader : IAnimationLoader +{ + public Animation? LoadAnimation(uint id) => null; +} + +/// In-memory test double. +file sealed class FakeLoader : IAnimationLoader +{ + private readonly System.Collections.Generic.Dictionary _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; +}