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; } }