diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a5ba3f4..28901ff 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1467,6 +1467,60 @@ public sealed class GameWindow : IDisposable // No-op if same; the sequencer's fast path guards against that. ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); + + // 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). + 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. + } + } return; } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index d08008d..6d3e918 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -109,7 +110,27 @@ public static class CreateObject /// Nullified Statue of a Drudge, which is rendered in the wrong pose /// if you only consult the MotionTable's default style. /// - public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null); + public readonly record struct ServerMotionState( + ushort Stance, + ushort? ForwardCommand, + float? ForwardSpeed = null, + IReadOnlyList? Commands = null); + + /// + /// One entry in the InterpretedMotionState's Commands list (MotionItem). + /// The server packs 0..many of these per broadcast: emotes, attacks, + /// and other one-shot motions arrive here, not in ForwardCommand. + /// + /// Wire layout (see ACE Network/Motion/MotionItem.cs): + /// u16 command — low 16 bits of MotionCommand (Action class + /// typically 0x10xx; ChatEmote 0x13xx) + /// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp + /// f32 speed — speedMod for the animation + /// + public readonly record struct MotionItem( + ushort Command, + ushort PackedSequence, + float Speed); /// /// Server instruction to replace the surface texture at @@ -480,6 +501,7 @@ public static class CreateObject ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; // 0 = Invalid is the only union variant we care about for static // entities. Walking/turning entities use the other variants but @@ -488,21 +510,20 @@ public static class CreateObject if (movementType == 0) { // InterpretedMotionState: u32 (flags | numCommands<<7), then - // each present field in flag order. We only care about - // ForwardCommand, so read in order and stop early if we - // can't get that far. + // each present field in flag order. Flag bits (low 7) are + // CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is + // the MotionItem list length that follows after the speed + // fields (see ACE InterpretedMotionState.cs::Write). if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p)); p += 4; uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits + uint numCommands = packed >> 7; // CurrentStyle (0x1) if ((flags & 0x1u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); - // The InterpretedMotionState's CurrentStyle is just a copy - // of MovementData.CurrentStyle per ACE source. Read and - // prefer it as the more specific value. currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } @@ -525,10 +546,32 @@ public static class CreateObject forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); p += 4; } + // SidestepSpeed (0x20) — skip + if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } + // TurnSpeed (0x40) — skip + if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } + + // Commands list: numCommands × 8-byte MotionItem (u16 cmd + + // u16 packedSeq + f32 speed). One-shot actions, emotes, + // attacks — everything that's NOT a looping cycle change + // arrives here. Cap read at the buffer boundary. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (mv.Length - p < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4)); + p += 8; + commands.Add(new MotionItem(cmd, seq, speed)); + } + } done:; } - return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed); + return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 5a74e76..f4f1486 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -122,17 +123,19 @@ public static class UpdateMotion ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; if (movementType == 0) { // InterpretedMotionState — same layout as in CreateObject's // MovementInvalid branch, just reached via the header'd path. - // Only ForwardCommand is pulled out; the rest is deliberately - // ignored because the animation system consumes nothing else. + // Includes the Commands list (MotionItem[]) that carries + // Actions, emotes, and other one-shots not in ForwardCommand. if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; + uint numCommands = packed >> 7; // CurrentStyle (0x1) — prefer the InterpretedMotionState's copy // if present, matching the CreateObject parser's behavior. @@ -161,10 +164,30 @@ public static class UpdateMotion forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } + // SidestepSpeed (0x20) — skip + if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } + // TurnSpeed (0x40) — skip + if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } + + // Commands list: actions/emotes/attacks. Guard against a + // malformed numCommands by capping at a sane max. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (body.Length - pos < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)); + pos += 8; + commands.Add(new CreateObject.MotionItem(cmd, seq, speed)); + } + } done:; } - return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed)); + return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands)); } catch { diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs new file mode 100644 index 0000000..1a0a3e2 --- /dev/null +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Physics; + +/// +/// Reconstructs the 32-bit retail value from +/// a 16-bit wire value broadcast in InterpretedMotionState.Commands[]. +/// +/// +/// The server serializes MotionCommands as u16 (ACE +/// InterpretedMotionState.cs:139), truncating the class byte (Style / +/// SubState / Modifier / Action / ChatEmote / UI / Toggle / Mappable / +/// Command — see r03 §3.1). The client must re-attach the class byte before +/// routing the command into the motion table, because the same low 16 bits +/// can map to different classes (e.g. 0x0003 is Ready as a SubState, +/// but there's no other 0x0003). +/// +/// +/// +/// This is implemented as an eager lookup table built from all values of +/// via reflection. If the wire value matches +/// more than one enum value (different class bits), we prefer the +/// lowest-class-numbered variant that has a non-zero class byte — roughly +/// matching retail priority (Action < Modifier < SubState < Style). +/// +/// +/// +/// Cited references: +/// +/// +/// references/ACE/Source/ACE.Server/Network/Motion/InterpretedMotionState.cs::Write +/// L138-L144 — writer emits u16 for every command field. +/// +/// +/// references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs — the +/// class bit assignments: 0x80=Style, 0x40=SubState, 0x20=Modifier, +/// 0x10=Action, 0x13 and 0x12=ChatEmote (with Mappable set), etc. +/// +/// +/// docs/research/deepdives/r03-motion-animation.md §3 — complete +/// command catalogue. +/// +/// +/// +/// +public static class MotionCommandResolver +{ + // Lookup table built eagerly at type-init. Sparse: only values that + // appear in the DRW enum (which came from the generated protocol XML) + // are present. ~450 entries typical. + private static readonly Dictionary s_lookup = BuildLookup(); + + /// + /// Given a 16-bit wire value, return the full 32-bit MotionCommand + /// (class byte restored). Returns 0 if no matching enum value exists. + /// + public static uint ReconstructFullCommand(ushort wireCommand) + { + if (wireCommand == 0) return 0u; + s_lookup.TryGetValue(wireCommand, out var full); + return full; + } + + private static Dictionary BuildLookup() + { + var result = new Dictionary(512); + var values = Enum.GetValues(typeof(DRWMotionCommand)); + foreach (DRWMotionCommand v in values) + { + uint full = (uint)v; + ushort lo = (ushort)(full & 0xFFFFu); + if (lo == 0) continue; // Invalid / unmappable + + // If a value with this low-16-bit already exists, keep the one + // with the lower class byte (Action=0x10 beats SubState=0x41 + // beats Style=0x80). This matches retail: the server tends to + // emit Actions and ChatEmotes far more often than Styles, so + // the Action-class reconstruction is the common case. + if (!result.TryGetValue(lo, out var existing) + || (full >> 24) < (existing >> 24)) + { + result[lo] = full; + } + } + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 83b0ab4..d79ce03 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -110,6 +110,74 @@ public class UpdateMotionTests Assert.Null(result.Value.MotionState.ForwardCommand); } + [Fact] + public void ParsesForwardSpeed_WhenSpeedFlagSet() + { + // Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13) + // Test value: 1.5× speed — matches a typical RunRate broadcast. + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // MovementData header + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); + Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed); + } + + [Fact] + public void ParsesCommandsList_Wave() + { + // A typical NPC wave broadcast: + // - stance NonCombat (0x003D) + // - ForwardCommand flag set, command = 0x0003 (Ready) + // - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 } + // + // Packed u32 = (flags | numCommands << 7) + // flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03 + // numCommands << 7 = 1 << 7 = 0x80 + // total = 0x83 + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1 + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready + + // MotionItem: u16 command + u16 packedSeq + f32 speed + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand); + + Assert.NotNull(result.Value.MotionState.Commands); + Assert.Single(result.Value.MotionState.Commands!); + var wave = result.Value.MotionState.Commands![0]; + Assert.Equal((ushort)0x0087, wave.Command); + Assert.Equal(1.0f, wave.Speed); + } + [Fact] public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() { diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs new file mode 100644 index 0000000..a233b02 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13, +/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer +/// routes commands to the wrong MotionTable dict and NPC emotes/attacks +/// silently fail. +/// +public class MotionCommandResolverTests +{ + [Theory] + // SubState / Ready / Movement commands + [InlineData(0x0003, 0x41000003u)] // Ready + [InlineData(0x0005, 0x45000005u)] // WalkForward + [InlineData(0x0007, 0x44000007u)] // RunForward + [InlineData(0x0006, 0x45000006u)] // WalkBackward + [InlineData(0x000D, 0x6500000Du)] // TurnRight + [InlineData(0x000E, 0x6500000Eu)] // TurnLeft + [InlineData(0x000F, 0x6500000Fu)] // SideStepRight + [InlineData(0x0015, 0x40000015u)] // Falling + // Action-class one-shots: melee attacks, death, portals + [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) + [InlineData(0x0058, 0x10000058u)] // ThrustMed + [InlineData(0x005B, 0x1000005Bu)] // SlashHigh + [InlineData(0x0061, 0x10000061u)] // Shoot + [InlineData(0x004B, 0x1000004Bu)] // Jumpup + [InlineData(0x0050, 0x10000050u)] // FallDown + // ChatEmotes (class 0x13) + [InlineData(0x0087, 0x13000087u)] // Wave + [InlineData(0x0080, 0x13000080u)] // Laugh + [InlineData(0x007D, 0x1300007Du)] // BowDeep + public void ReconstructsKnownCommands(ushort wire, uint expected) + { + uint got = MotionCommandResolver.ReconstructFullCommand(wire); + Assert.Equal(expected, got); + } + + [Fact] + public void ZeroWireReturnsZero() + { + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0)); + } + + [Fact] + public void UnknownWireReturnsZero() + { + // 0xFFFF is not a real MotionCommand low-16. + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF)); + } +}