feat(R2-Q1): verbatim MotionState (gap H2)

Style/Substate/SubstateMod + the two independent MotionList chains with
retail's exact disciplines: modifier PUSH-FRONT stack
(add_modifier_no_check 0x00525ff0; add_modifier 0x00526340 refusing
duplicates AND the current base substate; remove_modifier by node;
clear_modifiers) and action TAIL-APPEND FIFO (add_action 0x005260a0;
remove_action_head 0x00526120 returning the popped motion, 0 when
empty; clear_actions). Deep-copy ctor clones both chains (Q0-pins
A4-#5: re_modify's snapshot is a termination bound, never shared).

11 discipline-table tests. Next: Q2 (CMotionTable + free functions —
the GetObjectSequence dispatcher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 20:31:16 +02:00
parent dc54a3e41f
commit 2345da30e9
2 changed files with 267 additions and 0 deletions

View file

@ -0,0 +1,145 @@
using System.Linq;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R2-Q1 — verbatim <c>MotionState</c> (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).
/// </summary>
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);
}
}