From 460f95cb42e7398082e656075f95598970d6ef72 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:46:22 +0200 Subject: [PATCH] fix(anim): Phase L.1b route motion commands --- src/AcDream.App/Rendering/GameWindow.cs | 57 ++--------- .../Physics/AnimationCommandRouter.cs | 97 +++++++++++++++++++ src/AcDream.Core/Physics/MotionInterpreter.cs | 20 ++-- .../Physics/AnimationCommandRouterTests.cs | 66 +++++++++++++ .../Physics/MotionCommandResolverTests.cs | 4 + .../Physics/MotionInterpreterTests.cs | 27 ++++++ 6 files changed, 218 insertions(+), 53 deletions(-) create mode 100644 src/AcDream.Core/Physics/AnimationCommandRouter.cs create mode 100644 tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs 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() {