diff --git a/src/AcDream.Core/Physics/Motion/MotionState.cs b/src/AcDream.Core/Physics/Motion/MotionState.cs new file mode 100644 index 00000000..94c96a58 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionState.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; + +namespace AcDream.Core.Physics.Motion; + +/// +/// One entry of a chain — retail's +/// MotionList node (12 bytes: motion + speed_mod + next; +/// acclient.h, r2-motiontable-decomp.md §14). One struct, two independent +/// chain disciplines: the modifier chain is a PUSH-FRONT STACK, the action +/// chain a TAIL-APPEND FIFO. +/// +public sealed class MotionEntry +{ + public uint Motion; + public float SpeedMod = 1f; + + public MotionEntry(uint motion, float speedMod) + { + Motion = motion; + SpeedMod = speedMod; + } +} + +/// +/// R2-Q1 — verbatim port of retail's MotionState (acclient.h:31081; +/// r2-motiontable-decomp.md §13): the motion-table-facing state block — +/// current style / substate / substate_mod plus the modifier stack and the +/// action FIFO that CMotionTable::GetObjectSequence, +/// StopSequenceMotion, re_modify, and +/// MotionTableManager::AnimationDone read and write. +/// +public sealed class MotionState +{ + /// Current style/stance command word (0 = unset; ctor 0x00525fd0). + public uint Style; + + /// Current base substate command word (0 = unset). + public uint Substate; + + /// Speed modifier of the base substate (default 1.0). + public float SubstateMod = 1f; + + private readonly LinkedList _modifiers = new(); // modifier_head — push-front stack + private readonly LinkedList _actions = new(); // action_head/tail — FIFO + + public MotionState() + { + } + + /// + /// Deep copy (retail copy ctor 0x00526790; Q0-pins A4-#5): both chains + /// CLONED — re_modify's snapshot is a termination bound, never + /// shared state. + /// + public MotionState(MotionState other) + { + Style = other.Style; + Substate = other.Substate; + SubstateMod = other.SubstateMod; + foreach (var m in other._modifiers) + _modifiers.AddLast(new MotionEntry(m.Motion, m.SpeedMod)); + foreach (var a in other._actions) + _actions.AddLast(new MotionEntry(a.Motion, a.SpeedMod)); + } + + /// Modifier chain in retail order (newest first — push-front). + public IEnumerable Modifiers => _modifiers; + + /// Action FIFO in retail order (oldest first). + public IEnumerable Actions => _actions; + + /// add_modifier_no_check (0x00525ff0): unconditional + /// push-front. + public void AddModifierNoCheck(uint motion, float speedMod) + => _modifiers.AddFirst(new MotionEntry(motion, speedMod)); + + /// + /// add_modifier (0x00526340): refuse when the motion is already + /// a modifier (caller must stop-then-re-add to refresh) or already IS + /// the current base substate. + /// + public bool AddModifier(uint motion, float speedMod) + { + for (var n = _modifiers.First; n is not null; n = n.Next) + if (n.Value.Motion == motion) + return false; + + if (Substate == motion) + return false; + + AddModifierNoCheck(motion, speedMod); + return true; + } + + /// remove_modifier (0x00526040) — by node identity + /// (retail's node+predecessor pair collapses in a managed list). + public void RemoveModifier(MotionEntry entry) + { + _modifiers.Remove(entry); + } + + /// clear_modifiers (0x00526070). + public void ClearModifiers() => _modifiers.Clear(); + + /// add_action (0x005260a0): tail append. + public void AddAction(uint motion, float speedMod) + => _actions.AddLast(new MotionEntry(motion, speedMod)); + + /// remove_action_head (0x00526120): pop the FIFO head, + /// returning its motion id (0 when empty). + public uint RemoveActionHead() + { + var head = _actions.First; + if (head is null) + return 0; + _actions.RemoveFirst(); + return head.Value.Motion; + } + + /// clear_actions (0x005260f0). + public void ClearActions() => _actions.Clear(); +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs new file mode 100644 index 00000000..41c51f98 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs @@ -0,0 +1,145 @@ +using System.Linq; +using AcDream.Core.Physics.Motion; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R2-Q1 — verbatim MotionState (gap H2). Oracle: +/// r2-motiontable-decomp.md §13 (ctor 0x00525fd0, add_modifier_no_check +/// 0x00525ff0, add_modifier 0x00526340, remove_modifier 0x00526040, +/// clear_modifiers 0x00526070, add_action 0x005260a0, clear_actions +/// 0x005260f0, remove_action_head 0x00526120) + §14 (one node struct, +/// two independent chains: modifier PUSH-FRONT STACK vs action TAIL-APPEND +/// FIFO) + Q0-pins A4-#5 (deep-copy ctor clones both chains — re_modify's +/// snapshot is a termination bound, never shared state). +/// +public class MotionStateTests +{ + [Fact] + public void Defaults_MatchRetailCtor() + { + var ms = new MotionState(); + Assert.Equal(0u, ms.Style); + Assert.Equal(0u, ms.Substate); + Assert.Equal(1f, ms.SubstateMod); + Assert.Empty(ms.Modifiers); + Assert.Empty(ms.Actions); + } + + [Fact] + public void Modifiers_ArePushFrontStack() + { + var ms = new MotionState(); + ms.AddModifierNoCheck(0x0Du, 1.0f); + ms.AddModifierNoCheck(0x0Fu, 1.5f); + + // Newest first — retail pushes onto modifier_head. + Assert.Equal(new uint[] { 0x0Fu, 0x0Du }, ms.Modifiers.Select(m => m.Motion).ToArray()); + } + + [Fact] + public void Actions_AreTailAppendFifo() + { + var ms = new MotionState(); + ms.AddAction(0x62u, 1.0f); + ms.AddAction(0x63u, 1.25f); + + Assert.Equal(new uint[] { 0x62u, 0x63u }, ms.Actions.Select(a => a.Motion).ToArray()); + } + + [Fact] + public void AddModifier_RejectsDuplicate() + { + var ms = new MotionState(); + Assert.True(ms.AddModifier(0x0Du, 1.0f)); + Assert.False(ms.AddModifier(0x0Du, 2.0f)); // already present → caller must stop-then-re-add + Assert.Single(ms.Modifiers); + Assert.Equal(1.0f, ms.Modifiers.First().SpeedMod); // original untouched + } + + [Fact] + public void AddModifier_RefusesCurrentSubstate() + { + var ms = new MotionState { Substate = 0x45000005u }; + Assert.False(ms.AddModifier(0x45000005u, 1.0f)); + Assert.Empty(ms.Modifiers); + } + + [Fact] + public void AddModifierNoCheck_SkipsBothGuards() + { + var ms = new MotionState { Substate = 0x45000005u }; + ms.AddModifierNoCheck(0x45000005u, 1.0f); + ms.AddModifierNoCheck(0x45000005u, 2.0f); // duplicate allowed too + Assert.Equal(2, ms.Modifiers.Count()); + } + + [Fact] + public void RemoveModifier_ByNodeIdentity() + { + var ms = new MotionState(); + ms.AddModifierNoCheck(0x0Du, 1.0f); + ms.AddModifierNoCheck(0x0Fu, 1.5f); + var target = ms.Modifiers.First(m => m.Motion == 0x0Du); + + ms.RemoveModifier(target); + + Assert.Single(ms.Modifiers); + Assert.Equal(0x0Fu, ms.Modifiers.First().Motion); + } + + [Fact] + public void RemoveActionHead_PopsFifo_ReturnsMotion_ZeroWhenEmpty() + { + var ms = new MotionState(); + ms.AddAction(0x62u, 1f); + ms.AddAction(0x63u, 1f); + + Assert.Equal(0x62u, ms.RemoveActionHead()); + Assert.Equal(0x63u, ms.RemoveActionHead()); + Assert.Equal(0u, ms.RemoveActionHead()); // empty → 0 (retail returns 0) + Assert.Empty(ms.Actions); + + // Tail cleared with the last pop — a fresh append works normally. + ms.AddAction(0x64u, 1f); + Assert.Equal(new uint[] { 0x64u }, ms.Actions.Select(a => a.Motion).ToArray()); + } + + [Fact] + public void ClearModifiers_And_ClearActions_AreIndependentChains() + { + var ms = new MotionState(); + ms.AddModifierNoCheck(0x0Du, 1f); + ms.AddAction(0x62u, 1f); + + ms.ClearModifiers(); + Assert.Empty(ms.Modifiers); + Assert.Single(ms.Actions); + + ms.ClearActions(); + Assert.Empty(ms.Actions); + } + + [Fact] + public void DeepCopy_ClonesChains_NoSharedState() + { + // A4-#5: re_modify's snapshot is a DEEP copy used as a termination + // bound — mutating the original must not touch the snapshot. + var ms = new MotionState { Style = 0x8000003Du, Substate = 0x44000007u, SubstateMod = 2.85f }; + ms.AddModifierNoCheck(0x0Du, 1.5f); + ms.AddAction(0x62u, 1f); + + var snap = new MotionState(ms); + + ms.ClearModifiers(); + ms.RemoveActionHead(); + ms.Substate = 0x41000003u; + + Assert.Equal(0x8000003Du, snap.Style); + Assert.Equal(0x44000007u, snap.Substate); + Assert.Equal(2.85f, snap.SubstateMod); + Assert.Single(snap.Modifiers); + Assert.Equal(0x0Du, snap.Modifiers.First().Motion); + Assert.Single(snap.Actions); + } +}