From 9958458318747453dc4ba7c9c4b071b42d44b06e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 13:34:55 +0200 Subject: [PATCH] fix(combat): apply server attack motions locally Route accepted non-autonomous local UpdateMotion state through retail's wholesale interpreted-motion funnel, so ACE-selected melee and missile actions use the normal motion queue and action-stamp gate. Share nullable wire conversion with remotes and remove the local direct command replay. Co-Authored-By: Codex --- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 4 + ...26-07-11-local-combat-motion-pseudocode.md | 81 +++++++++ .../InboundInterpretedMotionFactory.cs | 89 ++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 163 +++--------------- .../InboundInterpretedMotionFactoryTests.cs | 72 ++++++++ 6 files changed, 275 insertions(+), 135 deletions(-) create mode 100644 docs/research/2026-07-11-local-combat-motion-pseudocode.md create mode 100644 src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs create mode 100644 tests/AcDream.App.Tests/Physics/InboundInterpretedMotionFactoryTests.cs diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index af31d050..61847577 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -496,6 +496,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display. - **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. +- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 93fe4cf3..5f6a5022 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -454,6 +454,10 @@ include dungeons. - **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail CreateObject/ParentEvent child attachment and per-frame hand-follow rendering. +- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — + local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted + funnel and action-stamp gate, so ACE's server-selected melee/missile action + drives the player's motion table; direct command-list replay is removed. - **L.1b** — Command router + motion-state cleanup (prereq for L.1c). **Freeze on landing:** diff --git a/docs/research/2026-07-11-local-combat-motion-pseudocode.md b/docs/research/2026-07-11-local-combat-motion-pseudocode.md new file mode 100644 index 00000000..6564c2cb --- /dev/null +++ b/docs/research/2026-07-11-local-combat-motion-pseudocode.md @@ -0,0 +1,81 @@ +# Local combat UpdateMotion — retail receive pseudocode + +Date: 2026-07-11 + +## Question + +Who chooses and plays the local player's melee or missile attack motion? + +## Named-retail oracle + +- `ClientCombatSystem::ExecuteAttack` at `0x0056BB70` sends the targeted + melee/missile request and updates combat-repeat state. It does not choose a + concrete swing command and does not play an animation locally. +- `CPhysics::SetObjectMovement` at `0x00509690` rejects a local autonomous + echo, but accepts a newer non-autonomous movement event, stores + `last_move_was_autonomous = false`, calls `unpack_movement`, then returns 1 + so `CommandInterpreter::LoseControlToServer` runs. +- `MovementManager::unpack_movement` at `0x00524440`, case 0, unpacks an + `InterpretedMotionState`, calls `move_to_interpreted_state`, then applies the + sticky-object and standing-long-jump trailers. +- `CMotionInterp::move_to_interpreted_state` at `0x005289C0` copies the state + wholesale, reapplies style/forward/side/turn, and dispatches fresh command + list entries under the 15-bit `server_action_stamp` gate. The player skips + only command entries whose autonomous bit is set. + +## ACE cross-reference + +ACE's `Player_Melee.DoSwingMotion` selects the actual maneuver from the combat +table, constructs a `Motion`, marks the target sticky, and broadcasts it. +Missile attacks similarly enqueue the server-selected aim action, followed by +reload and Ready. `MovementData.IsAutonomous` documents the wire split as +client-initiated `true`, server-initiated `false`. + +## Retail pseudocode + +```text +on local attack input: + send TargetedMeleeAttack or TargetedMissileAttack + do not invent or play a swing locally + +on UpdateMotion for local player: + apply movement and server-control sequence gates + if event.isAutonomous: + drop the entire event + + player.lastMoveWasAutonomous = false + interrupt current move + unstick + apply outer style if it changed + + if movementType is MoveTo/TurnTo: + route to MoveToManager + return + + if movementType == InterpretedMotionState: + ims = constructor defaults (NonCombat, Ready, speeds 1) + unpack every present field over ims + move_to_interpreted_state(ims) + copy every axis wholesale + apply style, forward, sidestep, turn + for each fresh command-list entry: + if local player and entry.autonomous: skip + dispatch entry through the motion table + apply sticky target trailer + write standingLongJump, including false + + command interpreter loses control to server + next local input edge takes autonomous control back +``` + +## acdream correction + +The local branch already had the autonomous-event rejection and the +interrupt/style/MoveTo routing, but deliberately skipped the mt-0 wholesale +copy. It then replayed `Commands[]` directly through +`AnimationCommandRouter`, bypassing retail's action-stamp and local-autonomous +gates. The correction routes local mt-0 through the existing +`MotionInterpreter.MoveToInterpretedState` and persistent motion-table sink, +exactly like remotes, and removes the direct replay. A shared App-layer factory +now owns nullable-wire-to-constructor-default conversion for both branches. + diff --git a/src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs b/src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs new file mode 100644 index 00000000..3409b9db --- /dev/null +++ b/src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs @@ -0,0 +1,89 @@ +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Converts the nullable wire fields from an mt-0 UpdateMotion payload into +/// retail's wholesale value. +/// +/// +/// +/// Retail constructs an InterpretedMotionState, whose constructor +/// supplies NonCombat/Ready/1.0 defaults, then calls UnPack over it +/// before MovementManager::move_to_interpreted_state copies every +/// field. See MovementManager::unpack_movement at 0x00524440 and +/// CMotionInterp::move_to_interpreted_state at 0x005289c0. +/// +/// +/// Keeping this conversion outside GameWindow gives local and remote +/// actors one definition of absent-field semantics and one command-list +/// reconstruction path. +/// +/// +internal static class InboundInterpretedMotionFactory +{ + private const uint NonCombatStyle = 0x8000003Du; + private const uint Ready = 0x41000003u; + + public static InboundInterpretedState Create( + in CreateObject.ServerMotionState wire, + uint fallbackForwardClass = 0x40000000u) + { + var result = new InboundInterpretedState + { + CurrentStyle = wire.Stance != 0 + ? 0x80000000u | wire.Stance + : NonCombatStyle, + ForwardCommand = ResolveForward(wire.ForwardCommand, fallbackForwardClass), + ForwardSpeed = wire.ForwardSpeed ?? 1f, + SideStepCommand = ResolveAxis(wire.SideStepCommand, 0x65000000u), + SideStepSpeed = wire.SideStepSpeed ?? 1f, + TurnCommand = ResolveAxis(wire.TurnCommand, 0x65000000u), + TurnSpeed = wire.TurnSpeed ?? 1f, + }; + + if (wire.Commands is { Count: > 0 } commands) + { + var actions = new List(commands.Count); + foreach (var item in commands) + { + uint command = MotionCommandResolver.ReconstructFullCommand(item.Command); + if (command == 0) + command = 0x10000000u | item.Command; + + actions.Add(new InboundMotionAction( + command, + Stamp: item.PackedSequence & 0x7FFF, + Autonomous: (item.PackedSequence & 0x8000) != 0, + Speed: item.Speed)); + } + + result.Actions = actions; + } + + return result; + } + + private static uint ResolveForward(ushort? wireCommand, uint fallbackClass) + { + if (wireCommand is not { } command || command == 0) + return Ready; + + uint resolved = MotionCommandResolver.ReconstructFullCommand(command); + if (resolved != 0) + return resolved; + + uint commandClass = fallbackClass & 0xFF000000u; + return (commandClass != 0 ? commandClass : 0x40000000u) | command; + } + + private static uint ResolveAxis(ushort? wireCommand, uint fallbackClass) + { + if (wireCommand is not { } command || command == 0) + return 0; + + uint resolved = MotionCommandResolver.ReconstructFullCommand(command); + return resolved != 0 ? resolved : fallbackClass | command; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 185efe51..9cc5ea69 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4877,27 +4877,13 @@ public sealed class GameWindow : IDisposable // No-op if same; the sequencer's fast path guards against that. uint priorMotion = ae.Sequencer.CurrentMotion; - // CRITICAL: for the local player, UpdatePlayerAnimation is the - // authoritative driver of the sequencer. ACE's BroadcastMovement - // echoes the player's own motion back, but: - // (a) ACE's own ForwardSpeed is `creature.GetRunRate()`, which - // may differ from our locally-computed runRate (ACDREAM_RUN_SKILL - // vs real server-side skills). - // (b) ACE omits the ForwardSpeed flag when speed == 1.0 (per - // InterpretedMotionState.BuildMovementFlags). When omitted, - // our wire parser returns null and we'd default to 1.0 — - // clobbering our locally-authoritative 2.375 × animScale - // and leaving the legs at 30 fps cadence regardless of - // actual run rate. - // So: for the player's own guid, skip the wire-echo SetCycle. - // UpdatePlayerAnimation has already set the correct cycle with - // our locally-chosen speedMod, and that value should persist - // until the next local motion change. + // The SetObjectMovement gate above already rejects local + // autonomous echoes. A local event reaching this point is + // server-authored, so retail applies it through the interpreted + // funnel. This is especially important for attacks: ACE chooses + // the exact swing and carries it in Commands[]. if (update.Guid == _playerServerGuid) { - // Still update the stance echo (_playerMotionTableId, etc) via - // the paths above, but don't stomp the animation sequencer. - // B.6 slice 1 (2026-05-14): trace inbound motion for the // local player. One line per inbound UM, gated on // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). @@ -4927,9 +4913,9 @@ public sealed class GameWindow : IDisposable // already dropped the autonomous echoes that would have // made this unsafe against ACE); then types 6-9 route to // the player's MoveToManager. mt-0 falls through to the - // existing skip-sequencer posture (the interpreted-state - // copy + LoseControlToServer autonomy handoff is - // R5/MovementManager scope — V0-pins.md P1 adjacent seam). + // interpreted-state copy below; LastMoveWasAutonomous=false + // is the local equivalent of LoseControlToServer until the + // next user-input edge takes control back. if (_playerController is not null) { // P1 tail (00509730): the unpack path stores the wire @@ -4978,16 +4964,23 @@ public sealed class GameWindow : IDisposable return; } - // R5-V4: retail unpack_movement case-0 TAIL for the local - // player (@00524583-0052458e) — stick_to_object when the - // motionFlags 0x1 trailer carried a guid, then - // standing_longjump ← motionFlags 0x2 (UNCONDITIONAL — - // an absent flag clears it). The interpreted-state COPY - // stays skipped for the local player (the - // sequencer-authority posture above); these two writes - // are the only case-0 effects that apply here. + // Retail case-0 order: wholesale interpreted-state copy, + // then stick_to_object, then the unconditional + // standing_longjump flag write (@00524551-0052458e). if (update.MotionState.MovementType == 0) { + // Retail MovementManager::unpack_movement case 0 + // (0x00524440) applies the constructor-defaulted wire + // state wholesale for the local player too. This is + // how ACE's server-selected combat action reaches the + // player's motion table; autonomous movement echoes + // were already rejected by the gate above. + var playerIms = AcDream.App.Physics.InboundInterpretedMotionFactory.Create( + update.MotionState, + ae.Sequencer.CurrentMotion & 0xFF000000u); + _playerController.Motion.MoveToInterpretedState( + playerIms, _playerController.Motion.DefaultSink); + if (update.MotionState.StickyObjectGuid is { } playerSticky && playerSticky != 0) StickToObjectFromWire(_playerHost, playerSticky); @@ -5084,34 +5077,13 @@ public sealed class GameWindow : IDisposable remoteMot.PrevServerPosTime = 0.0; } - // Build the funnel input. fullMotion/speedMod already - // encode retail's absent-field forward defaults (null/0 - // → Ready; mt 6-9 never reach here post-V4). Style: - // retail's InterpretedMotionState::UnPack (0x0051f400) - // defaults an absent stance to NonCombat 0x8000003D — - // NOT keep-current (S0 trace: every empty UM applied - // 0x8000003d on the retail observer). - var ims = new AcDream.Core.Physics.InboundInterpretedState - { - CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du, - ForwardCommand = fullMotion, - ForwardSpeed = speedMod, - SideStepCommand = 0u, - SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f, - TurnCommand = 0u, - TurnSpeed = update.MotionState.TurnSpeed ?? 1f, - }; - if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) - { - uint sideFull = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(sideCmd16); - ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16); - } + // Build the constructor-defaulted wholesale state through + // the SAME converter used by the local-player path. + var ims = AcDream.App.Physics.InboundInterpretedMotionFactory.Create( + update.MotionState, + ae.Sequencer.CurrentMotion & 0xFF000000u); if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0) { - uint turnFull = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(turnCmd16); - ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16); if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( @@ -5120,29 +5092,6 @@ public sealed class GameWindow : IDisposable } } - // Command list → the funnel's action list. Retail applies - // EVERY entry via DoInterpretedMotion under the 15-bit - // server_action_stamp gate — the gate is what makes ACE's - // re-bundled stale entries (e.g. the Ready bundled into a - // RunForward UM, 2026-05-03 finding) inert, replacing the - // old skip-SubState-class workaround. - if (update.MotionState.Commands is { Count: > 0 } cmdList) - { - var actionList = new System.Collections.Generic.List(cmdList.Count); - foreach (var item in cmdList) - { - uint full = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(item.Command); - if (full == 0) full = 0x10000000u | item.Command; - actionList.Add(new AcDream.Core.Physics.InboundMotionAction( - full, - Stamp: item.PackedSequence & 0x7FFF, - Autonomous: (item.PackedSequence & 0x8000) != 0, - Speed: item.Speed)); - } - ims.Actions = actionList; - } - // R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the // motion-table stack (GetObjectSequence + is_allowed // decide) — mt-0 only post-V4 (types 6-9 returned above). @@ -5192,62 +5141,6 @@ public sealed class GameWindow : IDisposable dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } - // 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. - // - // 2026-05-03: SKIP SubState class commands (high-byte 0x40-0x4F). - // The animCycle picker above already chose the correct SubState - // cycle based on Forward/Sidestep/Turn command priority and just - // called SetCycle for it. Letting the Commands list also call - // SetCycle(SubState) would OVERRIDE our chosen cycle — e.g. ACE - // bundles Ready (0x41000003) into the Commands list of a - // RunForward UpdateMotion (cdb trace 2026-05-03 confirmed retail - // does the same), and our router would silently re-cycle the - // sequencer back to Ready right after we set RunForward. That's - // why observed retail-driven characters never visibly switched - // their leg cycle even though SETCYCLE diags fired correctly: - // a second SetCycle call wiped the first within the same UM - // packet processing. Only Actions/Modifiers/ChatEmotes (overlays - // that interleave with the cycle) belong in the list iteration. - if (update.Guid == _playerServerGuid // L.2g S2b: LOCAL ONLY — remote command - // lists flow through the funnel's - // action-stamp gate (retail's actual - // mechanism for bundled stale entries) - && update.MotionState.Commands is { Count: > 0 } cmds) - { - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - var sb = new System.Text.StringBuilder(); - sb.Append($"[CMD_LIST] guid={update.Guid:X8} fwd=0x{fullMotion:X8} cmds=["); - for (int i = 0; i < cmds.Count; i++) - { - if (i > 0) sb.Append(", "); - uint fc = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(cmds[i].Command); - var rt = AcDream.Core.Physics.AnimationCommandRouter.Classify(fc); - sb.Append($"0x{fc:X8}({rt})"); - } - sb.Append("]"); - System.Console.WriteLine(sb.ToString()); - } - foreach (var item in cmds) - { - uint fullItemCommand = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(item.Command); - var itemRoute = AcDream.Core.Physics.AnimationCommandRouter - .Classify(fullItemCommand); - if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState) - continue; - AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( - ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)` - fullStyle, - item.Command, - item.Speed); - } - } return; } diff --git a/tests/AcDream.App.Tests/Physics/InboundInterpretedMotionFactoryTests.cs b/tests/AcDream.App.Tests/Physics/InboundInterpretedMotionFactoryTests.cs new file mode 100644 index 00000000..5b5336ae --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/InboundInterpretedMotionFactoryTests.cs @@ -0,0 +1,72 @@ +using AcDream.App.Physics; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.Physics; + +public sealed class InboundInterpretedMotionFactoryTests +{ + [Fact] + public void EmptyWireStateUsesRetailConstructorDefaults() + { + var wire = new CreateObject.ServerMotionState(0, null); + + var result = InboundInterpretedMotionFactory.Create(wire); + + Assert.Equal(0x8000003Du, result.CurrentStyle); + Assert.Equal(0x41000003u, result.ForwardCommand); + Assert.Equal(1f, result.ForwardSpeed); + Assert.Equal(0u, result.SideStepCommand); + Assert.Equal(1f, result.SideStepSpeed); + Assert.Equal(0u, result.TurnCommand); + Assert.Equal(1f, result.TurnSpeed); + Assert.Null(result.Actions); + } + + [Fact] + public void AttackCommandPreservesStampAutonomyAndSpeed() + { + var commands = new[] + { + new CreateObject.MotionItem( + Command: 0x0058, // ThrustMed + PackedSequence: 0x8007, + Speed: 1.75f), + }; + var wire = new CreateObject.ServerMotionState( + Stance: 0x3E, + ForwardCommand: 0x0003, + Commands: commands); + + var result = InboundInterpretedMotionFactory.Create(wire); + + Assert.Equal(0x8000003Eu, result.CurrentStyle); + Assert.NotNull(result.Actions); + var action = Assert.Single(result.Actions); + Assert.Equal(0x10000058u, action.Command); + Assert.Equal(7, action.Stamp); + Assert.True(action.Autonomous); + Assert.Equal(1.75f, action.Speed); + } + + [Fact] + public void AxesAndForwardCommandsUseSharedRuntimeCatalog() + { + var wire = new CreateObject.ServerMotionState( + Stance: 0x3F, + ForwardCommand: 0x0007, + ForwardSpeed: 2.85f, + SideStepCommand: 0x000F, + SideStepSpeed: -1.2f, + TurnCommand: 0x000D, + TurnSpeed: 1.5f); + + var result = InboundInterpretedMotionFactory.Create(wire); + + Assert.Equal(0x44000007u, result.ForwardCommand); + Assert.Equal(2.85f, result.ForwardSpeed); + Assert.Equal(0x6500000Fu, result.SideStepCommand); + Assert.Equal(-1.2f, result.SideStepSpeed); + Assert.Equal(0x6500000Du, result.TurnCommand); + Assert.Equal(1.5f, result.TurnSpeed); + } +}