diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 874aa94..d252c9b 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2435,57 +2435,20 @@ public sealed class GameWindow : IDisposable
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
- // Route the Commands list — one-shot Actions, Modifiers, and
- // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
- // live in the motion table's Links / Modifiers dicts, not
- // Cycles, and are played on top of the current cycle via
- // PlayAction which resolves the right dict and interleaves the
- // action frames before the cyclic tail.
- //
- // A typical NPC wave looks like:
- // ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
- // [{0x0003=Ready, ...}]
- // Each item runs through PlayAction (for 0x10/0x20 mask) or the
- // standard SetCycle path (for 0x40 SubState). We leave SubState
- // commands to fall through to the next UpdateMotion; that's how
- // retail handles transition sequences (Wave → Ready).
+ // Route command-list entries through the shared Core router.
+ // Retail/ACE send these as 16-bit MotionCommand lows in
+ // InterpretedMotionState.Commands[]; the router reconstructs the
+ // class byte and chooses PlayAction for actions/modifiers/emotes
+ // or SetCycle for persistent substates.
if (update.MotionState.Commands is { Count: > 0 } cmds)
{
foreach (var item in cmds)
{
- // Restore the 32-bit MotionCommand from the wire's 16-bit
- // truncation by OR-ing class bits. The class is encoded
- // in the low byte's high nibble via command ranges:
- // 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
- // 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
- // 0x0051-0x00A1 — Action class (0x10xx xxxx)
- //
- // The retail MotionCommand enum carries the class byte in
- // bits 24-31. DatReaderWriter's enum values match. For
- // broadcasts, servers emit only low 16 bits (ACE
- // InterpretedMotionState.cs:139). We reconstruct via a
- // range-based lookup. See MotionCommand.generated.cs.
- uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
- if (fullCmd == 0) continue;
-
- // Action class: play through the link dict then drop back
- // to the current cycle. Modifier class: resolve from the
- // Modifiers dict and combine on top. SubState: cycle
- // change; route through SetCycle so the style-specific
- // cycle fallback applies.
- uint cls = fullCmd & 0xFF000000u;
- if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
- || cls == 0x12000000u || cls == 0x13000000u)
- {
- ae.Sequencer.PlayAction(fullCmd, item.Speed);
- }
- else if ((cls & 0x40000000u) != 0)
- {
- // Substate in the command list — typically the "and
- // then return to Ready" item. Update the cycle.
- ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
- }
- // else: Style / UI / Toggle class — not animation-driving.
+ AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
+ ae.Sequencer,
+ fullStyle,
+ item.Command,
+ item.Speed);
}
}
return;
diff --git a/src/AcDream.Core/Physics/AnimationCommandRouter.cs b/src/AcDream.Core/Physics/AnimationCommandRouter.cs
new file mode 100644
index 0000000..1101c03
--- /dev/null
+++ b/src/AcDream.Core/Physics/AnimationCommandRouter.cs
@@ -0,0 +1,97 @@
+namespace AcDream.Core.Physics;
+
+///
+/// Central routing for full retail MotionCommand values after the wire's
+/// 16-bit command id has been reconstructed.
+///
+/// Retail/ACE split motion commands by class mask:
+/// - Action and ChatEmote commands play through link/action data.
+/// - Modifier commands play through modifier data.
+/// - SubState commands become the new cyclic state.
+/// - Style/UI/Toggle commands do not directly drive an animation overlay here.
+///
+/// References:
+/// CMotionTable::GetObjectSequence 0x00522860,
+/// CMotionInterp::DoInterpretedMotion 0x00528360,
+/// ACE MotionTable.GetObjectSequence, and
+/// docs/research/deepdives/r03-motion-animation.md section 3.
+///
+public static class AnimationCommandRouter
+{
+ private const uint ActionMask = 0x10000000u;
+ private const uint ModifierMask = 0x20000000u;
+ private const uint SubStateMask = 0x40000000u;
+ private const uint ClassMask = 0xFF000000u;
+
+ ///
+ /// Classifies a reconstructed full MotionCommand.
+ ///
+ public static AnimationCommandRouteKind Classify(uint fullCommand)
+ {
+ if (fullCommand == 0)
+ return AnimationCommandRouteKind.None;
+
+ uint cls = fullCommand & ClassMask;
+ if (cls == 0x12000000u || cls == 0x13000000u)
+ return AnimationCommandRouteKind.ChatEmote;
+
+ if ((fullCommand & ModifierMask) != 0)
+ return AnimationCommandRouteKind.Modifier;
+
+ if ((fullCommand & ActionMask) != 0)
+ return AnimationCommandRouteKind.Action;
+
+ if ((fullCommand & SubStateMask) != 0)
+ return AnimationCommandRouteKind.SubState;
+
+ return AnimationCommandRouteKind.Ignored;
+ }
+
+ ///
+ /// Reconstructs and routes a 16-bit wire command.
+ ///
+ public static AnimationCommandRouteKind RouteWireCommand(
+ AnimationSequencer sequencer,
+ uint currentStyle,
+ ushort wireCommand,
+ float speedMod = 1f)
+ {
+ uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
+ return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod);
+ }
+
+ ///
+ /// Routes a full MotionCommand to the matching sequencer API.
+ ///
+ public static AnimationCommandRouteKind RouteFullCommand(
+ AnimationSequencer sequencer,
+ uint currentStyle,
+ uint fullCommand,
+ float speedMod = 1f)
+ {
+ var route = Classify(fullCommand);
+ switch (route)
+ {
+ case AnimationCommandRouteKind.Action:
+ case AnimationCommandRouteKind.Modifier:
+ case AnimationCommandRouteKind.ChatEmote:
+ sequencer.PlayAction(fullCommand, speedMod);
+ break;
+ case AnimationCommandRouteKind.SubState:
+ sequencer.SetCycle(currentStyle, fullCommand, speedMod);
+ break;
+ }
+
+ return route;
+ }
+}
+
+public enum AnimationCommandRouteKind
+{
+ None = 0,
+ Action,
+ Modifier,
+ ChatEmote,
+ SubState,
+ Ignored,
+}
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 81d8201..038f675 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -72,12 +72,20 @@ public static class MotionCommand
/// regular SetCycle transition.
///
public const uint FallDown = 0x10000050u;
- /// 0x10000057 — Dead.
- public const uint Dead = 0x10000057u;
+ /// 0x40000011 - persistent dead substate.
+ public const uint Dead = 0x40000011u;
+ /// 0x10000057 - Sanctuary death-trigger action.
+ public const uint Sanctuary = 0x10000057u;
+ /// 0x41000012 - crouching substate.
+ public const uint Crouch = 0x41000012u;
+ /// 0x41000013 - sitting substate.
+ public const uint Sitting = 0x41000013u;
+ /// 0x41000014 - sleeping substate.
+ public const uint Sleeping = 0x41000014u;
/// 0x41000011 — Crouch lower bound for blocked-jump check.
public const uint CrouchLowerBound = 0x41000011u;
- /// 0x41000014 — upper bound of crouch/sit/sleep range.
- public const uint CrouchUpperBound = 0x41000014u;
+ /// 0x41000015 - exclusive upper bound of crouch/sit/sleep range.
+ public const uint CrouchUpperExclusive = 0x41000015u;
}
///
@@ -819,7 +827,7 @@ public sealed class MotionInterpreter
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
/// return 0x49
/// uVar1 = InterpretedState.ForwardCommand
- /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
+ /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
/// return 0x48
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
/// return 0x48
@@ -850,7 +858,7 @@ public sealed class MotionInterpreter
return false;
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
- if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
+ if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
return false;
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs
new file mode 100644
index 0000000..83ca7d0
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs
@@ -0,0 +1,66 @@
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class AnimationCommandRouterTests
+{
+ private const uint NonCombat = 0x8000003Du;
+
+ [Theory]
+ [InlineData(0x00000000u, AnimationCommandRouteKind.None)]
+ [InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary
+ [InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump
+ [InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave
+ [InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready
+ [InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead
+ [InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style
+ public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected)
+ {
+ Assert.Equal(expected, AnimationCommandRouter.Classify(command));
+ }
+
+ [Fact]
+ public void RouteWireCommand_SubState_UsesSetCycle()
+ {
+ var seq = MakeEmptySequencer();
+
+ var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011);
+
+ Assert.Equal(AnimationCommandRouteKind.SubState, route);
+ Assert.Equal(NonCombat, seq.CurrentStyle);
+ Assert.Equal(MotionCommand.Dead, seq.CurrentMotion);
+ }
+
+ [Fact]
+ public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle()
+ {
+ var seq = MakeEmptySequencer();
+
+ var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057);
+
+ Assert.Equal(AnimationCommandRouteKind.Action, route);
+ Assert.Equal(0u, seq.CurrentMotion);
+ }
+
+ [Fact]
+ public void RouteWireCommand_Wave_IsChatEmote()
+ {
+ var seq = MakeEmptySequencer();
+
+ var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087);
+
+ Assert.Equal(AnimationCommandRouteKind.ChatEmote, route);
+ }
+
+ private static AnimationSequencer MakeEmptySequencer()
+ {
+ return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
+ }
+
+ private sealed class NullAnimationLoader : IAnimationLoader
+ {
+ public Animation? LoadAnimation(uint id) => null;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs
index a233b02..c436f7e 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs
@@ -21,6 +21,10 @@ public class MotionCommandResolverTests
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
[InlineData(0x0015, 0x40000015u)] // Falling
+ [InlineData(0x0011, 0x40000011u)] // Dead
+ [InlineData(0x0012, 0x41000012u)] // Crouch
+ [InlineData(0x0013, 0x41000013u)] // Sitting
+ [InlineData(0x0014, 0x41000014u)] // Sleeping
// Action-class one-shots: melee attacks, death, portals
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
[InlineData(0x0058, 0x10000058u)] // ThrustMed
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
index e41b679..1892611 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
@@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests
Assert.False(allowed);
}
+ [Fact]
+ public void ContactAllowsMove_DeadState_RejectsMove()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
+
+ bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
+
+ Assert.False(allowed);
+ }
+
+ [Theory]
+ [InlineData(MotionCommand.Crouch)]
+ [InlineData(MotionCommand.Sitting)]
+ [InlineData(MotionCommand.Sleeping)]
+ public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.InterpretedState.ForwardCommand = postureCommand;
+
+ bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
+
+ Assert.False(allowed);
+ }
+
[Fact]
public void ContactAllowsMove_CrouchRange_RejectsMove()
{