diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 55b093d..c4cb6ee 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -306,6 +306,49 @@ with retail's MMB-hold mouse-look.
---
+### Phase L.1 — Animation system completion
+
+**Status:** IN PROGRESS on `feature/animation-system-complete`.
+
+**Goal:** complete the retail-faithful animation surface beyond the
+locomotion/jump K-fix series: combat swings, spell casting, emotes, death,
+item-use, NPC/monster special actions, remote observer parity, and the
+remaining floating-point polish around style transitions, modifiers, action
+queues, speed scaling, and PosFrame root motion.
+
+**Plan of record:** `docs/plans/animation-system-audit.md`.
+
+**Sub-pieces:**
+- **L.1a — Audit & inventory.** Map retail named-decomp evidence, ACE
+ cross-references, existing acdream hook points, and current gaps for each
+ animation category. Output: `docs/plans/animation-system-audit.md`.
+- **L.1b — Command router + motion-state cleanup.** Extract tested
+ `SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
+ and split death `Sanctuary` action from persistent `Dead` substate.
+- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
+ style transitions, attack swings by stance/power/height, hit reactions,
+ evades/blocks/parries, and death handoff.
+- **L.1d — Spell casting wiring.** Cast command classification, windup,
+ release, fizzle/interruption, recoil, and school/effect distinctions.
+- **L.1e — Emotes + postures.** Outbound slash emotes, inbound
+ command-list emotes, and persistent sit/lie/kneel/sleep states.
+- **L.1f — NPC/monster + item-use coverage.** Scripted gestures, monster
+ special actions, potion/food/scroll/recall cycles, and remote parity.
+- **L.1g — Polish + conformance.** Style-transition chain, durable
+ modifiers/action queues, root-motion handling, speed scaling, and broad
+ synthetic MotionTable tests.
+
+**Acceptance:**
+- `dotnet build` and `dotnet test` green at each commit.
+- Test count grows by at least 30 with one representative cycle/action test
+ per major animation category.
+- Every AC-specific behavior cites named retail decomp or ACE/holtburger
+ cross-reference evidence in code comments, tests, or commit notes.
+- User visual sign-off for local and remote attack, spell, emote, death, and
+ item-use animation parity before marking shipped.
+
+---
+
### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.
diff --git a/docs/plans/animation-system-audit.md b/docs/plans/animation-system-audit.md
new file mode 100644
index 0000000..5d6cc54
--- /dev/null
+++ b/docs/plans/animation-system-audit.md
@@ -0,0 +1,557 @@
+# Animation System Audit
+
+Phase A audit for `feature/animation-system-complete`.
+
+Date: 2026-04-28.
+
+## Summary
+
+The animation core is much stronger than the feature surface around it.
+`AnimationSequencer` already handles cyclic state changes, transition links,
+negative-speed retail remaps, mid-cycle speed changes, frame hooks, and
+PosFrame root-motion accumulation. `GameWindow.OnLiveMotionUpdated` already
+routes `InterpretedMotionState.Commands[]` through `PlayAction`, which means
+server-broadcast NPC/monster/emote/action overlays are likely to animate when
+the server emits motion commands.
+
+The remaining gap is mostly orchestration:
+
+- local combat/spell/item-use commands build wire packets but do not yet drive
+ the local visible action immediately;
+- several combat/spell/emote packet surfaces need conformance fixes before
+ animation triggers can be trusted: combat mode enum values, split
+ melee/missile attack builders, `CombatCommenceAttack`, `AttackDone`,
+ damage/death notification parsers, `MagicSchool` enum order, and outbound
+ emote/soul-emote builders;
+- combat/spell/item-use game events populate state/chat but do not yet map to
+ animation overlays for attacker/defender/caster;
+- style changes are handled as simple `SetCycle(style, motion)` swaps, not the
+ full ACE `MotionTable.GetObjectSequence` multi-link style transition chain;
+- held posture/emote commands need a small command resolver and tests around
+ one-shot-vs-persistent routing;
+- death needs explicit `Sanctuary` action -> `Dead/Fallen` persistence rather
+ than relying on chat/health side effects.
+
+## Evidence Sources
+
+Named retail decomp:
+
+- `CMotionTable::is_allowed` at `0x005226C0`
+- `CMotionTable::get_link` at `0x00522710`
+- `CSequence::update_internal` at `0x005255D0`
+- `CMotionInterp::adjust_motion` at `0x00528010`
+- `CMotionInterp::charge_jump` at `0x005281C0`
+- `CMotionInterp::get_jump_v_z` at `0x00527AA0`
+- `CMotionInterp::jump` at `0x00528780`
+- `CMotionInterp::apply_current_movement` at `0x00528870`
+- `CMotionInterp::HitGround` at `0x00528AC0`
+- `CMotionInterp::LeaveGround` at `0x00528B00`
+- `CMotionInterp::DoMotion` at `0x00528D20`
+- `CMotionInterp::DoInterpretedMotion` at `0x00528360`
+- `ClientCombatSystem::HandleCommenceAttackEvent` at `0x0056AD20`
+- `ClientCombatSystem::SetCombatMode` at `0x0056BE30`
+- `ClientCombatSystem::StartAttackRequest` at `0x0056C040`
+- `ClientCombatSystem::EndAttackRequest` at `0x0056C0E0`
+- `ClientCombatSystem::StartPowerBarBuild` at `0x0056ADB0`
+- `ClientCombatSystem::GetPowerBarLevel` at `0x0056ADE0`
+- `ClientCombatSystem::ExecuteAttack` at `0x0056BB70`
+- `ClientCombatSystem::HandleDefenderNotificationEvent` at `0x0056C920`
+- `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` at `0x0056C620`
+- `ClientCombatSystem::HandlePlayerDeathEvent` at `0x0056C320`
+- `ClientCombatSystem::HandleAttackerNotificationEvent` at `0x0056B420`
+- `ClientCombatSystem::HandleAttackDoneEvent` at `0x0056C500`
+- `CM_Combat::Event_ChangeCombatMode` at `0x006A9A70`
+- `CM_Combat::Event_TargetedMeleeAttack` at `0x006A9C10`
+- `CM_Combat::Event_TargetedMissileAttack` at `0x006A9D60`
+- `AttackHook::Execute` at `0x00526B70`
+- `gmSpellcastingUI::Cast` at `0x004C6050`
+- `ClientMagicSystem::CastSpell` at `0x00568040`
+- `ClientMagicSystem::FreeHandsAndCastSpell` at `0x00566EF0`
+- `ClientMagicSystem::GetAppropriateSpellFormula` at `0x00567D50`
+- `CM_Magic::Event_CastUntargetedSpell` at `0x006A3150`
+- `CM_Magic::Event_CastTargetedSpell` at `0x006A3040`
+- `ItemHolder::UseObject` at `0x00588A80`
+- `CM_Inventory::Event_UseEvent` at `0x006AC3B0`
+- `CM_Inventory::Event_UseWithTargetEvent` at `0x006AC480`
+- `CM_Item::DispatchUI_UseDone` at `0x006A8510`
+- `CommandInterpreter::PlayerIsDead` at `0x006B3D70`
+- `SmartBox::HandlePlayScriptID` at `0x00452020`
+- `CM_Physics::DispatchSB_PlayScriptID` at `0x006ACC40`
+- `CM_Physics::DispatchSB_PlayScriptType` at `0x006AC6E0`
+- `ClientCommunicationSystem::DoEmote` at `0x00578AD0`
+- `ClientCommunicationSystem::Pose` at `0x00580480`
+- `ClientCommunicationSystem::Handle_Communication__HearEmote` at
+ `0x0057CBE0`
+- `ClientCommunicationSystem::Handle_Communication__HearSoulEmote` at
+ `0x0057D020`
+- `ChatPoseTable::InqChatPoseCommand` at `0x00570AD0`
+- `ChatEmoteData::Pack` at `0x004FCE80`
+
+Cross-reference material:
+
+- `docs/research/deepdives/r03-motion-animation.md`
+- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionTable.cs`
+- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Server\Physics\Animation\MotionInterp.cs`
+- `C:\Users\erikn\source\repos\acdream\references\ACE\Source\ACE.Entity\Enum\MotionCommand.cs`
+- `C:\Users\erikn\source\repos\acdream\references\holtburger\apps\holtburger-cli\src\pages\game\combat.rs`
+- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-core\src\client\messages.rs`
+- `C:\Users\erikn\source\repos\acdream\references\holtburger\crates\holtburger-protocol\src\messages\movement\types.rs`
+
+The clean worktree intentionally does not contain `references/`; it was read
+read-only from the original checkout path above.
+
+## Current Code Surface
+
+Core animation:
+
+- `src/AcDream.Core/Physics/AnimationSequencer.cs`
+ - `SetCycle(style, motion, speedMod, skipTransitionLink)` handles cyclic
+ state changes and transition links.
+ - `PlayAction(motionCommand, speedMod)` handles Action, Modifier, and
+ ChatEmote one-shots through Links/Modifiers lookup.
+ - `Advance(dt)` emits pending hooks and accumulates PosFrame deltas.
+ - Missing: full style-transition chain, durable modifier list, action queue
+ accounting, and a public command-resolution facade that callers can test
+ without `GameWindow`.
+- `src/AcDream.Core/Physics/MotionInterpreter.cs`
+ - Handles locomotion, jump, leave-ground/hit-ground, and basic contact
+ guards.
+ - Missing: full retail `MotionState`, action list, modifier list, hold-key
+ run application, combat-state guards, and `move_to_interpreted_state`.
+- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
+ - Reconstructs full 32-bit commands from 16-bit wire values.
+
+App integration:
+
+- `src/AcDream.App/Input/PlayerMovementController.cs`
+ - Local walk/run/strafe/turn/jump driver. It does not own combat/spell/item
+ action animation.
+- `src/AcDream.App/Rendering/GameWindow.cs`
+ - `OnLiveMotionUpdated` is the main inbound motion/action router.
+ - `OnLiveVectorUpdated` seeds airborne jump arcs and Falling cycles.
+ - `OnLivePositionUpdated` snaps positions and lands airborne remotes.
+ - `TickAnimations` advances sequencers and drains hooks.
+ - `UpdatePlayerAnimation` drives the local movement cycle.
+ - Missing: typed animation coordinator for combat/spell/use/death/emote
+ events; too much command mapping still lives inline.
+
+Wire/state:
+
+- `src/AcDream.Core.Net/Messages/AttackTargetRequest.cs`: outbound attack
+ request exists, but currently combines melee and missile into one layout;
+ retail/ACE/holtburger use distinct `0x0008` melee and `0x000A` missile
+ payloads.
+- `src/AcDream.Core.Net/Messages/CastSpellRequest.cs`: outbound spell request
+ exists.
+- `src/AcDream.Core.Net/Messages/CharacterActions.cs`: combat mode request
+ exists, but the combat-mode enum must be corrected to retail values
+ `NonCombat=1`, `Melee=2`, `Missile=4`, `Magic=8`.
+- `src/AcDream.Core.Net/Messages/InteractRequests.cs`: use/use-with-target
+ request exists.
+- `src/AcDream.Core.Net/GameEventWiring.cs`: combat, spell, item, chat events
+ route into state classes.
+- Missing: public `WorldSession.SendAttack/SendCast/SendUse/ChangeCombatMode`
+ wrappers and animation-side subscriptions.
+
+## Retail Command Catalogue To Use
+
+From ACE `MotionCommand.cs` + `r03-motion-animation.md`:
+
+- Locomotion substates: Ready `0x41000003`, WalkForward `0x45000005`,
+ WalkBackward `0x45000006`, RunForward `0x44000007`, TurnRight
+ `0x6500000D`, TurnLeft `0x6500000E`, SideStepRight `0x6500000F`,
+ SideStepLeft `0x65000010`, Falling `0x40000015`.
+- Held/posture substates: Crouch `0x41000012`, Sitting `0x41000013`,
+ Sleeping `0x41000014`, Dead `0x40000011`, Fallen `0x40000008`.
+- Item/use substates: Reload `0x40000016`, Unload `0x40000017`, Pickup
+ `0x40000018`, StoreInBackpack `0x40000019`, Eat `0x4000001A`, Drink
+ `0x4000001B`, Reading `0x4000001C`.
+- Spell substates/actions: CastSpell `0x400000D3`, MagicBlast
+ `0x4000002B`, MagicSelfHead `0x4000002C`, MagicSelfHeart `0x4000002D`,
+ MagicBonus..MagicPenalty `0x4000002E..0x40000034`, MagicTransfer
+ `0x40000035`, MagicEnchantItem `0x40000037`, MagicPortal `0x40000038`,
+ MagicPray `0x40000039`, MagicPowerUp01..10 `0x1000006F..0x10000078`,
+ MagicPowerUp01Purple..10Purple `0x1000012B..0x10000134`.
+- Combat actions: Sanctuary `0x10000057`, ThrustMed/Low/High
+ `0x10000058..0x1000005A`, SlashHigh/Med/Low `0x1000005B..0x1000005D`,
+ BackhandHigh/Med/Low `0x1000005E..0x10000060`, Shoot `0x10000061`,
+ AttackHigh/Med/Low1..6 `0x10000062..0x1000006A` and
+ `0x10000186..0x1000018E`, MissileAttack1..3 `0x100000D0..0x100000D2`,
+ SpecialAttack1..3 `0x100000CD..0x100000CF`, dual-wield/offhand ranges
+ `0x10000173..0x1000019A`.
+- ChatEmote actions: Wave `0x13000087`, BowDeep `0x1300007D`, Laugh
+ `0x13000080`, Point `0x13000084`, Salute `0x1300008A`, Kneel
+ `0x13000092`, HaveASeat `0x13000152`, DrudgeDance `0x13000151`, plus
+ the full `0x1200/0x1300` ranges in `r03`.
+- Persistent emote states: `0x430000EA..0x430000FD`, SnowAngelState
+ `0x43000118`, CurtseyState `0x4300011A`, AFKState `0x4300011B`,
+ MeditateState `0x4300011C`, SitState `0x4300013A`,
+ SitCrossleggedState `0x4300013B`, SitBackState `0x4300013C`,
+ PossumState `0x43000142`, HaveASeatState `0x43000145`. ACE's enum is a
+ useful alias catalog but has a shifted range for some late chat-emote states;
+ named-retail values win when hard-coding constants.
+
+## Category Audit
+
+### 1. Own Player Movement
+
+Status: mostly working.
+
+Evidence: retail `CMotionInterp` jump/grounding symbols listed above; ACE
+`MotionInterp.cs` for `adjust_motion`, `apply_current_movement`, `HitGround`,
+and `LeaveGround`.
+
+acdream locations: `PlayerMovementController`, `MotionInterpreter`,
+`UpdatePlayerAnimation`, `OnLiveVectorUpdated`, `OnLivePositionUpdated`.
+
+Gaps:
+
+- held postures exist as retail commands but are not driven by a general
+ posture/action API;
+- `MotionInterpreter` does not yet own full `MotionState`, so non-locomotion
+ commands cannot be uniformly tested there;
+- mounted/swimming need dat/retail verification before any implementation.
+
+Tests to add:
+
+- posture state `SetCycle` tests for Crouch/Sitting/Sleeping;
+- `motion_allows_jump` conformance for item/spell/aim/posture ranges;
+- local action does not stomp Falling while airborne.
+
+### 2. Other Players' Movement
+
+Status: partially working after the K-fix series.
+
+Evidence: `UpdateMotion` handling in `OnLiveMotionUpdated`; retail
+`CMotionInterp::DoInterpretedMotion` and `apply_current_movement`; ACE
+`MotionInterp.apply_current_movement`.
+
+acdream locations: `RemoteMotion`, `OnLiveMotionUpdated`,
+`OnLiveVectorUpdated`, `OnLivePositionUpdated`, `TickAnimations`.
+
+Gaps:
+
+- remote action overlays only happen when the server includes
+ `InterpretedMotionState.Commands[]`; combat/spell game events do not yet
+ synthesize overlays when the wire omits motion commands;
+- no test fixture exercises `OnLiveMotionUpdated` command-list routing outside
+ `GameWindow`;
+- root-motion deltas are accumulated but not applied to remote body transforms.
+
+Tests to add:
+
+- command-list `Wave` -> `PlayAction` routing through a new coordinator;
+- airborne remote ignores mid-arc locomotion cycle swaps but still updates
+ interpreted movement;
+- landing swaps Falling back to current interpreted command.
+
+### 3. NPC Movement
+
+Status: likely works for UpdateMotion-driven locomotion and simple gestures;
+not verified.
+
+Evidence: retail MotionTable/InterpretedMotionState path; ACE
+`MotionTable.GetObjectSequence` and `MotionInterp.move_to_interpreted_state`.
+
+acdream locations: `CreateObject.ParseServerMotionState`,
+`OnLiveMotionUpdated`, `TickAnimations`.
+
+Gaps:
+
+- no NPC-specific live test checklist;
+- no retained action/modifier list, so repeated scripted gestures are
+ fire-and-forget overlays only;
+- no head-look/threat-pose state beyond whatever arrives as motion commands.
+
+Tests to add:
+
+- synthetic NPC `UpdateMotion` with `Commands=[Wave, Ready]` plays one-shot
+ then returns to Ready;
+- style-default fallback for creature motion tables.
+
+### 4. Monster Movement
+
+Status: locomotion probably works when `MotionTableId` and UpdateMotion are
+present; special attacks are unknown.
+
+Evidence: ACE MotionTable supports monster actions such as HeadThrow,
+FistSlam, BreatheFlame, SpinAttack, Bite, SpecialAttack1..3.
+
+acdream locations: same as NPC movement; `AnimationHookRouter` for VFX/audio
+side effects.
+
+Gaps:
+
+- attack action overlays for monsters depend on server motion command lists;
+- no mapping from combat events to visible monster attack/hit reactions;
+- no exotic creature spot-checks.
+
+Tests to add:
+
+- `PlayAction(BreatheFlame)` resolves from Links/Modifiers when synthetic data
+ provides it;
+- Attack hooks fire exactly once for a synthetic monster action.
+
+### 5. Combat Actions
+
+Status: wire codecs and combat state exist; visual action orchestration is
+missing for local and event-driven paths.
+
+Evidence:
+
+- retail `ClientCombatSystem::StartPowerBarBuild`,
+ `ClientCombatSystem::GetPowerBarLevel`, `ClientCombatSystem::ExecuteAttack`,
+ `HandleCommenceAttackEvent`, `HandleAttackerNotificationEvent`,
+ `HandleAttackDoneEvent`;
+- ACE `MotionTable.GetAttackFrames` scans Attack hooks and is the canonical
+ hit-frame source;
+- holtburger combat UI tracks `AttackCommenced`, `AttackDone`, victim,
+ attacker, defender, evasion, and killed feedback as runtime state.
+
+acdream locations:
+
+- `AttackTargetRequest` exists but no `WorldSession.SendAttack` wrapper was
+ found;
+- `CombatState` emits `DamageTaken`, `DamageDealtAccepted`, evasion,
+ `AttackDone`, and `KillLanded`;
+- `GameEventWiring` registers combat event parsers;
+- `AnimationSequencer.PlayAction` can play the swing once the command is known.
+
+Gaps:
+
+- combat-mode enum values are currently non-retail for missile/magic;
+- melee/missile attack request builders need to be split to retail layouts:
+ `0x0008 targetGuid, attackHeight, power` and
+ `0x000A targetGuid, attackHeight, accuracy`;
+- `CombatCommenceAttack (0x01B8)` is enumerated but not parsed/wired;
+- `AttackDone (0x01A7)` and attacker/defender/death notification parsers need
+ ACE/holtburger fixtures before downstream animation can trust them;
+- `CombatState` has no `CurrentMode`, no attack sequence active flag, no
+ selected target, and no power-bar state;
+- no local predictive swing on attack request;
+- hit reactions (Twitch/Stagger/Tipped/FallDown) are not mapped from defender
+ notifications;
+- style changes for draw/sheath do not run the full style-transition chain.
+
+Tests to add:
+
+- parse/wire `CombatCommenceAttack`;
+- `CombatAnimationCoordinator` maps height/power/style to attack command;
+- defender hit quadrant maps to a stable flinch command;
+- `AttackHook` dispatch is one-shot.
+
+### 6. Spell Casting
+
+Status: outbound cast packets and spellbook/enchantment state exist; visible
+cast-stage animation is missing.
+
+Evidence:
+
+- retail `ClientMagicSystem::CastSpell` and `FreeHandsAndCastSpell`;
+- `gmSpellcastingUI::Cast` calls `ClientMagicSystem::CastSpell`;
+- outbound cast actions are `0x0048` untargeted (`spellId`) and `0x004A`
+ targeted (`targetGuid`, `spellId`);
+- retail/ACE school order is `War=1`, `Life=2`, `Item=3`, `Creature=4`,
+ `Void=5`;
+- MotionCommand spell catalogue above;
+- `GameEventWiring` wires spellbook/enchantment updates but not casting
+ animation.
+
+acdream locations:
+
+- `CastSpellRequest` targeted/untargeted builders;
+- `Spellbook`, `SpellTable`, `GameEventWiring` spell handlers;
+- `AnimationHookRouter` already routes hooks to audio/VFX sinks.
+
+Gaps:
+
+- no cast coordinator reading server `UpdateMotion`, spellcasting chat,
+ `PlayScript.Fizzle`, `UseDone`, and errors into one local cast timeline;
+- no fizzle/interruption animation mapping from `PlayScript.Fizzle = 0x51`
+ (ACE sends speed `0.5`) and `WeenieError`;
+- no recoil/release state;
+- no local immediate cast animation on request.
+- `MagicSchool` enum currently needs conformance against the retail/ACE order.
+
+Tests to add:
+
+- spell school/effect classifier maps to MagicBlast/MagicSelf/MagicPortal;
+- fizzle error maps to a one-shot action or recovery state once retail
+ command is confirmed;
+- cast request triggers local action overlay without waiting for enchantment.
+
+### 7. Emotes
+
+Status: inbound text parsers and chat display exist; motion command-list
+emotes likely animate if server emits them. Slash-command-to-emote wire and
+text-event-to-animation are missing.
+
+Evidence:
+
+- retail `ClientCommunicationSystem::DoEmote`, `HelpEmote`,
+ `DoEmoteList`, `InitializeEmoteInputActionHash`;
+- retail `ClientCommunicationSystem::Pose` looks up a token in
+ `ChatPoseTable`, issues the motion command locally, then sends SoulEmote;
+- `ChatEmoteData::Pack`;
+- ACE MotionCommand ChatEmote range.
+
+acdream locations:
+
+- `EmoteText` and `SoulEmote` top-level parsers;
+- `ChatLog.OnEmote` / `OnSoulEmote`;
+- `GameWindow.OnLiveMotionUpdated` command-list `PlayAction` route.
+
+Gaps:
+
+- no outbound `Communication_Emote (0x01DF)` or
+ `Communication_SoulEmote (0x01E1)` GameAction builder found;
+- `MoveToState` currently writes zero command-list entries, so the client
+ cannot yet send pose/emote commands in the retail motion-state path;
+- `ChatInputParser` has no `/em`, `/emote`, `/me`, `/sit`, `/kneel`,
+ `/sleep`, or `/lie` parsing;
+- `EmoteText`/`SoulEmote` text events do not carry an emote id, so they
+ should not be used as the primary animation source unless retail proves a
+ deterministic text -> command mapping;
+- held postures need `SetCycle`, not `PlayAction`.
+
+Tests to add:
+
+- `MotionCommandResolver` reconstructs representative ChatEmotes;
+- command-list Wave routes to `PlayAction`;
+- persistent Sit/Meditate routes to `SetCycle`.
+
+### 8. Death Animations
+
+Status: death chat and killer notifications exist; pose transition is missing.
+
+Evidence:
+
+- retail `CommandInterpreter::PlayerIsDead` checks forward command
+ `0x40000011`;
+- MotionCommand `Sanctuary = 0x10000057` is an action and must not be used as
+ the persistent death state;
+- MotionCommand `Dead = 0x40000011` and `Fallen = 0x40000008` are persistent
+ states;
+- `PlayerKilled` top-level message and `KillerNotification (0x01AD)` are
+ parsed/wired.
+
+acdream locations:
+
+- `PlayerKilled`, `ChatLog.OnPlayerKilled`;
+- `CombatState.OnKillerNotification`;
+- `MotionCommand.Dead` currently incorrectly comments `0x10000057` in
+ `MotionInterpreter`; this should be split into `Sanctuary` action and
+ `Dead` substate before death work.
+
+Gaps:
+
+- no explicit death animation coordinator;
+- no hit-direction-aware fall;
+- no dead-pose persistence or respawn reset.
+
+Tests to add:
+
+- death event plays Sanctuary then persists Dead/Fallen;
+- movement is blocked while Dead/Fallen;
+- respawn/reset returns to Ready.
+
+### 9. Item-Use Animations
+
+Status: outbound use builders exist; local visible use animations are missing.
+
+Evidence:
+
+- retail `ItemHolder::UseObject`;
+- MotionCommand item/use states: Pickup, StoreInBackpack, Eat, Drink,
+ Reading, HouseRecall, LifestoneRecall.
+
+acdream locations:
+
+- `InteractRequests.BuildUse` / `BuildUseWithTarget`;
+- `ItemRepository`, appraise/use-done event enum.
+
+Gaps:
+
+- no `WorldSession.SendUse` wrapper found;
+- `UseDone (0x01C7)` is enumerated but not parsed/wired;
+- `0xF754 PlayScriptId` is wired, but target anchoring and speed handling need
+ audit; `0xF755 PlayScriptType` is not wired;
+- no item-class-to-motion mapping.
+
+Tests to add:
+
+- potion use maps to Drink;
+- food maps to Eat;
+- scroll/book maps to Reading;
+- recall spell/item maps to recall command once retail source is confirmed.
+
+### 10. Mounting / Dismounting
+
+Status: not implemented; likely not relevant to retail AC character movement.
+
+Evidence: `r03` lists `Graze` as a monster-only/mount-like stance, but no
+player mount feature has been verified in retail references in this audit.
+
+Action: defer until a server/content feature requires it. Do not invent
+mounting behavior.
+
+### 11. Floating-Point / Polish
+
+Status: partially implemented.
+
+Evidence:
+
+- `AnimationSequencer.MultiplyCyclicFramerate` exists and is tested;
+- `LocalAnimationSpeed` exists in `MovementResult`;
+- PosFrame deltas are accumulated in `AnimationSequencer`.
+
+Gaps:
+
+- root-motion deltas are not composed into entity/body transforms;
+- remote animation speed scaling is tied to ForwardSpeed/SidestepSpeed/TurnSpeed
+ only when UpdateMotion carries them;
+- style-transition and modifier physics combination are incomplete.
+
+Tests to add:
+
+- same-motion/different-speed rescale remains green;
+- root-motion delta is consumed by an integration coordinator;
+- modifiers combine velocity/omega instead of replacing base cycle physics.
+
+## Implementation Order
+
+1. Extract an `AnimationCommandRouter` in Core/App-adjacent code that owns
+ `SetCycle` vs `PlayAction` routing for full 32-bit commands. Move the
+ command-list logic out of `GameWindow.OnLiveMotionUpdated` into tests.
+2. Add missing MotionCommand constants and fix the `Dead`/`Sanctuary`
+ distinction.
+3. Fix combat wire conformance first: combat-mode enum, split attack builders,
+ `CombatCommenceAttack`, `AttackDone`, damage/evasion/death notification
+ parsers, and fixtures from ACE/holtburger.
+4. Wire `CombatState.CurrentMode` and `WorldSession.SendAttack/ChangeCombatMode`;
+ trigger local and remote swing overlays through the router.
+5. Add spell-cast event/state wiring: `WorldSession.SendCast`, school enum
+ conformance, `UpdateMotion` cast actions, spellcasting chat,
+ `PlayScript.Fizzle`, `UseDone`, and errors.
+6. Add outbound emote/soul-emote builders, MoveToState command-list emission,
+ chat parser aliases, and posture routing.
+7. Add item-use wrappers, `UseDone`, script target anchoring, and
+ item-class-to-motion mapping.
+8. Add death coordinator and respawn reset.
+9. Port full ACE style-transition/modifier/action queue semantics into a
+ `MotionTableWalker` or equivalent, replacing `SetCycle` special cases only
+ after the category tests cover current behavior.
+10. Apply/consume root motion where retail expects it; leave purely decorative
+ PosFrames un-applied when decomp/ACE proves they should not move the body.
+
+## Visual Sign-Off Points
+
+The agent can build, test, and live-launch autonomously, but these require
+user visual confirmation before claiming complete:
+
+- local attack swing + defender flinch;
+- local spell windup -> release/fizzle;
+- local `/wave` and persistent sit/lie/kneel/sleep;
+- local death pose and respawn recovery;
+- potion drink/eat/read animations;
+- remote observer view for all of the above.
diff --git a/docs/research/2026-04-28-combat-animation-planner.md b/docs/research/2026-04-28-combat-animation-planner.md
new file mode 100644
index 0000000..f557659
--- /dev/null
+++ b/docs/research/2026-04-28-combat-animation-planner.md
@@ -0,0 +1,113 @@
+# Combat Animation Planner Pseudocode
+
+## Sources
+
+- Retail `ClientCombatSystem::ExecuteAttack` (`0x0056BB70`): sends
+ targeted melee or missile attack intent and records pending response state.
+ It does not choose a local swing animation.
+- Retail `ClientCombatSystem::HandleCommenceAttackEvent` (`0x0056AD20`):
+ starts/updates power-bar and busy UI state. The event carries no
+ `MotionCommand`.
+- Retail command-name table around `0x00803F34`: combat commands include
+ `Twitch1..4`, `StaggerBackward`, `StaggerForward`, `ThrustMed`,
+ `SlashHigh`, `Shoot`, `AttackHigh1`, and later offhand/multistrike
+ commands.
+- ACE `Player_Melee.DoSwingMotion` and `GetSwingAnimation`: server chooses
+ a swing from `CombatManeuverTable.GetMotion(...)` and broadcasts the
+ selected `MotionCommand` with `UpdateMotion`.
+- ACE `CombatManeuverTable.GetMotion`: indexes `(stance, attack height,
+ attack type)` to one or more motion commands; power level chooses between
+ multiple entries.
+
+## Retail Rule
+
+Combat GameEvents are state/UI notifications. Motion state is the animation
+authority.
+
+## Pseudocode
+
+```text
+PlanForEvent(event):
+ return None
+
+PlanFromWireCommand(wireCommand, speed):
+ fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand)
+ return PlanFromFullCommand(fullCommand, speed)
+
+PlanFromFullCommand(fullCommand, speed):
+ kind = ClassifyMotionCommand(fullCommand)
+ if kind is None:
+ return None
+
+ routeKind = AnimationCommandRouter.Classify(fullCommand)
+ return Plan(kind, routeKind, fullCommand, speed)
+
+ClassifyMotionCommand(fullCommand):
+ if command is a combat stance:
+ return CombatStance
+ if command is a thrust/slash/backhand/offhand/multistrike motion:
+ return MeleeSwing
+ if command is Shoot, MissileAttack*, or Reload:
+ return MissileAttack
+ if command is AttackHigh/Med/Low 1..6:
+ return CreatureAttack
+ if command is CastSpell, UseMagicStaff, or UseMagicWand:
+ return SpellCast
+ if command is Twitch*, Stagger*, FallDown, or Sanctuary:
+ return HitReaction
+ if command is Dead:
+ return Death
+ return None
+```
+
+## Maneuver Selection Pseudocode
+
+```text
+SelectMotion(table, stance, attackHeight, attackType, powerLevel,
+ isThrustSlashWeapon):
+ candidates = []
+ for maneuver in table.CombatManeuvers:
+ if maneuver.Style == stance
+ and maneuver.AttackHeight == attackHeight
+ and maneuver.AttackType == attackType:
+ candidates.append(maneuver.Motion)
+
+ if candidates is empty:
+ return None
+
+ subdivision = isThrustSlashWeapon ? 0.66 : 0.33
+
+ if candidates.Count > 1 and powerLevel < subdivision:
+ motion = candidates[1]
+ else:
+ motion = candidates[0]
+
+ return motion
+```
+
+This matches ACE `CombatManeuverTable.GetMotion` plus
+`Player_Melee.GetSwingAnimation`. The `prevMotion` parameter is present in
+ACE's table API but the current ACE implementation does not use it; the
+power threshold chooses between multiple entries.
+
+## Named Retail Motion IDs
+
+`DatReaderWriter.Enums.MotionCommand` is shifted by three entries starting
+at `AllegianceHometownRecall`. Named retail command tables are:
+
+- `command_ids` table lines 1017626-1017658:
+ `0x016E..0x0197 -> 0x1000016E..0x10000197`.
+- command-name table lines 1068272-1068313:
+ `OffhandSlashHigh = 0x10000170`, `AttackLow6 = 0x1000018B`,
+ `PunchFastLow = 0x1000018E`, etc.
+
+`MotionCommandResolver` therefore overrides that range after building the
+DRW reflection table, otherwise offhand and late unarmed attack actions
+resolve as UI/mappable commands and never reach `PlayAction`.
+
+## Implementation Note
+
+The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable`
+and `DatReaderWriter.Types.CombatManeuver` directly. acdream already
+references `Chorizite.DatReaderWriter`; the missing live-state piece is a
+named `CombatTable` data-id on player/creature state.
diff --git a/docs/research/2026-04-28-remote-moveto-pseudocode.md b/docs/research/2026-04-28-remote-moveto-pseudocode.md
new file mode 100644
index 0000000..19ec7c9
--- /dev/null
+++ b/docs/research/2026-04-28-remote-moveto-pseudocode.md
@@ -0,0 +1,285 @@
+# Phase L.1c — Remote MoveTo body-driver pseudocode
+
+**Date**: 2026-04-28
+**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body
+position of server-controlled chasing creatures (movementType 6/7) tracks the
+server-supplied destination smoothly, instead of freezing at zero velocity
+between sparse `UpdatePosition` snaps.
+
+## Problem (root cause from systematic-debugging Phase 1)
+
+The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive`
+is true, on the principle "do not let `apply_current_movement` free-run with
+incomplete MoveTo state." The state IS incomplete: our parser at
+[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs)
+keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters`
+block and the `runRate` trailer, **discarding** `Origin (destination)`,
+`targetGuid` (type 6 only), `distance_to_object`, `min_distance`,
+`fail_distance`, `walk_run_threshhold`, and `desired_heading`.
+
+Symptoms (live log + user observation 2026-04-28):
+- **Disappearing**: body frozen at `Velocity=0` while RunForward animation
+ plays; next UpdatePosition teleports body to actual server pose. If the
+ teleport target is outside the visible window, observer sees disappear/reappear.
+- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along
+ the OLD heading; meanwhile the server is steering the creature on a curve.
+ Each new UP snap-corrects → visible stutter.
+
+The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS
+sending fresh target positions and headings each tick — we're throwing them
+away.
+
+## Retail behavior (named decomp + ACE port)
+
+Sources:
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below
+- `docs/research/named-retail/acclient.h` — struct definitions
+- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs`
+- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`
+
+### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7)
+
+```
+[uint targetGuid] // type 6 only (MoveToObject)
+Origin: uint cellId // then 3 floats local x/y/z
+ float x, y, z // destination position
+MovementParameters (28 bytes, exact retail order):
+ uint flags // bitfield (see below)
+ float distance_to_object // arrival far-bound (ACE default 0.6)
+ float min_distance // arrival near-bound
+ float fail_distance // abort when starting→current >= this
+ float speed // base speed multiplier
+ float walk_run_threshhold // (sic, two h's) — wire default 15.0
+ float desired_heading // final orientation (radians or degrees)
+float runRate // CMotionInterp::my_run_rate copy
+```
+
+### MovementParameters bit-flags (declaration order, acclient.h:31423-31443)
+
+| Bit | Mask | Name | Meaning |
+|----:|---------|------|---------|
+| 0 | 0x00001 | can_walk | gait permission |
+| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) |
+| 2 | 0x00004 | can_sidestep | enables strafe path |
+| 3 | 0x00008 | can_walk_backwards | gait permission |
+| 4 | 0x00010 | can_charge | force HoldKey_Run |
+| 5 | 0x00020 | fail_walk | fail if only walk possible |
+| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival |
+| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion |
+| 8 | 0x00100 | move_away | flee target |
+| 9 | 0x00200 | move_towards | chase target (chase creatures set this) |
+| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line |
+| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply |
+| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) |
+
+### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440)
+
+```
+if physics.motions_pending:
+ cancel any aux turn cmd (let the queued motion complete)
+else:
+ targetWorld = currentTargetPosition // last server-supplied destination
+ desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd)
+ headingDelta = normalize(desiredHeading - body.heading)
+ if |headingDelta| <= 20°: // retail tolerance
+ // ACE adds set_heading(target, true) here (server-tic-rate fudge)
+ cancel any aux turn cmd
+ else:
+ edi = (headingDelta < 180°) ? TurnLeft : TurnRight
+ if edi != auxCommand:
+ _DoMotion(edi) // -> CMotionInterp
+ auxCommand = edi
+dist = GetCurrentDistance()
+if CheckProgressMade(dist):
+ if !movingAway and dist <= min_distance: // arrived
+ popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode()
+ if movingAway and dist >= distance_to_object:
+ popHeadNode(); ...
+ if !movingAway and Position.distance(starting, current) >= fail_distance:
+ CancelMoveTo(0x3d) // YouChargedTooFar
+```
+
+### Key insight: MoveToManager does NOT touch the body directly
+
+Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion`
+(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the
+ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is
+purely a *planner* sitting above CMotionInterp, deciding *which command* (and
+which auxiliary turn) the body should be running at any given tick.
+
+## Acdream port — minimum viable subset
+
+The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can
+skip:
+- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it
+- Sticky / `PositionManager::StickTo`
+- `CheckProgressMade` stall detection — server cancels the move
+- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern
+- `WeenieObj::OnMoveComplete` callback
+- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as
+ a fresh single-step plan)
+
+We DO need:
+1. **Parser**: extract the discarded fields into `ServerMotionState`.
+2. **Per-tick steer**: compute heading-to-destination, turn body orientation
+ toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow*
+ `apply_current_movement` to run — which sets `Body.Velocity` from the
+ active RunForward cycle, oriented along the now-correct heading.
+3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready
+ and clear `ServerMoveToActive`. Server's next MoveTo packet will resume.
+
+## Pseudocode — acdream port
+
+### Parser change (`UpdateMotion.TryParseMoveToPayload`)
+
+```
+TryParseMoveToPayload(body, pos, mt, out parsed):
+ if mt == 6:
+ if rem < 4: return false
+ parsed.TargetGuid = ReadU32; pos += 4
+
+ if rem < 16: return false
+ parsed.OriginCellId = ReadU32; pos += 4
+ parsed.OriginX = ReadF32; pos += 4
+ parsed.OriginY = ReadF32; pos += 4
+ parsed.OriginZ = ReadF32; pos += 4
+
+ if rem < 28: return false
+ parsed.Flags = ReadU32; pos += 4
+ parsed.DistanceToObject = ReadF32; pos += 4
+ parsed.MinDistance = ReadF32; pos += 4
+ parsed.FailDistance = ReadF32; pos += 4
+ parsed.Speed = ReadF32; pos += 4
+ parsed.WalkRunThreshold = ReadF32; pos += 4
+ parsed.DesiredHeading = ReadF32; pos += 4
+
+ if rem < 4: return false
+ parsed.RunRate = ReadF32
+ return true
+```
+
+### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`)
+
+```
+DriveOneTick(rm, dt):
+ if not rm.HasMoveToDestination: return ApplyDefault
+
+ targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time
+ bodyPos = rm.Body.Position
+
+ // Distance check first — arrival short-circuits before any heading work
+ dist = horizontalDistance(targetWorld, bodyPos)
+ if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble):
+ rm.HasMoveToDestination = false
+ // animation cycle moves to Ready via the existing
+ // ApplyServerControlledVelocityCycle path on next zero-velocity sample
+ rm.Body.Velocity = Vector3.Zero
+ return Arrived
+
+ // Heading compute (XY plane; Z untouched — server owns Z)
+ deltaXY = (targetWorld.XY - bodyPos.XY).Normalized
+ desiredHeading = atan2(deltaXY) // radians
+ currentHeading = QuaternionToYaw(rm.Body.Orientation)
+ headingDelta = wrapPi(desiredHeading - currentHeading)
+
+ // Snap orientation toward target — match ACE's set_heading(target, true)
+ // when within tolerance, otherwise rotate at retail-faithful turn rate.
+ const float tolerance = 20° (in radians)
+ if |headingDelta| <= tolerance:
+ rm.Body.Orientation = QuaternionFromYaw(desiredHeading)
+ else:
+ // retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt
+ float maxStep = TurnRateRadPerSec * dt
+ float step = clamp(headingDelta, -maxStep, +maxStep)
+ rm.Body.Orientation = QuaternionFromYaw(currentHeading + step)
+
+ // Allow apply_current_movement to set Velocity from RunForward cycle.
+ // The cycle was already seeded by PlanMoveToStart at packet receipt
+ // and is being played by the AnimationSequencer. CMotionInterp's
+ // apply_current_movement reads InterpretedState.ForwardCommand and
+ // sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod.
+ return DriveActive // caller now invokes apply_current_movement
+```
+
+### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch)
+
+```
+on receipt of MoveTo packet:
+ // existing code already seeds the animation cycle via PlanMoveToStart
+ // NEW: store world-converted destination + thresholds on rmState
+ lbX = (originCellId >> 24) & 0xFF
+ lbY = (originCellId >> 16) & 0xFF
+ origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0)
+ rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin
+ rmState.MoveToMinDistance = parsed.MinDistance
+ rmState.MoveToDistanceToObject = parsed.DistanceToObject
+ rmState.HasMoveToDestination = true
+ // ServerMoveToActive remains set; existing
+```
+
+### Integration in per-tick remote update (`GameWindow.cs` ~line 5045)
+
+```
+// Replace the current Velocity = Zero hold with:
+else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination)
+{
+ var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt);
+ if driveResult == Arrived:
+ // signal cycle update to Ready via existing path
+ ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero);
+ else:
+ rm.Body.TransientState |= Contact | OnWalkable | Active
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+}
+else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
+{
+ // No destination yet (very early frame, packet hasn't fully landed)
+ rm.Body.Velocity = Vector3.Zero;
+}
+else
+{
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+}
+```
+
+## Conformance test cases
+
+1. **Parser round-trip — type 7 (MoveToPosition)**
+ - Synthesize a 68-byte body with known origin + 7 params + runRate.
+ - Assert all 9 new fields decode correctly.
+
+2. **Parser round-trip — type 6 (MoveToObject)**
+ - Synthesize a 72-byte body with target guid + origin + params + runRate.
+ - Assert TargetGuid populated and shifts subsequent fields by 4 bytes.
+
+3. **DriveOneTick — heading snap within tolerance**
+ - body at (0,0,0) facing east, destination (10,0,0).
+ - DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap.
+ - assert orientation unchanged (already correct).
+
+4. **DriveOneTick — heading turn beyond tolerance**
+ - body at (0,0,0) facing east, destination (0,10,0).
+ - desiredHeading=π/2; current=0; |delta|=π/2 > 20°.
+ - dt=0.1s, TurnRate=π/2 → step = π/4 toward target.
+ - assert orientation rotated by π/4 (not full snap).
+
+5. **DriveOneTick — arrival**
+ - body at (0,0,0), destination (0.4,0,0), MinDistance=0.6.
+ - assert HasMoveToDestination cleared and Velocity zeroed.
+
+6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`)
+ - assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`.
+
+## Out of scope (future Phase L.1d if needed)
+
+- Sticky / StickTo for MoveToObject completion
+- `use_final_heading` (post-arrival turn-to-heading)
+- `fail_distance` early-cancel (server already does this; we just don't flag it)
+- `CheckProgressMade` stall detector
+- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper)
+- Sphere-cylinder distance (`use_spheres` bit)
+- `MoveToObject` target-guid resolution — currently we only honor the Origin,
+ which works because the server re-emits with refreshed Origin each tick.
+ If the target is moving fast and the server's emit cadence falls behind,
+ we'd see lag; a future enhancement is to look up the target entity by
+ guid and use its current world position when fresher than Origin.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6d6f558..cfefc85 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -226,6 +226,71 @@ public sealed class GameWindow : IDisposable
/// Last known server position — kept for diagnostics / HUD.
public System.Numerics.Vector3 LastServerPos;
///
+ /// Latest server-authoritative velocity for NPC/monster smoothing.
+ /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
+ /// it for a server-controlled creature, derive it from consecutive
+ /// authoritative positions instead of guessing from player RUM state.
+ ///
+ public System.Numerics.Vector3 ServerVelocity;
+ public bool HasServerVelocity;
+ ///
+ /// True while a server MoveToObject/MoveToPosition packet is the
+ /// active locomotion source. Retail runs these through MoveToManager
+ /// and CMotionInterp; the per-tick remote driver consults this to
+ /// decide whether to feed body steering through
+ /// instead of
+ /// the InterpretedMotionState path.
+ ///
+ public bool ServerMoveToActive;
+
+ ///
+ /// True once a MoveTo packet's full path payload (Origin + thresholds)
+ /// has been parsed and the world-converted destination is stored on
+ /// . Cleared on arrival or when
+ /// the next non-MoveTo UpdateMotion replaces the locomotion source.
+ /// Phase L.1c (2026-04-28).
+ ///
+ public bool HasMoveToDestination;
+
+ ///
+ /// World-space destination from the most recent MoveTo packet's
+ /// Origin field, converted via the same landblock-grid
+ /// arithmetic OnLivePositionUpdated uses.
+ ///
+ public System.Numerics.Vector3 MoveToDestinationWorld;
+
+ ///
+ /// min_distance from the MoveTo packet's MovementParameters.
+ /// Used by as
+ /// the chase-arrival threshold per retail
+ /// MoveToManager::HandleMoveToPosition.
+ ///
+ public float MoveToMinDistance;
+
+ ///
+ /// distance_to_object from the MoveTo packet. Reserved for
+ /// the flee branch (move_away); chase uses
+ /// .
+ ///
+ public float MoveToDistanceToObject;
+
+ ///
+ /// True if MovementParameters bit 9 (move_towards, mask
+ /// 0x200) is set on the active packet — i.e. this is a
+ /// chase. False = flee (move_away) or static target.
+ ///
+ public bool MoveToMoveTowards;
+
+ ///
+ /// Seconds-since-epoch timestamp of the most recent MoveTo packet
+ /// for this entity. Used by the per-tick driver to give up
+ /// steering when no refresh has arrived for
+ ///
+ /// — typically because the entity left our streaming view and
+ /// the server stopped broadcasting its MoveTo updates.
+ ///
+ public double LastMoveToPacketTime;
+ ///
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
///
@@ -532,6 +597,13 @@ public sealed class GameWindow : IDisposable
/// keys the render list; this parallel dictionary keys by server guid.
///
private readonly Dictionary _entitiesByServerGuid = new();
+ private readonly Dictionary _liveEntityInfoByGuid = new();
+ private uint? _selectedTargetGuid;
+ private readonly record struct LiveEntityInfo(
+ string? Name,
+ AcDream.Core.Items.ItemType ItemType);
+ private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
+ private const double ServerControlledVelocityStaleSeconds = 0.60;
private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated;
private int _liveDropReasonNoPos;
@@ -1315,6 +1387,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: connecting to {endpoint} as {user}");
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
+ _liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.VectorUpdated += OnLiveVectorUpdated;
@@ -1666,20 +1739,7 @@ public sealed class GameWindow : IDisposable
// For a respawn, drop the previous rendering state here before we
// build the new one. `_entitiesByServerGuid` is the canonical map,
// its value is the live WorldEntity we need to dispose.
- if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
- {
- _worldState.RemoveEntityByServerGuid(spawn.Guid);
- _worldGameState.RemoveById(existingEntity.Id);
- _animatedEntities.Remove(existingEntity.Id);
- // Physics collision registry entry is keyed by local id too.
- _physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
- // Dead-reckon state is keyed by SERVER guid (not local id) so we
- // clear using the same guid the new spawn will use. Leaving old
- // SnapResidual / DeadReckonedPos in would make the next first
- // UpdatePosition look like a 2m-residual soft-snap.
- _remoteDeadReckon.Remove(spawn.Guid);
- _remoteLastMove.Remove(spawn.Guid);
- }
+ RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
// Log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
@@ -1691,12 +1751,19 @@ public sealed class GameWindow : IDisposable
: "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
+ string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
- $"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
+ $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
+
+ _liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
+ spawn.Name,
+ spawn.ItemType is { } rawItemType
+ ? (AcDream.Core.Items.ItemType)rawItemType
+ : AcDream.Core.Items.ItemType.None);
// Target the statue specifically for full diagnostic dump: Name match
// is cheap and gives us exactly one entity's worth of log regardless
@@ -2045,9 +2112,64 @@ public sealed class GameWindow : IDisposable
if (mtable is not null)
{
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
- uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
- uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u;
- sequencer.SetCycle(seqStyle, seqMotion);
+ uint seqStyle = stanceOverride is > 0
+ ? (0x80000000u | (uint)stanceOverride.Value)
+ : (uint)mtable.DefaultStyle;
+ uint seqMotion;
+ if (commandOverride is > 0)
+ {
+ uint resolved = AcDream.Core.Physics.MotionCommandResolver
+ .ReconstructFullCommand(commandOverride.Value);
+ seqMotion = resolved != 0
+ ? resolved
+ : (0x40000000u | (uint)commandOverride.Value);
+ }
+ else
+ {
+ seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
+ }
+
+ // Phase L.1c followup (2026-04-28): apply the same
+ // missing-cycle fallback the OnLiveMotionUpdated path
+ // uses. Without this, a monster spawned in combat
+ // stance with the wire's seqMotion absent from its
+ // MotionTable hits ClearCyclicTail() with no
+ // replacement enqueue, every body part snaps to its
+ // setup-default offset, and the visual collapses to
+ // "torso on the ground" — visible to acdream
+ // observers when another client is in combat with a
+ // monster, until the first OnLiveMotionUpdated UM
+ // applies the same fallback there.
+ uint spawnCycle = seqMotion;
+ if (!sequencer.HasCycle(seqStyle, spawnCycle))
+ {
+ uint origCycle = spawnCycle;
+ // RunForward → WalkForward → Ready
+ if ((spawnCycle & 0xFFu) == 0x07
+ && sequencer.HasCycle(seqStyle, 0x45000005u))
+ {
+ spawnCycle = 0x45000005u;
+ }
+ else if (sequencer.HasCycle(seqStyle, 0x41000003u))
+ {
+ spawnCycle = 0x41000003u;
+ }
+ else
+ {
+ spawnCycle = 0;
+ }
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ {
+ Console.WriteLine(
+ $"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " +
+ $"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " +
+ $"→ fallback=0x{spawnCycle:X8}");
+ }
+ }
+
+ if (spawnCycle != 0)
+ sequencer.SetCycle(seqStyle, spawnCycle);
}
}
}
@@ -2152,6 +2274,41 @@ public sealed class GameWindow : IDisposable
}
}
+ private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
+ {
+ if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true)
+ && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ {
+ Console.WriteLine(
+ $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
+ }
+ }
+
+ private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
+ {
+ if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
+ return false;
+
+ _worldState.RemoveEntityByServerGuid(serverGuid);
+ _worldGameState.RemoveById(existingEntity.Id);
+ _animatedEntities.Remove(existingEntity.Id);
+ _physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
+
+ // Dead-reckon state is keyed by SERVER guid (not local id) so we
+ // clear using the same guid the next spawn/update would use.
+ _remoteDeadReckon.Remove(serverGuid);
+ _remoteLastMove.Remove(serverGuid);
+ _liveEntityInfoByGuid.Remove(serverGuid);
+ _entitiesByServerGuid.Remove(serverGuid);
+ if (_selectedTargetGuid == serverGuid)
+ _selectedTargetGuid = null;
+
+ if (logDelete)
+ _lightingSink?.UnregisterOwner(existingEntity.Id);
+
+ return true;
+ }
+
///
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
@@ -2186,11 +2343,13 @@ public sealed class GameWindow : IDisposable
&& update.Guid != _playerServerGuid)
{
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
- float spd = update.MotionState.ForwardSpeed ?? 0f;
+ float spd = update.MotionState.ForwardSpeed
+ ?? ((update.MotionState.MoveToSpeed ?? 0f)
+ * (update.MotionState.MoveToRunRate ?? 0f));
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine(
- $"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
+ $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
@@ -2231,10 +2390,27 @@ public sealed class GameWindow : IDisposable
// command == null → retail stop signal → Ready
// command.Value == 0 → explicit 0 (rare) → Ready
// otherwise → resolve class byte and use full cmd
+ float speedMod = update.MotionState.ForwardSpeed ?? 1f;
uint fullMotion;
- if (!command.HasValue || command.Value == 0)
+ if ((!command.HasValue || command.Value == 0)
+ && update.MotionState.IsServerControlledMoveTo)
+ {
+ // Retail MoveToManager::BeginMoveForward calls
+ // MovementParameters::get_command (0x0052AA00), then
+ // _DoMotion -> adjust_motion. With CanRun and enough
+ // distance, WalkForward + HoldKey_Run becomes RunForward,
+ // and CMotionInterp::apply_run_to_command (0x00527BE0)
+ // multiplies speed by the packet's runRate.
+ var seed = AcDream.Core.Physics.ServerControlledLocomotion
+ .PlanMoveToStart(
+ update.MotionState.MoveToSpeed ?? 1f,
+ update.MotionState.MoveToRunRate ?? 1f,
+ update.MotionState.MoveToCanRun);
+ fullMotion = seed.Motion;
+ speedMod = seed.SpeedMod;
+ }
+ else if (!command.HasValue || command.Value == 0)
{
- // Stop — return to the style's default substate (Ready).
fullMotion = 0x41000003u;
}
else
@@ -2262,8 +2438,6 @@ public sealed class GameWindow : IDisposable
// apply_run_to_command). Treating zero as "unspecified / 1.0"
// produces "slow walk that never stops" — exactly what the
// stop bug looked like.
- float speedMod = update.MotionState.ForwardSpeed ?? 1f;
-
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
Console.WriteLine(
@@ -2295,6 +2469,125 @@ public sealed class GameWindow : IDisposable
}
else
{
+ var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
+ bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
+ or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
+ or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
+ bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
+ && rmCheck.Airborne;
+
+ // Retail MotionTable::GetObjectSequence routes action-class
+ // ForwardCommand values (creature attacks, chat-emotes) through
+ // the Action branch, where the swing is appended before the
+ // current cyclic tail and currState.Substate remains Ready.
+ // Treating 0x10000051/52/53 as SetCycle commands made the
+ // immediate follow-up Ready packet abort the swing.
+ // Phase L.1c followup (2026-04-28): the next two state-update
+ // blocks are LIFTED out of the substate-only `else` branch so
+ // they run for BOTH overlay (Action/Modifier/ChatEmote) and
+ // substate (Walk/Run/Ready/etc) packets. Two separate research
+ // agents converged on the same root cause for the user-
+ // observed "creature just runs instead of attacking" symptom:
+ //
+ // 1. Attack swings arrive as mt=0 with
+ // ForwardCommand=AttackHigh1 (Action class). Retail's
+ // CMotionInterp::move_to_interpreted_state
+ // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
+ // forward_command from the wire into the body's
+ // InterpretedState UNCONDITIONALLY. With
+ // forward_command=AttackHigh1, get_state_velocity
+ // returns 0 because its gate is RunForward||WalkForward
+ // — body stops moving forward.
+ //
+ // 2. The acdream overlay branch was routing through
+ // PlayAction (animation overlay) but skipping ALL of:
+ // - ServerMoveToActive flag update
+ // - MoveToPath capture
+ // - InterpretedState.ForwardCommand assignment
+ // So during a swing UM, the body's InterpretedState
+ // stayed at RunForward from the prior MoveTo packet,
+ // ServerMoveToActive stayed true, and the per-tick
+ // remote driver kept steering + applying RunForward
+ // velocity through every frame.
+ //
+ // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
+ // here because the latter is a heuristic that ONLY handles
+ // WalkForward / RunForward / WalkBackward / SideStep / Turn
+ // / Ready (MotionInterpreter.cs:941-970). For an Action
+ // command (e.g. AttackHigh1 = 0x10000062) the switch falls
+ // through and InterpretedState is silently NOT updated —
+ // exactly the bug we are fixing. Direct field assignment
+ // matches retail's copy_movement_from bulk-copy
+ // (acclient_2013_pseudo_c.txt:293301-293311).
+ if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
+ {
+ remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
+
+ // Bulk-copy the wire's resolved ForwardCommand + speed
+ // into InterpretedState UNCONDITIONALLY (overlay,
+ // substate, AND MoveTo packets). Matches retail's
+ // copy_movement_from semantics
+ // (acclient_2013_pseudo_c.txt:293301-293311) which does
+ // not filter by MovementType.
+ //
+ // For MoveTo packets, fullMotion is the RunForward seed
+ // from PlanMoveToStart, so this populates
+ // ForwardCommand=RunForward + ForwardSpeed=speed*runRate
+ // — what the OLD substate-only DoInterpretedMotion call
+ // (commit f794832 removed) used to set. Without it,
+ // apply_current_movement reads the default
+ // ForwardCommand=Ready and produces zero velocity, so
+ // chasing creatures only translate via UpdatePosition
+ // hard-snaps and at spawn appear posed at default
+ // (visible as "torso on the ground" until the first UP
+ // snap hits).
+ //
+ // For overlay (Action) packets this sets ForwardCommand
+ // to the Attack/Twitch/etc command, and
+ // get_state_velocity returns 0 because the gate is
+ // RunForward||WalkForward — body stops moving forward.
+ remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
+ remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
+
+ if (update.MotionState.IsServerControlledMoveTo
+ && update.MotionState.MoveToPath is { } path)
+ {
+ remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
+ .OriginToWorld(
+ path.OriginCellId,
+ path.OriginX,
+ path.OriginY,
+ path.OriginZ,
+ _liveCenterX,
+ _liveCenterY);
+ remoteMot.MoveToMinDistance = path.MinDistance;
+ remoteMot.MoveToDistanceToObject = path.DistanceToObject;
+ remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
+ remoteMot.HasMoveToDestination = true;
+ remoteMot.LastMoveToPacketTime =
+ (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ }
+ else if (!update.MotionState.IsServerControlledMoveTo)
+ {
+ // Off MoveTo — clear stale destination so the per-tick
+ // driver doesn't keep steering.
+ remoteMot.HasMoveToDestination = false;
+ }
+ }
+
+ if (forwardIsOverlay)
+ {
+ if (!remoteIsAirborne)
+ {
+ AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
+ ae.Sequencer,
+ fullStyle,
+ fullMotion,
+ speedMod <= 0f ? 1f : speedMod);
+ }
+ }
+ else
+ {
// Pick which cycle to play on the sequencer. Priority:
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
// 2. Else sidestep cmd if active — legs strafe.
@@ -2342,10 +2635,63 @@ public sealed class GameWindow : IDisposable
// the post-resolve landing path restores the cycle to
// whatever the interpreted state says when the body
// lands.
- bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
- && rmCheck.Airborne;
if (!remoteIsAirborne)
- ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
+ {
+ // Fallback chain for missing cycles in the MotionTable.
+ // SetCycle unconditionally calls ClearCyclicTail() before
+ // looking up the cycle; if the cycle is absent, the body
+ // ends up with no cyclic tail at all and every part snaps
+ // to its setup-default offset — visible as "torso on the
+ // ground" because most creatures' setup-default puts all
+ // limbs at the torso origin.
+ //
+ // This is specifically a regression from commit 186a584
+ // (Phase L.1c port): pre-fix, MoveTo packets fell through
+ // to fullMotion=Ready (which always exists in every
+ // MotionTable). Post-fix, MoveTo packets seed
+ // fullMotion=RunForward, but some creatures (especially
+ // when stance=HandCombat) lack a (combat, RunForward)
+ // cycle. Fall through RunForward → WalkForward → Ready
+ // until we find one the table actually contains.
+ //
+ // Note: this fallback is for the SEQUENCER (visible
+ // animation) only. InterpretedState.ForwardCommand still
+ // gets the wire's (or seeded) ForwardCommand verbatim
+ // so apply_current_movement produces correct velocity.
+ uint cycleToPlay = animCycle;
+ if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
+ {
+ uint requested = cycleToPlay;
+ // RunForward (0x44000007) → WalkForward (0x45000005)
+ if ((cycleToPlay & 0xFFu) == 0x07
+ && ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
+ {
+ cycleToPlay = 0x45000005u;
+ }
+ // WalkForward → Ready (0x41000003)
+ else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
+ {
+ cycleToPlay = 0x41000003u;
+ }
+ // Ready missing too — leave the existing cycle alone
+ // by not calling SetCycle at all (avoids the
+ // ClearCyclicTail wipe).
+ else
+ {
+ cycleToPlay = 0;
+ }
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ {
+ Console.WriteLine(
+ $"UM cycle missing for guid=0x{update.Guid:X8} " +
+ $"style=0x{fullStyle:X8} requested=0x{requested:X8} " +
+ $"→ fallback=0x{cycleToPlay:X8}");
+ }
+ }
+ if (cycleToPlay != 0)
+ ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
+ }
// Retail runs the full MotionInterp state machine on every
// remote. Route each wire command (forward, sidestep, turn)
@@ -2359,12 +2705,17 @@ public sealed class GameWindow : IDisposable
// FUN_00528f70 DoInterpretedMotion
// FUN_00528960 get_state_velocity
// FUN_00529210 apply_current_movement
- if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
+ // ServerMoveToActive flag, MoveToPath capture, and the
+ // InterpretedState.ForwardCommand bulk-copy are already
+ // handled by the LIFTED block above (so overlay-class swings
+ // also clear stale MoveTo state and update the body's
+ // forward command). This branch only handles sidestep /
+ // turn axes plus the ObservedOmega seed — none of which
+ // appear on overlay packets, so the existing logic is
+ // correct unchanged. (`remoteMot` is the same dictionary
+ // entry obtained at the top of the lifted block.)
+ if (remoteMot is not null)
{
- // Forward axis (Ready / WalkForward / RunForward / WalkBackward).
- remoteMot.Motion.DoInterpretedMotion(
- fullMotion, speedMod, modifyInterpretedState: true);
-
// Sidestep axis.
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
{
@@ -2421,6 +2772,7 @@ public sealed class GameWindow : IDisposable
}
}
}
+ }
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
// stamp the _remoteLastMove timestamp to "now". Without this,
@@ -2452,57 +2804,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;
@@ -2604,6 +2919,39 @@ public sealed class GameWindow : IDisposable
}
}
+ private static bool IsRemoteLocomotion(uint motion)
+ {
+ uint low = motion & 0xFFu;
+ return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
+ }
+
+ private void ApplyServerControlledVelocityCycle(
+ uint serverGuid,
+ AnimatedEntity ae,
+ RemoteMotion rm,
+ System.Numerics.Vector3 velocity)
+ {
+ if (IsPlayerGuid(serverGuid)) return;
+ if (rm.Airborne) return;
+ if (ae.Sequencer is null) return;
+ // MoveTo packets already seeded the retail speed/runRate cycle.
+ // Keep UpdatePosition-derived velocity for render position only;
+ // using it to choose the cycle reverts fast chases back to slow
+ // velocity-estimated animation.
+ if (rm.ServerMoveToActive) return;
+
+ var plan = AcDream.Core.Physics.ServerControlledLocomotion
+ .PlanFromVelocity(velocity);
+ uint currentMotion = ae.Sequencer.CurrentMotion;
+ if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion))
+ return;
+
+ uint style = ae.Sequencer.CurrentStyle != 0
+ ? ae.Sequencer.CurrentStyle
+ : 0x8000003Du;
+ ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
+ }
+
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
@@ -2673,6 +3021,26 @@ public sealed class GameWindow : IDisposable
// slerp doesn't visibly rotate from Identity to truth.
rmState.Body.Orientation = rot;
}
+ double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
+ System.Numerics.Vector3? serverVelocity = update.Velocity;
+ if (serverVelocity is null
+ && !IsPlayerGuid(update.Guid)
+ && rmState.LastServerPosTime > 0.0)
+ {
+ double elapsed = nowSec - rmState.LastServerPosTime;
+ if (elapsed > 0.001)
+ serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
+ }
+ if (serverVelocity is { } authoritativeVelocity)
+ {
+ rmState.ServerVelocity = authoritativeVelocity;
+ rmState.HasServerVelocity = true;
+ }
+ else if (!IsPlayerGuid(update.Guid))
+ {
+ rmState.ServerVelocity = System.Numerics.Vector3.Zero;
+ rmState.HasServerVelocity = false;
+ }
rmState.Body.Position = worldPos;
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
@@ -2712,7 +3080,7 @@ public sealed class GameWindow : IDisposable
rmState.Body.Orientation = rot;
rmState.TargetOrientation = rot;
rmState.LastServerPos = worldPos;
- rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
+ rmState.LastServerPosTime = nowSec;
// Align the body's physics clock with our clock so update_object
// doesn't sub-step a huge initial gap.
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
@@ -2737,6 +3105,7 @@ public sealed class GameWindow : IDisposable
// carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f)
{
+ rmState.ServerMoveToActive = false;
rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null)
@@ -2749,6 +3118,21 @@ public sealed class GameWindow : IDisposable
}
}
}
+ else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
+ {
+ rmState.Body.Velocity = rmState.ServerVelocity;
+ }
+
+ if (!IsPlayerGuid(update.Guid)
+ && rmState.HasServerVelocity
+ && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
+ {
+ ApplyServerControlledVelocityCycle(
+ update.Guid,
+ aeForVelocity,
+ rmState,
+ rmState.ServerVelocity);
+ }
entity.Position = rmState.Body.Position;
entity.Rotation = rmState.Body.Orientation;
@@ -5009,7 +5393,114 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
- rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+ if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
+ {
+ double velocityAge = nowSec - rm.LastServerPosTime;
+ if (velocityAge > ServerControlledVelocityStaleSeconds)
+ {
+ rm.ServerVelocity = System.Numerics.Vector3.Zero;
+ rm.HasServerVelocity = false;
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ ApplyServerControlledVelocityCycle(
+ serverGuid,
+ ae,
+ rm,
+ System.Numerics.Vector3.Zero);
+ }
+ else
+ {
+ rm.Body.Velocity = rm.ServerVelocity;
+ }
+ }
+ else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive
+ && rm.HasMoveToDestination)
+ {
+ // Phase L.1c port of retail MoveToManager per-tick
+ // steering (HandleMoveToPosition @ 0x00529d80).
+ // Steer body orientation toward the latest
+ // server-supplied destination, then let
+ // apply_current_movement set Velocity from the
+ // RunForward cycle through the now-correct heading.
+
+ // Stale-destination guard (2026-04-28): if no
+ // MoveTo packet has refreshed the destination
+ // recently, the entity has likely left our
+ // streaming view or the server cancelled the
+ // move without us seeing the cancel UM. Continuing
+ // to steer toward a stale point produces the
+ // "monster runs in place after popping back into
+ // view" symptom. Clear and stand down.
+ double moveToAge = nowSec - rm.LastMoveToPacketTime;
+ if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
+ {
+ rm.HasMoveToDestination = false;
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ }
+ else
+ {
+ var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
+ .Drive(
+ rm.Body.Position,
+ rm.Body.Orientation,
+ rm.MoveToDestinationWorld,
+ rm.MoveToMinDistance,
+ rm.MoveToDistanceToObject,
+ (float)dt,
+ rm.MoveToMoveTowards,
+ out var steeredOrientation);
+ rm.Body.Orientation = steeredOrientation;
+
+ if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
+ .DriveResult.Arrived)
+ {
+ // Within arrival window — zero velocity until the
+ // next MoveTo packet refreshes the destination
+ // (or the server explicitly stops us with an
+ // interpreted-motion UM cmd=Ready).
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ }
+ else
+ {
+ // Steering active — apply_current_movement reads
+ // InterpretedState.ForwardCommand=RunForward (set
+ // when the MoveTo packet arrived) and emits
+ // velocity along +Y in body local space. Our
+ // updated orientation rotates that into the right
+ // world direction toward the target.
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+
+ // Clamp horizontal velocity so we don't overshoot
+ // the arrival threshold during the final tick of
+ // approach. Without this, a 4 m/s body advances
+ // ~6 cm/tick and visibly runs slightly through
+ // the target before the swing UM lands.
+ float arrivalThreshold = rm.MoveToMoveTowards
+ ? rm.MoveToDistanceToObject
+ : rm.MoveToMinDistance;
+ rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver
+ .ClampApproachVelocity(
+ rm.Body.Position,
+ rm.Body.Velocity,
+ rm.MoveToDestinationWorld,
+ arrivalThreshold,
+ (float)dt,
+ rm.MoveToMoveTowards);
+ }
+ }
+ }
+ else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
+ {
+ // MoveTo flag set but we haven't seen a path payload
+ // yet (e.g. truncated packet, or a brand-new entity
+ // whose first cycle UM is still in flight). Hold
+ // velocity at zero — same conservative stance as the
+ // 882a07c stabilizer for incomplete state.
+ rm.Body.Velocity = System.Numerics.Vector3.Zero;
+ }
+ else
+ {
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+ }
}
else
{
@@ -6081,6 +6572,26 @@ public sealed class GameWindow : IDisposable
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
break;
+ case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
+ SelectClosestCombatTarget(showToast: true);
+ break;
+
+ case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
+ ToggleLiveCombatMode();
+ break;
+
+ case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
+ SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
+ break;
+
+ case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
+ SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
+ break;
+
+ case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
+ SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
+ break;
+
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
@@ -6098,6 +6609,123 @@ public sealed class GameWindow : IDisposable
}
}
+ private void ToggleLiveCombatMode()
+ {
+ if (_liveSession is null
+ || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ return;
+
+ var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
+ _liveSession.SendChangeCombatMode(nextMode);
+ Combat.SetCombatMode(nextMode);
+ string text = $"Combat mode {nextMode}";
+ Console.WriteLine($"combat: {text}");
+ _debugVm?.AddToast(text);
+ }
+
+ private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
+ {
+ if (_liveSession is null
+ || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ return;
+
+ if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
+ {
+ _debugVm?.AddToast("Enter melee or missile combat first");
+ Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
+ return;
+ }
+
+ uint? target = GetSelectedOrClosestCombatTarget();
+ if (target is null)
+ {
+ _debugVm?.AddToast("No monster target");
+ Console.WriteLine("combat: attack ignored; no creature target found");
+ return;
+ }
+
+ var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
+ const float FullBar = 1.0f;
+ if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
+ {
+ _liveSession.SendMissileAttack(target.Value, height, FullBar);
+ Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
+ }
+ else
+ {
+ _liveSession.SendMeleeAttack(target.Value, height, FullBar);
+ Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
+ }
+ }
+
+ private uint? GetSelectedOrClosestCombatTarget()
+ {
+ if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
+ return selected;
+
+ return SelectClosestCombatTarget(showToast: false);
+ }
+
+ private uint? SelectClosestCombatTarget(bool showToast)
+ {
+ if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
+ return null;
+
+ uint? bestGuid = null;
+ float bestDistanceSq = float.PositiveInfinity;
+ foreach (var (guid, entity) in _entitiesByServerGuid)
+ {
+ if (!IsLiveCreatureTarget(guid))
+ continue;
+
+ float distanceSq = System.Numerics.Vector3.DistanceSquared(
+ entity.Position,
+ playerEntity.Position);
+ if (distanceSq >= bestDistanceSq)
+ continue;
+
+ bestDistanceSq = distanceSq;
+ bestGuid = guid;
+ }
+
+ _selectedTargetGuid = bestGuid;
+ if (bestGuid is { } selected)
+ {
+ string label = DescribeLiveEntity(selected);
+ float distance = MathF.Sqrt(bestDistanceSq);
+ Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
+ if (showToast)
+ _debugVm?.AddToast($"Target {label}");
+ }
+ else if (showToast)
+ {
+ _debugVm?.AddToast("No monster target");
+ Console.WriteLine("combat: no creature target found");
+ }
+
+ return bestGuid;
+ }
+
+ private bool IsLiveCreatureTarget(uint guid)
+ {
+ if (guid == _playerServerGuid)
+ return false;
+ if (!_entitiesByServerGuid.ContainsKey(guid))
+ return false;
+ if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info))
+ return false;
+
+ return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
+ }
+
+ private string DescribeLiveEntity(uint guid)
+ {
+ if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
+ && !string.IsNullOrWhiteSpace(info.Name))
+ return info.Name!;
+ return $"0x{guid:X8}";
+ }
+
///
/// K.1b: Tab handler extracted into a method so the dispatcher
/// subscriber can call it. Same body as the previous Tab branch in
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index a4515cc..93fd62e 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -156,22 +156,20 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.VictimNotification, e =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
- if (p is not null) combat.OnVictimNotification(
- p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
- p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
+ if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
});
dispatcher.Register(GameEventType.DefenderNotification, e =>
{
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
if (p is not null) combat.OnDefenderNotification(
- p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
+ p.Value.AttackerName, 0u, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
});
dispatcher.Register(GameEventType.AttackerNotification, e =>
{
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
if (p is not null) combat.OnAttackerNotification(
- p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent);
+ p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
});
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
{
@@ -188,12 +186,15 @@ public static class GameEventWiring
var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
});
+ dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
+ {
+ if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
+ combat.OnCombatCommenceAttack();
+ });
dispatcher.Register(GameEventType.KillerNotification, e =>
{
- // ISSUES.md #10 — orphan parser, never registered before. The
- // server fires this after a player lands a killing blow.
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
- if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
+ if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
});
// ── Spells ────────────────────────────────────────────────
diff --git a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs
index f3df54e..d4fc1f5 100644
--- a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs
+++ b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs
@@ -3,60 +3,79 @@ using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
///
-/// Outbound 0x0008 AttackTargetRequest GameAction.
+/// Outbound combat attack GameActions.
+///
+/// Retail/ACE use distinct payloads for melee and missile:
///
-///
-/// Wire layout (inside the 0xF7B1 GameAction envelope):
///
/// u32 0xF7B1 // GameAction envelope opcode
/// u32 gameActionSequence // client sequence
-/// u32 0x0008 // sub-opcode
-/// u32 targetGuid // who to attack
-/// f32 powerLevel // [0.0, 1.0] — the power bar position
-/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons
+/// u32 0x0008 // TargetedMeleeAttack
+/// u32 targetGuid
/// u32 attackHeight // 1=High, 2=Medium, 3=Low
+/// f32 powerLevel // [0.0, 1.0]
+///
+/// u32 0xF7B1
+/// u32 gameActionSequence
+/// u32 0x000A // TargetedMissileAttack
+/// u32 targetGuid
+/// u32 attackHeight
+/// f32 accuracyLevel // [0.0, 1.0]
///
-///
///
-///
-/// The server ALREADY knows the attacker (it's the session's player),
-/// so this message only carries the target + attack params. The server
-/// then rolls damage, picks a body part, and broadcasts
-/// / AttackerNotification
-/// / DefenderNotification / EvasionAttackerNotification /
-/// EvasionDefenderNotification with the result.
-///
-///
-///
-/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
-///
+/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
+/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
+/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
+/// holtburger protocol game_action.rs.
///
public static class AttackTargetRequest
{
public const uint GameActionEnvelope = 0xF7B1u;
- public const uint SubOpcode = 0x0008u;
+ public const uint TargetedMeleeAttackOpcode = 0x0008u;
+ public const uint TargetedMissileAttackOpcode = 0x000Au;
+ public const uint CancelAttackOpcode = 0x01B7u;
- ///
- /// Build the wire body for an attack request.
- ///
- /// [0..1] melee power bar position.
- /// [0..1] missile accuracy bar position; pass 0 for melee.
- /// 1=High, 2=Medium, 3=Low.
- public static byte[] Build(
+ /// Build the wire body for a targeted melee attack.
+ public static byte[] BuildMelee(
uint gameActionSequence,
uint targetGuid,
- float powerLevel,
- float accuracyLevel,
- uint attackHeight)
+ uint attackHeight,
+ float powerLevel)
{
- byte[] body = new byte[28];
+ byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
- BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
- BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel);
+ return body;
+ }
+
+ /// Build the wire body for a targeted missile attack.
+ public static byte[] BuildMissile(
+ uint gameActionSequence,
+ uint targetGuid,
+ uint attackHeight,
+ float accuracyLevel)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
- BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
+ return body;
+ }
+
+ /// Build the wire body for cancelling an active attack request.
+ public static byte[] BuildCancel(uint gameActionSequence)
+ {
+ byte[] body = new byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode);
return body;
}
}
diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs
index 0da9505..4abbbc3 100644
--- a/src/AcDream.Core.Net/Messages/CharacterActions.cs
+++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs
@@ -22,9 +22,17 @@ public static class CharacterActions
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
+ [Flags]
public enum CombatMode : uint
{
- Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5,
+ Undef = 0,
+ NonCombat = 0x01,
+ Melee = 0x02,
+ Missile = 0x04,
+ Magic = 0x08,
+
+ ValidCombat = NonCombat | Melee | Missile | Magic,
+ CombatCombat = Melee | Missile | Magic,
}
/// Spend XP to raise an attribute (Strength, Endurance, etc).
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 1541e07..39b30cd 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
///
///
///
-/// All other fields (weenie header, object description, motion tables,
+/// Most other fields (extended weenie header, object description, motion tables,
/// palettes, texture overrides, animation frames, velocity, ...) are
/// consumed-but-ignored so the parse position ends up wherever the
/// client-side caller wanted — a Parse call doesn't need to reach
-/// the end of the body to return useful output. We stop after PhysicsData
-/// since that's the last segment containing fields acdream cares about
-/// in this phase.
+/// the end of the body to return useful output. We read through the fixed
+/// WeenieHeader prefix for Name/ItemType, then stop before optional header
+/// tails.
///
///
///
@@ -51,6 +51,8 @@ public static class CreateObject
public const uint PaletteTypePrefix = 0x04000000u;
/// SurfaceTexture dat id type prefix.
public const uint SurfaceTextureTypePrefix = 0x05000000u;
+ /// Icon dat id type prefix.
+ public const uint IconTypePrefix = 0x06000000u;
[Flags]
public enum PhysicsDescriptionFlag : uint
@@ -78,9 +80,9 @@ public static class CreateObject
}
///
- /// The three fields acdream cares about. Position and SetupTableId are
- /// nullable because their corresponding physics-description-flag bits
- /// may not be set on every CreateObject.
+ /// The spawn fields acdream currently cares about. Position and
+ /// SetupTableId are nullable because their corresponding
+ /// physics-description-flag bits may not be set on every CreateObject.
///
public readonly record struct Parsed(
uint Guid,
@@ -92,6 +94,7 @@ public static class CreateObject
uint? BasePaletteId,
float? ObjScale,
string? Name,
+ uint? ItemType,
ServerMotionState? MotionState,
uint? MotionTableId,
ushort InstanceSequence = 0,
@@ -136,7 +139,59 @@ public static class CreateObject
ushort? SideStepCommand = null,
float? SideStepSpeed = null,
ushort? TurnCommand = null,
- float? TurnSpeed = null);
+ float? TurnSpeed = null,
+ byte MovementType = 0,
+ uint? MoveToParameters = null,
+ float? MoveToSpeed = null,
+ float? MoveToRunRate = null,
+ MoveToPathData? MoveToPath = null)
+ {
+ ///
+ /// ACE/retail movement types 6 and 7 are server-controlled
+ /// MoveToObject/MoveToPosition packets. Their union body does not
+ /// carry an InterpretedMotionState.ForwardCommand, so command absence
+ /// is not a stop signal.
+ ///
+ public bool IsServerControlledMoveTo => MovementType is 6 or 7;
+
+ public bool MoveToCanRun => !MoveToParameters.HasValue
+ || (MoveToParameters.Value & 0x2u) != 0;
+
+ ///
+ /// MovementParameters bit 9 (mask 0x200) — set when the creature is
+ /// chasing its target. Cross-checked against acclient.h:31423-31443
+ /// (named retail) + ACE MovementParamFlags.MoveTowards.
+ ///
+ public bool MoveTowards => MoveToParameters.HasValue
+ && (MoveToParameters.Value & 0x200u) != 0;
+ }
+
+ ///
+ /// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
+ /// Wire layout per MovementParameters::UnPackNet @ 0x0052ac50
+ /// + the leading Origin + optional target guid for type 6:
+ ///
+ /// - type 6 (MoveToObject) only: u32 TargetGuid
+ /// - Origin: u32 cellId, then 3 floats (local x/y/z within the landblock)
+ /// - MovementParameters (28 bytes, exact retail order):
+ /// u32 flags, f32 distance_to_object, f32 min_distance,
+ /// f32 fail_distance, f32 speed, f32 walk_run_threshhold,
+ /// f32 desired_heading
+ ///
+ /// (The trailing runRate float is captured separately on
+ /// .)
+ ///
+ public readonly record struct MoveToPathData(
+ uint? TargetGuid,
+ uint OriginCellId,
+ float OriginX,
+ float OriginY,
+ float OriginZ,
+ float DistanceToObject,
+ float MinDistance,
+ float FailDistance,
+ float WalkRunThreshold,
+ float DesiredHeading);
///
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
@@ -390,27 +445,39 @@ public static class CreateObject
pos += 9 * 2;
AlignTo4(ref pos);
- // --- WeenieHeader: read just the Name field (second after flags). ---
+ // --- WeenieHeader: read the fixed prefix fields we need. ---
+ // ACE WorldObject_Networking.SerializeCreateObject writes:
+ // weenieFlags, Name, WeenieClassId(PackedDword),
+ // IconId(PackedDwordOfKnownType 0x06000000), ItemType,
+ // ObjectDescriptionFlags, align.
string? name = null;
+ uint? itemType = null;
if (body.Length - pos >= 4)
{
pos += 4; // skip weenieFlags u32
try
{
name = ReadString16L(body, ref pos);
+ _ = ReadPackedDword(body, ref pos); // WeenieClassId
+ _ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
+ if (body.Length - pos >= 4)
+ itemType = ReadU32(body, ref pos);
+ if (body.Length - pos >= 4)
+ _ = ReadU32(body, ref pos); // ObjectDescriptionFlags
+ AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
return new Parsed(guid, position, setupTableId, animParts,
- textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId,
+ textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new(
guid, position, setupTableId, animParts,
- textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
+ textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
}
catch
{
@@ -528,6 +595,9 @@ public static class CreateObject
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
+ uint? moveToParameters = null;
+ float? moveToSpeed = null;
+ float? moveToRunRate = null;
List? commands = null;
// 0 = Invalid is the only union variant we care about for static
@@ -630,14 +700,62 @@ public static class CreateObject
}
done:;
}
+ else if (movementType is 6 or 7)
+ {
+ TryParseMoveToPayload(
+ mv,
+ p,
+ movementType,
+ out moveToParameters,
+ out moveToSpeed,
+ out moveToRunRate);
+ }
return new ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
- sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
+ sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
+ movementType,
+ moveToParameters,
+ moveToSpeed,
+ moveToRunRate);
}
catch
{
return null;
}
}
+
+ private static bool TryParseMoveToPayload(
+ ReadOnlySpan body,
+ int pos,
+ byte movementType,
+ out uint? movementParameters,
+ out float? speed,
+ out float? runRate)
+ {
+ movementParameters = null;
+ speed = null;
+ runRate = null;
+
+ if (movementType == 6)
+ {
+ if (body.Length - pos < 4) return false;
+ pos += 4; // target guid
+ }
+
+ if (body.Length - pos < 16 + 28 + 4) return false;
+ pos += 16; // Origin
+
+ movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // distanceToObject
+ pos += 4; // minDistance
+ pos += 4; // failDistance
+ speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ pos += 4; // walkRunThreshold
+ pos += 4; // desiredHeading
+ runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ return true;
+ }
}
diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs
new file mode 100644
index 0000000..c18bb13
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs
@@ -0,0 +1,39 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound ObjectDelete GameMessage (opcode 0xF747).
+///
+///
+/// Retail dispatch path:
+/// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 reads guid from
+/// buf+4 and instance sequence from buf+8, then calls
+/// SmartBox::HandleDeleteObject 0x00451EA0. ACE emits the same
+/// layout from GameMessageDeleteObject.
+///
+///
+public static class DeleteObject
+{
+ public const uint Opcode = 0xF747u;
+
+ public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
+
+ ///
+ /// Parse a 0xF747 body. must start with the
+ /// 4-byte opcode, matching every other parser in this namespace.
+ ///
+ public static Parsed? TryParse(ReadOnlySpan body)
+ {
+ if (body.Length < 10)
+ return null;
+
+ uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
+ if (opcode != Opcode)
+ return null;
+
+ uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
+ ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
+ return new Parsed(guid, instanceSequence);
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 6889140..d913162 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -147,56 +147,34 @@ public static class GameEvents
// ── Combat notifications ────────────────────────────────────────────────
- /// 0x01AC VictimNotification — "you got hit for X".
- public readonly record struct VictimNotification(
- string AttackerName,
- uint AttackerGuid,
- uint DamageType,
- uint Damage,
- uint HitQuadrant,
- uint Critical,
- uint AttackType);
+ /// 0x01AC VictimNotification - death message for the victim.
+ public readonly record struct VictimNotification(string DeathMessage);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan payload)
{
int pos = 0;
- try
- {
- string name = ReadString16L(payload, ref pos);
- if (payload.Length - pos < 24) return null;
- uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType);
- }
+ try { return new VictimNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
- /// 0x01AD KillerNotification — "you killed X".
- public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
+ /// 0x01AD KillerNotification - death message for the killer.
+ public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan payload)
{
int pos = 0;
- try
- {
- string name = ReadString16L(payload, ref pos);
- if (payload.Length - pos < 4) return null;
- uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
- return new KillerNotification(name, guid);
- }
+ try { return new KillerNotification(ReadString16L(payload, ref pos)); }
catch { return null; }
}
- /// 0x01B1 AttackerNotification — "you hit X for Y%".
+ /// 0x01B1 AttackerNotification - "you hit X".
public readonly record struct AttackerNotification(
string DefenderName,
uint DamageType,
+ double HealthPercent,
uint Damage,
- float DamagePercent);
+ uint Critical,
+ ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload)
{
@@ -204,23 +182,26 @@ public static class GameEvents
try
{
string name = ReadString16L(payload, ref pos);
- if (payload.Length - pos < 12) return null;
- uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4;
- return new AttackerNotification(name, damageType, damage, pct);
+ if (payload.Length - pos < 28) return null;
+ uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
+ uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
+ return new AttackerNotification(name, damageType, pct, damage, crit, cond);
}
catch { return null; }
}
- /// 0x01B2 DefenderNotification — "X hit you for Y".
+ /// 0x01B2 DefenderNotification - "X hit you".
public readonly record struct DefenderNotification(
string AttackerName,
- uint AttackerGuid,
uint DamageType,
+ double HealthPercent,
uint Damage,
uint HitQuadrant,
- uint Critical);
+ uint Critical,
+ ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload)
{
@@ -228,40 +209,42 @@ public static class GameEvents
try
{
string name = ReadString16L(payload, ref pos);
- if (payload.Length - pos < 20) return null;
- uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
- return new DefenderNotification(name, guid, dtype, dmg, quad, crit);
+ if (payload.Length - pos < 32) return null;
+ uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
+ uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
+ return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
}
catch { return null; }
}
- /// 0x01B3 EvasionAttackerNotification — "X evaded".
+ /// 0x01B3 EvasionAttackerNotification - "X evaded".
public static string? ParseEvasionAttackerNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
- /// 0x01B4 EvasionDefenderNotification — "you evaded X".
+ /// 0x01B4 EvasionDefenderNotification - "you evaded X".
public static string? ParseEvasionDefenderNotification(ReadOnlySpan payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
- /// 0x01A7 AttackDone — (attackSequence, weenieError).
+ /// 0x01B8 CombatCommenceAttack - empty payload.
+ public static bool ParseCombatCommenceAttack(ReadOnlySpan payload) => payload.Length == 0;
+
+ /// 0x01A7 AttackDone - single WeenieError value.
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan payload)
{
- if (payload.Length < 8) return null;
- return new AttackDone(
- BinaryPrimitives.ReadUInt32LittleEndian(payload),
- BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
+ if (payload.Length < 4) return null;
+ return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
// ── Spell enchantments ──────────────────────────────────────────────────
diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
index 65791a7..8756281 100644
--- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs
+++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
@@ -127,6 +127,10 @@ public static class UpdateMotion
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
+ uint? moveToParameters = null;
+ float? moveToSpeed = null;
+ float? moveToRunRate = null;
+ CreateObject.MoveToPathData? moveToPath = null;
List? commands = null;
if (movementType == 0)
@@ -135,7 +139,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path.
// 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));
+ if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
@@ -158,13 +162,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0)
{
- if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
+ if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((flags & 0x2u) != 0)
{
- if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
+ if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
@@ -221,14 +225,108 @@ public static class UpdateMotion
}
done:;
}
+ else if (movementType is 6 or 7)
+ {
+ TryParseMoveToPayload(
+ body,
+ pos,
+ movementType,
+ out moveToParameters,
+ out moveToSpeed,
+ out moveToRunRate,
+ out moveToPath);
+ }
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
- sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
+ sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
+ movementType,
+ moveToParameters,
+ moveToSpeed,
+ moveToRunRate,
+ moveToPath));
}
catch
{
return null;
}
}
+
+ private static bool TryParseMoveToPayload(
+ ReadOnlySpan body,
+ int pos,
+ byte movementType,
+ out uint? movementParameters,
+ out float? speed,
+ out float? runRate,
+ out CreateObject.MoveToPathData? path)
+ {
+ movementParameters = null;
+ speed = null;
+ runRate = null;
+ path = null;
+
+ // Retail MovementManager::PerformMovement (0x00524440) consumes
+ // MoveToObject/MoveToPosition as:
+ // [object guid, for MoveToObject only]
+ // Origin(cell + xyz)
+ // MovementParameters::UnPackNet (0x0052AC50): flags, distance,
+ // min, fail, speed, walk/run threshold, desired heading
+ // f32 runRate copied into CMotionInterp::my_run_rate.
+ //
+ // Phase L.1c (2026-04-28): the full path payload is now retained on
+ // so the per-tick remote
+ // body driver can steer toward Origin instead of holding velocity at
+ // zero between sparse UpdatePosition snaps. The 882a07c stabilizer
+ // was deliberately conservative because we only had speed+runRate;
+ // with the rest of the packet captured, the body solver has full
+ // path data and can run faithfully.
+ uint? targetGuid = null;
+ if (movementType == 6)
+ {
+ if (body.Length - pos < 4) return false;
+ targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ }
+
+ if (body.Length - pos < 16 + 28 + 4) return false;
+
+ uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+
+ movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+
+ path = new CreateObject.MoveToPathData(
+ targetGuid,
+ originCellId,
+ originX,
+ originY,
+ originZ,
+ distanceToObject,
+ minDistance,
+ failDistance,
+ walkRunThreshold,
+ desiredHeading);
+ return true;
+ }
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 3389fb7..885ec63 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -1,6 +1,7 @@
using System.Buffers.Binary;
using System.Net;
using System.Threading.Channels;
+using AcDream.Core.Combat;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
@@ -53,12 +54,23 @@ public sealed class WorldSession : IDisposable
uint? BasePaletteId,
float? ObjScale,
string? Name,
+ uint? ItemType,
CreateObject.ServerMotionState? MotionState,
uint? MotionTableId);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
+ ///
+ /// Fires when the session parses a 0xF747 ObjectDelete game message.
+ /// Retail routes this through
+ /// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 →
+ /// SmartBox::HandleDeleteObject 0x00451EA0; ACE emits it when
+ /// an object leaves the world, including the living creature object
+ /// after its corpse is created.
+ ///
+ public event Action? EntityDeleted;
+
///
/// Payload for : the server guid of the entity
/// whose motion changed and its new server-side stance + forward command.
@@ -634,10 +646,17 @@ public sealed class WorldSession : IDisposable
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name,
+ parsed.Value.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId));
}
}
+ else if (op == DeleteObject.Opcode)
+ {
+ var parsed = DeleteObject.TryParse(body);
+ if (parsed is not null)
+ EntityDeleted?.Invoke(parsed.Value);
+ }
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
@@ -909,6 +928,48 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
+ /// Send retail ChangeCombatMode (0x0053).
+ public void SendChangeCombatMode(CombatMode mode)
+ {
+ uint seq = NextGameActionSequence();
+ byte[] body = CharacterActions.BuildChangeCombatMode(
+ seq,
+ (CharacterActions.CombatMode)(uint)mode);
+ SendGameAction(body);
+ }
+
+ /// Send retail TargetedMeleeAttack (0x0008).
+ public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
+ {
+ uint seq = NextGameActionSequence();
+ byte[] body = AttackTargetRequest.BuildMelee(
+ seq,
+ targetGuid,
+ (uint)attackHeight,
+ powerLevel);
+ SendGameAction(body);
+ }
+
+ /// Send retail TargetedMissileAttack (0x000A).
+ public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
+ {
+ uint seq = NextGameActionSequence();
+ byte[] body = AttackTargetRequest.BuildMissile(
+ seq,
+ targetGuid,
+ (uint)attackHeight,
+ accuracyLevel);
+ SendGameAction(body);
+ }
+
+ /// Send retail CancelAttack (0x01B7).
+ public void SendCancelAttack()
+ {
+ uint seq = NextGameActionSequence();
+ byte[] body = AttackTargetRequest.BuildCancel(seq);
+ SendGameAction(body);
+ }
+
///
/// Phase I.6: send a TurbineChat RequestSendToRoomById to a
/// global community room (General / Trade / LFG / Roleplay /
diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs
new file mode 100644
index 0000000..bf67a85
--- /dev/null
+++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs
@@ -0,0 +1,308 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Combat;
+
+///
+/// Retail-faithful combat animation planner for server-sent motion commands.
+///
+/// Retail evidence:
+/// - ClientCombatSystem::ExecuteAttack (0x0056BB70) only sends the
+/// targeted melee/missile GameAction and sets response state; it does not
+/// locally choose or play a swing animation.
+/// - ClientCombatSystem::HandleCommenceAttackEvent (0x0056AD20)
+/// updates the power bar/busy state; it carries no MotionCommand.
+/// - ACE Player_Melee.DoSwingMotion chooses a swing via
+/// CombatManeuverTable.GetMotion and broadcasts that MotionCommand
+/// in UpdateMotion.
+///
+/// So acdream treats combat GameEvents as state/UI signals and treats
+/// UpdateMotion command IDs as the animation authority.
+///
+public static class CombatAnimationPlanner
+{
+ public static CombatAnimationPlan PlanForEvent(CombatAnimationEvent combatEvent)
+ {
+ _ = combatEvent;
+ return CombatAnimationPlan.None;
+ }
+
+ public static CombatAnimationPlan PlanFromWireCommand(ushort wireCommand, float speedMod = 1f)
+ {
+ uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
+ return PlanFromFullCommand(fullCommand, speedMod);
+ }
+
+ public static CombatAnimationPlan PlanFromFullCommand(uint fullCommand, float speedMod = 1f)
+ {
+ var kind = ClassifyMotionCommand(fullCommand);
+ if (kind == CombatAnimationKind.None)
+ return CombatAnimationPlan.None;
+
+ return new CombatAnimationPlan(
+ kind,
+ AnimationCommandRouter.Classify(fullCommand),
+ fullCommand,
+ speedMod);
+ }
+
+ public static CombatAnimationKind ClassifyMotionCommand(uint fullCommand)
+ {
+ return fullCommand switch
+ {
+ CombatAnimationMotionCommands.HandCombat
+ or CombatAnimationMotionCommands.SwordCombat
+ or CombatAnimationMotionCommands.SwordShieldCombat
+ or CombatAnimationMotionCommands.TwoHandedSwordCombat
+ or CombatAnimationMotionCommands.TwoHandedStaffCombat
+ or CombatAnimationMotionCommands.BowCombat
+ or CombatAnimationMotionCommands.CrossbowCombat
+ or CombatAnimationMotionCommands.SlingCombat
+ or CombatAnimationMotionCommands.DualWieldCombat
+ or CombatAnimationMotionCommands.ThrownWeaponCombat
+ or CombatAnimationMotionCommands.AtlatlCombat
+ or CombatAnimationMotionCommands.ThrownShieldCombat
+ or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance,
+
+ CombatAnimationMotionCommands.ThrustMed
+ or CombatAnimationMotionCommands.ThrustLow
+ or CombatAnimationMotionCommands.ThrustHigh
+ or CombatAnimationMotionCommands.SlashHigh
+ or CombatAnimationMotionCommands.SlashMed
+ or CombatAnimationMotionCommands.SlashLow
+ or CombatAnimationMotionCommands.BackhandHigh
+ or CombatAnimationMotionCommands.BackhandMed
+ or CombatAnimationMotionCommands.BackhandLow
+ or CombatAnimationMotionCommands.DoubleSlashLow
+ or CombatAnimationMotionCommands.DoubleSlashMed
+ or CombatAnimationMotionCommands.DoubleSlashHigh
+ or CombatAnimationMotionCommands.TripleSlashLow
+ or CombatAnimationMotionCommands.TripleSlashMed
+ or CombatAnimationMotionCommands.TripleSlashHigh
+ or CombatAnimationMotionCommands.DoubleThrustLow
+ or CombatAnimationMotionCommands.DoubleThrustMed
+ or CombatAnimationMotionCommands.DoubleThrustHigh
+ or CombatAnimationMotionCommands.TripleThrustLow
+ or CombatAnimationMotionCommands.TripleThrustMed
+ or CombatAnimationMotionCommands.TripleThrustHigh
+ or CombatAnimationMotionCommands.OffhandSlashHigh
+ or CombatAnimationMotionCommands.OffhandSlashMed
+ or CombatAnimationMotionCommands.OffhandSlashLow
+ or CombatAnimationMotionCommands.OffhandThrustHigh
+ or CombatAnimationMotionCommands.OffhandThrustMed
+ or CombatAnimationMotionCommands.OffhandThrustLow
+ or CombatAnimationMotionCommands.OffhandDoubleSlashLow
+ or CombatAnimationMotionCommands.OffhandDoubleSlashMed
+ or CombatAnimationMotionCommands.OffhandDoubleSlashHigh
+ or CombatAnimationMotionCommands.OffhandTripleSlashLow
+ or CombatAnimationMotionCommands.OffhandTripleSlashMed
+ or CombatAnimationMotionCommands.OffhandTripleSlashHigh
+ or CombatAnimationMotionCommands.OffhandDoubleThrustLow
+ or CombatAnimationMotionCommands.OffhandDoubleThrustMed
+ or CombatAnimationMotionCommands.OffhandDoubleThrustHigh
+ or CombatAnimationMotionCommands.OffhandTripleThrustLow
+ or CombatAnimationMotionCommands.OffhandTripleThrustMed
+ or CombatAnimationMotionCommands.OffhandTripleThrustHigh
+ or CombatAnimationMotionCommands.OffhandKick
+ or CombatAnimationMotionCommands.PunchFastHigh
+ or CombatAnimationMotionCommands.PunchFastMed
+ or CombatAnimationMotionCommands.PunchFastLow
+ or CombatAnimationMotionCommands.PunchSlowHigh
+ or CombatAnimationMotionCommands.PunchSlowMed
+ or CombatAnimationMotionCommands.PunchSlowLow
+ or CombatAnimationMotionCommands.OffhandPunchFastHigh
+ or CombatAnimationMotionCommands.OffhandPunchFastMed
+ or CombatAnimationMotionCommands.OffhandPunchFastLow
+ or CombatAnimationMotionCommands.OffhandPunchSlowHigh
+ or CombatAnimationMotionCommands.OffhandPunchSlowMed
+ or CombatAnimationMotionCommands.OffhandPunchSlowLow => CombatAnimationKind.MeleeSwing,
+
+ CombatAnimationMotionCommands.Shoot
+ or CombatAnimationMotionCommands.MissileAttack1
+ or CombatAnimationMotionCommands.MissileAttack2
+ or CombatAnimationMotionCommands.MissileAttack3
+ or CombatAnimationMotionCommands.Reload => CombatAnimationKind.MissileAttack,
+
+ CombatAnimationMotionCommands.AttackHigh1
+ or CombatAnimationMotionCommands.AttackMed1
+ or CombatAnimationMotionCommands.AttackLow1
+ or CombatAnimationMotionCommands.AttackHigh2
+ or CombatAnimationMotionCommands.AttackMed2
+ or CombatAnimationMotionCommands.AttackLow2
+ or CombatAnimationMotionCommands.AttackHigh3
+ or CombatAnimationMotionCommands.AttackMed3
+ or CombatAnimationMotionCommands.AttackLow3
+ or CombatAnimationMotionCommands.AttackHigh4
+ or CombatAnimationMotionCommands.AttackMed4
+ or CombatAnimationMotionCommands.AttackLow4
+ or CombatAnimationMotionCommands.AttackHigh5
+ or CombatAnimationMotionCommands.AttackMed5
+ or CombatAnimationMotionCommands.AttackLow5
+ or CombatAnimationMotionCommands.AttackHigh6
+ or CombatAnimationMotionCommands.AttackMed6
+ or CombatAnimationMotionCommands.AttackLow6 => CombatAnimationKind.CreatureAttack,
+
+ CombatAnimationMotionCommands.CastSpell
+ or CombatAnimationMotionCommands.UseMagicStaff
+ or CombatAnimationMotionCommands.UseMagicWand => CombatAnimationKind.SpellCast,
+
+ CombatAnimationMotionCommands.FallDown
+ or CombatAnimationMotionCommands.Twitch1
+ or CombatAnimationMotionCommands.Twitch2
+ or CombatAnimationMotionCommands.Twitch3
+ or CombatAnimationMotionCommands.Twitch4
+ or CombatAnimationMotionCommands.StaggerBackward
+ or CombatAnimationMotionCommands.StaggerForward
+ or CombatAnimationMotionCommands.Sanctuary => CombatAnimationKind.HitReaction,
+
+ MotionCommand.Dead => CombatAnimationKind.Death,
+
+ _ => CombatAnimationKind.None,
+ };
+ }
+}
+
+public readonly record struct CombatAnimationPlan(
+ CombatAnimationKind Kind,
+ AnimationCommandRouteKind RouteKind,
+ uint MotionCommand,
+ float SpeedMod)
+{
+ public static CombatAnimationPlan None { get; } = new(
+ CombatAnimationKind.None,
+ AnimationCommandRouteKind.None,
+ 0u,
+ 0f);
+
+ public bool HasMotion => Kind != CombatAnimationKind.None && MotionCommand != 0;
+}
+
+public enum CombatAnimationEvent
+{
+ CombatCommenceAttack,
+ AttackDone,
+ AttackerNotification,
+ DefenderNotification,
+ EvasionAttackerNotification,
+ EvasionDefenderNotification,
+ VictimNotification,
+ KillerNotification,
+}
+
+public enum CombatAnimationKind
+{
+ None = 0,
+ CombatStance,
+ MeleeSwing,
+ MissileAttack,
+ CreatureAttack,
+ SpellCast,
+ HitReaction,
+ Death,
+}
+
+internal static class CombatAnimationMotionCommands
+{
+ public const uint HandCombat = 0x8000003Cu;
+ public const uint SwordCombat = 0x8000003Eu;
+ public const uint BowCombat = 0x8000003Fu;
+ public const uint SwordShieldCombat = 0x80000040u;
+ public const uint CrossbowCombat = 0x80000041u;
+ public const uint SlingCombat = 0x80000043u;
+ public const uint TwoHandedSwordCombat = 0x80000044u;
+ public const uint TwoHandedStaffCombat = 0x80000045u;
+ public const uint DualWieldCombat = 0x80000046u;
+ public const uint ThrownWeaponCombat = 0x80000047u;
+ public const uint Magic = 0x80000049u;
+ public const uint AtlatlCombat = 0x8000013Bu;
+ public const uint ThrownShieldCombat = 0x8000013Cu;
+
+ public const uint FallDown = 0x10000050u;
+ public const uint Twitch1 = 0x10000051u;
+ public const uint Twitch2 = 0x10000052u;
+ public const uint Twitch3 = 0x10000053u;
+ public const uint Twitch4 = 0x10000054u;
+ public const uint StaggerBackward = 0x10000055u;
+ public const uint StaggerForward = 0x10000056u;
+ public const uint Sanctuary = 0x10000057u;
+ public const uint ThrustMed = 0x10000058u;
+ public const uint ThrustLow = 0x10000059u;
+ public const uint ThrustHigh = 0x1000005Au;
+ public const uint SlashHigh = 0x1000005Bu;
+ public const uint SlashMed = 0x1000005Cu;
+ public const uint SlashLow = 0x1000005Du;
+ public const uint BackhandHigh = 0x1000005Eu;
+ public const uint BackhandMed = 0x1000005Fu;
+ public const uint BackhandLow = 0x10000060u;
+ public const uint Shoot = 0x10000061u;
+ public const uint AttackHigh1 = 0x10000062u;
+ public const uint AttackMed1 = 0x10000063u;
+ public const uint AttackLow1 = 0x10000064u;
+ public const uint AttackHigh2 = 0x10000065u;
+ public const uint AttackMed2 = 0x10000066u;
+ public const uint AttackLow2 = 0x10000067u;
+ public const uint AttackHigh3 = 0x10000068u;
+ public const uint AttackMed3 = 0x10000069u;
+ public const uint AttackLow3 = 0x1000006Au;
+
+ public const uint MissileAttack1 = 0x100000D0u;
+ public const uint MissileAttack2 = 0x100000D1u;
+ public const uint MissileAttack3 = 0x100000D2u;
+ public const uint CastSpell = 0x400000D3u;
+ public const uint Reload = 0x100000D4u;
+ public const uint UseMagicStaff = 0x400000E0u;
+ public const uint UseMagicWand = 0x400000E1u;
+
+ public const uint DoubleSlashLow = 0x1000011Fu;
+ public const uint DoubleSlashMed = 0x10000120u;
+ public const uint DoubleSlashHigh = 0x10000121u;
+ public const uint TripleSlashLow = 0x10000122u;
+ public const uint TripleSlashMed = 0x10000123u;
+ public const uint TripleSlashHigh = 0x10000124u;
+ public const uint DoubleThrustLow = 0x10000125u;
+ public const uint DoubleThrustMed = 0x10000126u;
+ public const uint DoubleThrustHigh = 0x10000127u;
+ public const uint TripleThrustLow = 0x10000128u;
+ public const uint TripleThrustMed = 0x10000129u;
+ public const uint TripleThrustHigh = 0x1000012Au;
+
+ public const uint OffhandSlashHigh = 0x10000170u;
+ public const uint OffhandSlashMed = 0x10000171u;
+ public const uint OffhandSlashLow = 0x10000172u;
+ public const uint OffhandThrustHigh = 0x10000173u;
+ public const uint OffhandThrustMed = 0x10000174u;
+ public const uint OffhandThrustLow = 0x10000175u;
+ public const uint OffhandDoubleSlashLow = 0x10000176u;
+ public const uint OffhandDoubleSlashMed = 0x10000177u;
+ public const uint OffhandDoubleSlashHigh = 0x10000178u;
+ public const uint OffhandTripleSlashLow = 0x10000179u;
+ public const uint OffhandTripleSlashMed = 0x1000017Au;
+ public const uint OffhandTripleSlashHigh = 0x1000017Bu;
+ public const uint OffhandDoubleThrustLow = 0x1000017Cu;
+ public const uint OffhandDoubleThrustMed = 0x1000017Du;
+ public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
+ public const uint OffhandTripleThrustLow = 0x1000017Fu;
+ public const uint OffhandTripleThrustMed = 0x10000180u;
+ public const uint OffhandTripleThrustHigh = 0x10000181u;
+ public const uint OffhandKick = 0x10000182u;
+ public const uint AttackHigh4 = 0x10000183u;
+ public const uint AttackMed4 = 0x10000184u;
+ public const uint AttackLow4 = 0x10000185u;
+ public const uint AttackHigh5 = 0x10000186u;
+ public const uint AttackMed5 = 0x10000187u;
+ public const uint AttackLow5 = 0x10000188u;
+ public const uint AttackHigh6 = 0x10000189u;
+ public const uint AttackMed6 = 0x1000018Au;
+ public const uint AttackLow6 = 0x1000018Bu;
+ public const uint PunchFastHigh = 0x1000018Cu;
+ public const uint PunchFastMed = 0x1000018Du;
+ public const uint PunchFastLow = 0x1000018Eu;
+ public const uint PunchSlowHigh = 0x1000018Fu;
+ public const uint PunchSlowMed = 0x10000190u;
+ public const uint PunchSlowLow = 0x10000191u;
+ public const uint OffhandPunchFastHigh = 0x10000192u;
+ public const uint OffhandPunchFastMed = 0x10000193u;
+ public const uint OffhandPunchFastLow = 0x10000194u;
+ public const uint OffhandPunchSlowHigh = 0x10000195u;
+ public const uint OffhandPunchSlowMed = 0x10000196u;
+ public const uint OffhandPunchSlowLow = 0x10000197u;
+}
diff --git a/src/AcDream.Core/Combat/CombatManeuverSelector.cs b/src/AcDream.Core/Combat/CombatManeuverSelector.cs
new file mode 100644
index 0000000..8d0d07f
--- /dev/null
+++ b/src/AcDream.Core/Combat/CombatManeuverSelector.cs
@@ -0,0 +1,89 @@
+using DatReaderWriter.DBObjs;
+using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
+using DatMotionStance = DatReaderWriter.Enums.MotionStance;
+using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
+using DatAttackType = DatReaderWriter.Enums.AttackType;
+
+namespace AcDream.Core.Combat;
+
+///
+/// Selects combat swing motions from the retail CombatTable DBObj.
+///
+/// Retail evidence:
+/// - CombatManeuverTable::Get (0x0056AB60) loads DB type
+/// 0x1000000D for a 0x30xxxxxx combat table id.
+/// - ACE CombatManeuverTable.GetMotion indexes maneuvers by
+/// stance, attack height, and attack type, returning all matching motions.
+/// - ACE Player_Melee.GetSwingAnimation then chooses
+/// motions[1] when more than one motion exists and power is below
+/// the subdivision threshold; otherwise it uses motions[0].
+///
+public static class CombatManeuverSelector
+{
+ public const float DefaultSubdivision = 0.33f;
+ public const float ThrustSlashSubdivision = 0.66f;
+
+ public static CombatManeuverSelection SelectMotion(
+ CombatTable table,
+ DatMotionStance stance,
+ DatAttackHeight attackHeight,
+ DatAttackType attackType,
+ float powerLevel,
+ bool isThrustSlashWeapon = false)
+ {
+ var motions = FindMotions(table, stance, attackHeight, attackType);
+ if (motions.Count == 0)
+ return CombatManeuverSelection.None;
+
+ float subdivision = isThrustSlashWeapon
+ ? ThrustSlashSubdivision
+ : DefaultSubdivision;
+
+ var motion = motions.Count > 1 && powerLevel < subdivision
+ ? motions[1]
+ : motions[0];
+
+ return new CombatManeuverSelection(
+ Found: true,
+ Motion: motion,
+ Candidates: motions,
+ EffectiveAttackType: attackType,
+ Subdivision: subdivision);
+ }
+
+ public static IReadOnlyList FindMotions(
+ CombatTable table,
+ DatMotionStance stance,
+ DatAttackHeight attackHeight,
+ DatAttackType attackType)
+ {
+ var result = new List();
+
+ foreach (var maneuver in table.CombatManeuvers)
+ {
+ if (maneuver.Style == stance
+ && maneuver.AttackHeight == attackHeight
+ && maneuver.AttackType == attackType)
+ {
+ result.Add(maneuver.Motion);
+ }
+ }
+
+ return result;
+ }
+}
+
+public readonly record struct CombatManeuverSelection(
+ bool Found,
+ DatMotionCommand Motion,
+ IReadOnlyList Candidates,
+ DatAttackType EffectiveAttackType,
+ float Subdivision)
+{
+ public static CombatManeuverSelection None { get; } = new(
+ Found: false,
+ Motion: DatMotionCommand.Invalid,
+ Candidates: Array.Empty(),
+ EffectiveAttackType: DatAttackType.Undef,
+ Subdivision: 0f);
+}
diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs
index a70d6d7..a57d37d 100644
--- a/src/AcDream.Core/Combat/CombatModel.cs
+++ b/src/AcDream.Core/Combat/CombatModel.cs
@@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
// Full research: docs/research/deepdives/r02-combat-system.md
// ─────────────────────────────────────────────────────────────────────
+[Flags]
public enum CombatMode
{
Undef = 0,
- NonCombat = 1,
- Melee = 2,
- Missile = 3,
- Magic = 4,
- Peaceful = 5,
+ NonCombat = 0x01,
+ Melee = 0x02,
+ Missile = 0x04,
+ Magic = 0x08,
+
+ ValidCombat = NonCombat | Melee | Missile | Magic,
+ CombatCombat = Melee | Missile | Magic,
}
public enum AttackHeight
@@ -24,6 +27,51 @@ public enum AttackHeight
Low = 3,
}
+public enum CombatAttackAction
+{
+ Low,
+ Medium,
+ High,
+}
+
+///
+/// Retail input-facing combat decisions. The heavyweight parts of the combat
+/// system remain server authoritative; this helper only maps UI intent to the
+/// mode / attack-height values sent on the wire.
+///
+/// References:
+/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
+/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
+/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
+/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
+/// targeted attacks for Melee and Missile modes.
+///
+public static class CombatInputPlanner
+{
+ public static CombatMode ToggleMode(
+ CombatMode currentMode,
+ CombatMode defaultCombatMode = CombatMode.Melee)
+ {
+ if ((currentMode & CombatMode.CombatCombat) != 0)
+ return CombatMode.NonCombat;
+
+ return (defaultCombatMode & CombatMode.CombatCombat) != 0
+ ? defaultCombatMode
+ : CombatMode.Melee;
+ }
+
+ public static bool SupportsTargetedAttack(CombatMode mode) =>
+ mode == CombatMode.Melee || mode == CombatMode.Missile;
+
+ public static AttackHeight HeightFor(CombatAttackAction action) => action switch
+ {
+ CombatAttackAction.Low => AttackHeight.Low,
+ CombatAttackAction.Medium => AttackHeight.Medium,
+ CombatAttackAction.High => AttackHeight.High,
+ _ => AttackHeight.Medium,
+ };
+}
+
///
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
/// See r02 §2 + ACE.Entity.Enum.AttackType.
@@ -31,20 +79,26 @@ public enum AttackHeight
[Flags]
public enum AttackType : uint
{
- None = 0,
- Punch = 0x0001,
- Kick = 0x0002,
- Thrust = 0x0004,
- Slash = 0x0008,
- DoubleSlash = 0x0010,
- TripleSlash = 0x0020,
- DoubleThrust = 0x0040,
- TripleThrust = 0x0080,
- Offhand = 0x0100,
- OffhandSlash = 0x0200,
- OffhandThrust = 0x0400,
- ThrustSlash = 0x0800,
- // more in r02 §2
+ None = 0,
+ Punch = 0x0001,
+ Thrust = 0x0002,
+ Slash = 0x0004,
+ Kick = 0x0008,
+ OffhandPunch = 0x0010,
+ DoubleSlash = 0x0020,
+ TripleSlash = 0x0040,
+ DoubleThrust = 0x0080,
+ TripleThrust = 0x0100,
+ OffhandThrust = 0x0200,
+ OffhandSlash = 0x0400,
+ OffhandDoubleSlash = 0x0800,
+ OffhandTripleSlash = 0x1000,
+ OffhandDoubleThrust = 0x2000,
+ OffhandTripleThrust = 0x4000,
+ Unarmed = Punch | Kick | OffhandPunch,
+ MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust
+ | OffhandDoubleSlash | OffhandTripleSlash
+ | OffhandDoubleThrust | OffhandTripleThrust,
}
[Flags]
diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs
index 93a5094..15018b0 100644
--- a/src/AcDream.Core/Combat/CombatState.cs
+++ b/src/AcDream.Core/Combat/CombatState.cs
@@ -39,6 +39,8 @@ public sealed class CombatState
{
private readonly ConcurrentDictionary _healthByGuid = new();
+ public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
+
/// Fires when a target's health percent changes (from UpdateHealth).
public event Action? HealthChanged;
@@ -57,6 +59,12 @@ public sealed class CombatState
/// An attack commit completed (0x01A7). WeenieError = 0 on success.
public event Action? AttackDone;
+ /// The server accepted the attack and the power bar/animation can begin.
+ public event Action? AttackCommenced;
+
+ /// The locally requested or server-confirmed combat mode changed.
+ public event Action? CombatModeChanged;
+
///
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent KillerNotification (0x01AD)). Event payload is
@@ -94,6 +102,15 @@ public sealed class CombatState
HealthChanged?.Invoke(targetGuid, healthPercent);
}
+ public void SetCombatMode(CombatMode mode)
+ {
+ if (CurrentMode == mode)
+ return;
+
+ CurrentMode = mode;
+ CombatModeChanged?.Invoke(mode);
+ }
+
public void OnVictimNotification(
string attackerName, uint attackerGuid, uint damageType, uint damage,
uint hitQuadrant, uint critical, uint attackType)
@@ -140,5 +157,8 @@ public sealed class CombatState
public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError);
+ public void OnCombatCommenceAttack()
+ => AttackCommenced?.Invoke();
+
public void Clear() => _healthByGuid.Clear();
}
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/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index 9afe076..ffce8e1 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -330,6 +330,33 @@ public sealed class AnimationSequencer
/// makes the jump look delayed (legs stand still for ~100 ms while
/// the link drains, then fold into Falling). Defaults to false to
/// preserve normal smooth transitions for everything else.
+ ///
+ /// Check whether the underlying MotionTable contains a cycle for the
+ /// given (style, motion) pair. Useful for callers that want to fall
+ /// back to a known-good motion (e.g. WalkForward →
+ /// Ready) instead of triggering 's
+ /// unconditional ClearCyclicTail path on a missing cycle —
+ /// which leaves the body without any animation tail and snaps every
+ /// part to the setup-default offset (visible as "torso on the
+ /// ground" since most creatures' setup-default has limbs at the
+ /// torso origin).
+ ///
+ public bool HasCycle(uint style, uint motion)
+ {
+ // adjust_motion remapping (mirrors the head of SetCycle):
+ // TurnLeft, SideStepLeft, WalkBackward map to their right/forward
+ // mirror cycles.
+ uint adjustedMotion = motion;
+ switch (motion & 0xFFFFu)
+ {
+ case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
+ case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
+ case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
+ }
+ int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
+ return _mtable.Cycles.ContainsKey(cycleKey);
+ }
+
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
{
// ── adjust_motion: remap left→right / backward→forward variants ───
diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs
index 1a0a3e2..016d8e1 100644
--- a/src/AcDream.Core/Physics/MotionCommandResolver.cs
+++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs
@@ -84,6 +84,24 @@ public static class MotionCommandResolver
result[lo] = full;
}
}
+
+ ApplyNamedRetailOverrides(result);
return result;
}
+
+ private static void ApplyNamedRetailOverrides(Dictionary result)
+ {
+ // The generated DRW enum is shifted by three entries starting at
+ // AllegianceHometownRecall. The named Sept 2013 retail command_ids
+ // table is authoritative here:
+ // named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
+ // and command-name table lines 1068272-1068313.
+ //
+ // These values cover recall, offhand, attack 4-6, and fast/slow punch
+ // actions. Without the override, wire command 0x0170 reconstructs to
+ // IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
+ // animations route as UI commands and never play.
+ for (ushort lo = 0x016E; lo <= 0x0197; lo++)
+ result[lo] = 0x10000000u | lo;
+ }
}
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/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
new file mode 100644
index 0000000..90a0388
--- /dev/null
+++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
@@ -0,0 +1,304 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Per-tick steering for server-controlled remote creatures while a
+/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
+/// is the active locomotion source.
+///
+///
+/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
+/// stabilizer. With the full MoveTo path payload now captured on
+/// ,
+/// the body solver has the destination + heading + thresholds it needs to
+/// run the retail per-tick loop instead of waiting for sparse
+/// UpdatePosition snap corrections.
+///
+///
+///
+/// Retail references:
+///
+/// -
+/// MoveToManager::HandleMoveToPosition (0x00529d80) — the
+/// per-tick driver. Computes heading-to-target, fires an aux
+/// TurnLeft/TurnRight command when |delta| > 20°, snaps
+/// orientation when within tolerance, and tests arrival via
+/// dist <= min_distance (chase) or
+/// dist >= distance_to_object (flee).
+///
+/// -
+/// MoveToManager::_DoMotion / _StopMotion route turn
+/// commands through CMotionInterp::DoInterpretedMotion — i.e.
+/// MoveToManager itself does NOT touch the body. The body's actual
+/// velocity comes from CMotionInterp::apply_current_movement
+/// reading InterpretedState.ForwardCommand = RunForward and
+/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by
+/// the body's orientation.
+///
+///
+///
+///
+///
+/// Acdream port scope: minimum viable subset. We skip target re-tracking
+/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
+/// StickTo, fail-distance progress detector, and the sphere-cylinder
+/// distance variant — all server-side concerns the local body doesn't need
+/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
+/// (with ACE's set_heading(true) snap-on-aligned fudge), and
+/// arrival detection via min_distance.
+///
+///
+///
+/// ACE divergence: ACE swaps the chase/flee arrival predicates
+/// (dist <= DistanceToObject vs retail's dist <= MinDistance).
+/// We follow retail.
+///
+///
+public static class RemoteMoveToDriver
+{
+ ///
+ /// Heading tolerance below which we snap orientation directly to the
+ /// target heading (ACE's set_heading(target, true)
+ /// server-tic-rate fudge). Above tolerance we rotate at
+ /// . Retail value (line 307251 of
+ /// acclient_2013_pseudo_c.txt) is 20°.
+ ///
+ public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
+
+ ///
+ /// Default angular rate for in-motion heading correction when delta
+ /// exceeds . Picked to match
+ /// ACE's TurnSpeed default of π/2 rad/s for monsters;
+ /// when the per-creature value differs, the future port can wire it
+ /// in via the TurnSpeed field on InterpretedMotionState.
+ ///
+ public const float TurnRateRadPerSec = MathF.PI / 2.0f;
+
+ ///
+ /// Float-comparison slack for the arrival predicate. With
+ /// min_distance == 0 in a chase packet, exact equality is
+ /// unreachable due to integration wobble; this epsilon prevents the
+ /// driver from over-shooting by a sub-meter and snap-flipping back.
+ ///
+ public const float ArrivalEpsilon = 0.05f;
+
+ ///
+ /// Maximum staleness (seconds) of the most recent MoveTo packet
+ /// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
+ /// during active chase; if no fresh packet arrives for this long,
+ /// the entity has likely either left our streaming view, switched
+ /// to a non-MoveTo motion the server's broadcast didn't reach us
+ /// for, or had its move cancelled server-side without our seeing
+ /// the cancel UM. In any of those cases, continuing to drive the
+ /// body toward a stale destination produces the "monster runs in
+ /// place after popping back into view" symptom (2026-04-28).
+ /// 1.5 s gives us comfortable margin over the ~1 s emit cadence
+ /// while still failing fast on real loss-of-state.
+ ///
+ public const double StaleDestinationSeconds = 1.5;
+
+ public enum DriveResult
+ {
+ /// Within arrival window — caller should zero velocity.
+ Arrived,
+ /// Steering active — caller should let
+ /// apply_current_movement set body velocity from the cycle.
+ Steering,
+ }
+
+ ///
+ /// Steer body orientation toward
+ /// and report whether the body has arrived or should keep running.
+ /// Pure function — emits the updated orientation via
+ /// (the input is not mutated; the
+ /// caller assigns the new value back to its body).
+ ///
+ ///
+ /// min_distance from the wire's MovementParameters block —
+ /// retail's HandleMoveToPosition chase-arrival threshold.
+ ///
+ ///
+ /// distance_to_object from the wire — ACE's chase-arrival
+ /// threshold (default 0.6 m, the melee range). The actual arrival
+ /// gate is max(minDistance, distanceToObject): retail-faithful
+ /// when retail sends min_distance > 0, ACE-compatible when
+ /// ACE puts the value in distance_to_object with
+ /// min_distance == 0. Without this, ACE's min_distance==0
+ /// chase packets never arrive — the body keeps re-targeting around
+ /// the player at melee range and visibly oscillates between facings,
+ /// which is the user-reported "monster keeps running in different
+ /// directions when it should be attacking" symptom (2026-04-28).
+ ///
+ public static DriveResult Drive(
+ Vector3 bodyPosition,
+ Quaternion bodyOrientation,
+ Vector3 destinationWorld,
+ float minDistance,
+ float distanceToObject,
+ float dt,
+ bool moveTowards,
+ out Quaternion newOrientation)
+ {
+ // Horizontal distance only — server owns Z, our body Z is
+ // hard-snapped to the latest UpdatePosition.
+ float dx = destinationWorld.X - bodyPosition.X;
+ float dy = destinationWorld.Y - bodyPosition.Y;
+ float dist = MathF.Sqrt(dx * dx + dy * dy);
+
+ // Arrival predicate per retail MoveToManager::HandleMoveToPosition
+ // (acclient_2013_pseudo_c.txt:307289-307320) and ACE
+ // MoveToManager.cs:476:
+ //
+ // chase (MoveTowards): dist <= distance_to_object
+ // flee (MoveAway): dist >= min_distance
+ //
+ // (My earlier max(MinDistance, DistanceToObject) was a
+ // defensive guess; cross-checked with two independent research
+ // agents against the named retail decomp + ACE port + holtburger,
+ // the chase threshold is unambiguously DistanceToObject —
+ // MinDistance is the FLEE arrival threshold. ACE's wire defaults
+ // give MinDistance=0, DistanceToObject=0.6 — the body should stop
+ // at melee range, not run to zero.)
+ float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
+ if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
+ {
+ newOrientation = bodyOrientation;
+ return DriveResult.Arrived;
+ }
+ if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
+ {
+ newOrientation = bodyOrientation;
+ return DriveResult.Arrived;
+ }
+
+ // Degenerate — already on target horizontally; preserve heading.
+ if (dist < 1e-4f)
+ {
+ newOrientation = bodyOrientation;
+ return DriveResult.Steering;
+ }
+
+ // Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
+ // at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
+ // World forward = Transform((0,1,0), orientation). Yaw extracted
+ // via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
+ var localForward = new Vector3(0f, 1f, 0f);
+ var worldForward = Vector3.Transform(localForward, bodyOrientation);
+ float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
+
+ // Desired heading: face the target. (dx, dy) is the world-space
+ // offset to the target. With local-forward=+Y we want yaw such
+ // that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
+ // to yaw = atan2(-dx, dy).
+ float desiredYaw = MathF.Atan2(-dx, dy);
+ float delta = WrapPi(desiredYaw - currentYaw);
+
+ if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
+ {
+ // ACE's set_heading(target, true) — sync to server-tic-rate.
+ // We have the same sparse-UP problem ACE does, so the same
+ // fudge applies.
+ newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
+ }
+ else
+ {
+ // Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
+ // rotate at TurnRate clamped to dt, in the shorter direction.
+ float maxStep = TurnRateRadPerSec * dt;
+ float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
+ // Apply incremental yaw around world +Z (preserving any
+ // server-supplied pitch/roll from the latest UpdatePosition).
+ var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
+ newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
+ }
+
+ return DriveResult.Steering;
+ }
+
+ ///
+ /// Convert a landblock-local Origin from a MoveTo packet
+ /// ()
+ /// into acdream's render world space using the same arithmetic as
+ /// OnLivePositionUpdated: shift by the landblock-grid offset
+ /// from the live-mode center.
+ ///
+ public static Vector3 OriginToWorld(
+ uint originCellId,
+ float originX,
+ float originY,
+ float originZ,
+ int liveCenterLandblockX,
+ int liveCenterLandblockY)
+ {
+ int lbX = (int)((originCellId >> 24) & 0xFFu);
+ int lbY = (int)((originCellId >> 16) & 0xFFu);
+ return new Vector3(
+ originX + (lbX - liveCenterLandblockX) * 192f,
+ originY + (lbY - liveCenterLandblockY) * 192f,
+ originZ);
+ }
+
+ ///
+ /// Cap horizontal velocity so the body lands exactly at
+ /// rather than overshooting past
+ /// it during the final tick of approach. Without this clamp, a body
+ /// running at RunAnimSpeed × speedMod ≈ 4 m/s can overshoot
+ /// the 0.6 m arrival window by up to one tick's advance (~6 cm at
+ /// 60 fps) — visible as the creature "running slightly through" the
+ /// player it's about to attack (user-reported 2026-04-28).
+ ///
+ ///
+ /// The clamp is a strict scale-down of the horizontal component
+ /// (X/Y); the vertical component (Z) is left to gravity / terrain
+ /// handling. false (flee branch) is a
+ /// no-op since fleeing has no overshoot risk — the body wants to
+ /// move AWAY from the destination.
+ ///
+ ///
+ public static Vector3 ClampApproachVelocity(
+ Vector3 bodyPosition,
+ Vector3 currentVelocity,
+ Vector3 destinationWorld,
+ float arrivalThreshold,
+ float dt,
+ bool moveTowards)
+ {
+ if (!moveTowards || dt <= 0f) return currentVelocity;
+
+ float dx = destinationWorld.X - bodyPosition.X;
+ float dy = destinationWorld.Y - bodyPosition.Y;
+ float dist = MathF.Sqrt(dx * dx + dy * dy);
+ float remaining = MathF.Max(0f, dist - arrivalThreshold);
+
+ float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
+ + currentVelocity.Y * currentVelocity.Y);
+ if (vxy < 1e-3f) return currentVelocity;
+
+ float advance = vxy * dt;
+ if (advance <= remaining) return currentVelocity;
+
+ // Already inside or right at the threshold: zero horizontal
+ // velocity, keep Z. (The arrival predicate in Drive() should
+ // have fired this tick, but this is the belt-and-braces guard.)
+ if (remaining < 1e-3f)
+ return new Vector3(0f, 0f, currentVelocity.Z);
+
+ float scale = remaining / advance;
+ return new Vector3(
+ currentVelocity.X * scale,
+ currentVelocity.Y * scale,
+ currentVelocity.Z);
+ }
+
+ /// Wrap an angle in radians to [-π, π].
+ private static float WrapPi(float r)
+ {
+ const float TwoPi = MathF.PI * 2f;
+ r %= TwoPi;
+ if (r > MathF.PI) r -= TwoPi;
+ if (r < -MathF.PI) r += TwoPi;
+ return r;
+ }
+}
diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs
new file mode 100644
index 0000000..af4d14d
--- /dev/null
+++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Chooses the visible locomotion cycle for server-controlled remotes whose
+/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an
+/// InterpretedMotionState.
+///
+/// Retail references:
+///
+/// -
+/// MovementManager::PerformMovement (0x00524440) dispatches movement
+/// types 6/7 into MoveToManager::MoveToObject/MoveToPosition instead
+/// of unpacking an InterpretedMotionState.
+///
+/// -
+/// MovementParameters::UnPackNet (0x0052AC50) shows MoveTo packets
+/// carry movement params + run rate, not a ForwardCommand field.
+///
+/// -
+/// ACE MovementData.Write uses the same movement type union; holtburger
+/// documents the matching MovementType::MoveToPosition = 7.
+///
+///
+///
+public static class ServerControlledLocomotion
+{
+ public const float StopSpeed = 0.20f;
+ public const float RunThreshold = 1.25f;
+ public const float MinSpeedMod = 0.25f;
+ public const float MaxSpeedMod = 3.00f;
+
+ // Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
+ // (0x0052AA00) seeds forward motion before the next position update.
+ public static LocomotionCycle PlanMoveToStart(
+ float moveToSpeed = 1f,
+ float runRate = 1f,
+ bool canRun = true)
+ {
+ moveToSpeed = SanitizePositive(moveToSpeed);
+ runRate = SanitizePositive(runRate);
+
+ if (!canRun)
+ return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
+
+ return new LocomotionCycle(
+ MotionCommand.RunForward,
+ moveToSpeed * runRate,
+ true);
+ }
+
+ public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
+ {
+ float horizontalSpeed = MathF.Sqrt(
+ worldVelocity.X * worldVelocity.X +
+ worldVelocity.Y * worldVelocity.Y);
+
+ if (horizontalSpeed < StopSpeed)
+ return new LocomotionCycle(MotionCommand.Ready, 1f, false);
+
+ if (horizontalSpeed < RunThreshold)
+ {
+ float speedMod = Math.Clamp(
+ horizontalSpeed / MotionInterpreter.WalkAnimSpeed,
+ MinSpeedMod,
+ MaxSpeedMod);
+ return new LocomotionCycle(MotionCommand.WalkForward, speedMod, true);
+ }
+
+ return new LocomotionCycle(
+ MotionCommand.RunForward,
+ Math.Clamp(horizontalSpeed / MotionInterpreter.RunAnimSpeed, MinSpeedMod, MaxSpeedMod),
+ true);
+ }
+
+ public readonly record struct LocomotionCycle(
+ uint Motion,
+ float SpeedMod,
+ bool IsMoving);
+
+ private static float SanitizePositive(float value)
+ {
+ return float.IsFinite(value) && value > 0f ? value : 1f;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index d32936c..f740efb 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -241,28 +241,32 @@ public sealed class GameEventWiringTests
}
[Fact]
- public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
+ public void WireAll_KillerNotification_AppendsCombatLine()
{
- // Issue #10 — orphan parser at GameEvents.ParseKillerNotification
- // existed but was never registered for dispatch until 2026-04-25.
- // Now wired: 0x01AD lands on CombatState.OnKillerNotification +
- // fires the KillLanded event.
- var (d, _, combat, _, _) = MakeAll();
- string? gotVictimName = null;
- uint gotVictimGuid = 0;
- combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
-
- // Wire shape: string16L victimName + u32 victimGuid
- byte[] nameBytes = MakeString16L("Drudge");
- byte[] payload = new byte[nameBytes.Length + 4];
- Array.Copy(nameBytes, payload, nameBytes.Length);
- BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
+ var (d, _, _, _, chat) = MakeAll();
+ byte[] payload = MakeString16L("You killed the drudge!");
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value);
- Assert.Equal("Drudge", gotVictimName);
- Assert.Equal(0x80001234u, gotVictimGuid);
+ Assert.Equal(1, chat.Count);
+ var entry = chat.Snapshot()[0];
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Info, entry.CombatKind);
+ Assert.Equal("You killed the drudge!", entry.Text);
+ }
+
+ [Fact]
+ public void WireAll_CombatCommenceAttack_FiresCombatStateEvent()
+ {
+ var (d, _, combat, _, _) = MakeAll();
+ bool commenced = false;
+ combat.AttackCommenced += () => commenced = true;
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty()));
+ d.Dispatch(env!.Value);
+
+ Assert.True(commenced);
}
[Fact]
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
index 979aeaa..9419461 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
@@ -57,4 +57,13 @@ public sealed class CharacterActionsTests
Assert.Equal(2u, // Melee = 2
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
+
+ [Fact]
+ public void CombatMode_UsesRetailAceBitValues()
+ {
+ Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat);
+ Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee);
+ Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile);
+ Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic);
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs
index 2352cac..b10b308 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Buffers.Binary;
-using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
@@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class CombatEventTests
{
- private static byte[] MakeString16L(string s)
- {
- byte[] data = Encoding.ASCII.GetBytes(s);
- int recordSize = 2 + data.Length;
- int padding = (4 - (recordSize & 3)) & 3;
- byte[] result = new byte[recordSize + padding];
- BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
- Array.Copy(data, 0, result, 2, data.Length);
- return result;
- }
-
[Fact]
- public void AttackTargetRequest_Build_EmitsCorrectWireBytes()
+ public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
{
- byte[] body = AttackTargetRequest.Build(
+ byte[] body = AttackTargetRequest.BuildMelee(
gameActionSequence: 3,
targetGuid: 0x12345678u,
- powerLevel: 0.75f,
- accuracyLevel: 0.5f,
- attackHeight: 2);
+ attackHeight: 2,
+ powerLevel: 0.75f);
- Assert.Equal(28, body.Length);
+ Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(3u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
- Assert.Equal(AttackTargetRequest.SubOpcode,
+ Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x12345678u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(2u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.75f,
- BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4);
+ BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
+ }
+
+ [Fact]
+ public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes()
+ {
+ byte[] body = AttackTargetRequest.BuildMissile(
+ gameActionSequence: 4,
+ targetGuid: 0x87654321u,
+ attackHeight: 1,
+ accuracyLevel: 0.5f);
+
+ Assert.Equal(24, body.Length);
+ Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0x87654321u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(1u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.5f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
- Assert.Equal(2u,
- BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
}
[Fact]
- public void ParseVictimNotification_RoundTrip()
+ public void AttackTargetRequest_BuildCancel_HasNoPayload()
{
- byte[] name = MakeString16L("Attacker");
- byte[] tail = new byte[24];
- BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType
+ byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
- byte[] payload = new byte[name.Length + tail.Length];
- Buffer.BlockCopy(name, 0, payload, 0, name.Length);
- Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
+ Assert.Equal(12, body.Length);
+ Assert.Equal(AttackTargetRequest.CancelAttackOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void ParseAttackDone_HoltburgerFixture()
+ {
+ var env = ParseFixture("B0F700000000000000000000A701000036000000");
+
+ Assert.Equal(GameEventType.AttackDone, env.EventType);
+ var parsed = GameEvents.ParseAttackDone(env.Payload.Span);
- var parsed = GameEvents.ParseVictimNotification(payload);
Assert.NotNull(parsed);
- Assert.Equal("Attacker", parsed!.Value.AttackerName);
- Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
- Assert.Equal(42u, parsed.Value.Damage);
+ Assert.Equal(0u, parsed!.Value.AttackSequence);
+ Assert.Equal(0x36u, parsed.Value.WeenieError);
+ }
+
+ [Fact]
+ public void ParseAttackerNotification_HoltburgerFixture()
+ {
+ var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000");
+
+ var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span);
+
+ Assert.NotNull(parsed);
+ Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName);
+ Assert.Equal(1u, parsed.Value.DamageType);
+ Assert.Equal(0.25, parsed.Value.HealthPercent, 6);
+ Assert.Equal(37u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.Critical);
+ Assert.Equal(6ul, parsed.Value.AttackConditions);
}
[Fact]
- public void ParseAttackerNotification_RoundTrip()
+ public void ParseDefenderNotification_HoltburgerFixture()
{
- byte[] name = MakeString16L("Drudge");
- byte[] tail = new byte[12];
- BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType
- BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage
- BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent
+ var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
- byte[] payload = new byte[name.Length + tail.Length];
- Buffer.BlockCopy(name, 0, payload, 0, name.Length);
- Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
+ var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
- var parsed = GameEvents.ParseAttackerNotification(payload);
Assert.NotNull(parsed);
- Assert.Equal("Drudge", parsed!.Value.DefenderName);
- Assert.Equal(30u, parsed.Value.Damage);
- Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
+ Assert.Equal("Banderling", parsed!.Value.AttackerName);
+ Assert.Equal(0x10u, parsed.Value.DamageType);
+ Assert.Equal(0.125, parsed.Value.HealthPercent, 6);
+ Assert.Equal(18u, parsed.Value.Damage);
+ Assert.Equal(1u, parsed.Value.HitQuadrant);
+ Assert.Equal(0u, parsed.Value.Critical);
+ Assert.Equal(8ul, parsed.Value.AttackConditions);
}
[Fact]
- public void ParseEvasionAttackerNotification_RoundTrip()
+ public void ParseEvasionNotifications_HoltburgerFixtures()
{
- byte[] payload = MakeString16L("Thrower");
- Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
+ var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
+ var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
+
+ Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
+ Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
}
[Fact]
- public void ParseAttackDone_RoundTrip()
+ public void ParseCombatCommenceAttack_HoltburgerFixture()
{
- byte[] payload = new byte[8];
- BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
- BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
+ var env = ParseFixture("B0F700000000000005000000B8010000");
- var parsed = GameEvents.ParseAttackDone(payload);
- Assert.NotNull(parsed);
- Assert.Equal(42u, parsed!.Value.AttackSequence);
- Assert.Equal(0u, parsed.Value.WeenieError);
+ Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType);
+ Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span));
+ }
+
+ [Fact]
+ public void ParseDeathNotifications_HoltburgerFixtures()
+ {
+ var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421");
+ var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521");
+
+ Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage);
+ Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage);
+ }
+
+ private static GameEventEnvelope ParseFixture(string hex)
+ {
+ byte[] body = Convert.FromHexString(hex);
+ var env = GameEventEnvelope.TryParse(body);
+ Assert.NotNull(env);
+ return env.Value;
}
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
new file mode 100644
index 0000000..a7dea33
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -0,0 +1,99 @@
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class CreateObjectTests
+{
+ [Fact]
+ public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
+ {
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x50000002u,
+ name: "Drudge",
+ itemType: (uint)ItemType.Creature);
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x50000002u, parsed.Value.Guid);
+ Assert.Equal("Drudge", parsed.Value.Name);
+ Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
+ }
+
+ private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
+ uint guid,
+ string name,
+ uint itemType)
+ {
+ var bytes = new List();
+ WriteU32(bytes, CreateObject.Opcode);
+ WriteU32(bytes, guid);
+
+ // ModelData header: marker, subpalette count, texture count, animpart count.
+ bytes.Add(0x11);
+ bytes.Add(0);
+ bytes.Add(0);
+ bytes.Add(0);
+
+ // PhysicsData: no flags, empty physics state, then 9 sequence stamps.
+ WriteU32(bytes, 0);
+ WriteU32(bytes, 0);
+ for (int i = 0; i < 9; i++)
+ WriteU16(bytes, 0);
+ Align4(bytes);
+
+ // Fixed WeenieHeader prefix per ACE SerializeCreateObject.
+ WriteU32(bytes, 0); // weenieFlags
+ WriteString16L(bytes, name);
+ WritePackedDword(bytes, 0x1234); // WeenieClassId
+ WritePackedDword(bytes, 0); // IconId via known-type writer
+ WriteU32(bytes, itemType);
+ WriteU32(bytes, 0); // ObjectDescriptionFlags
+ Align4(bytes);
+
+ return bytes.ToArray();
+ }
+
+ private static void WriteU32(List bytes, uint value)
+ {
+ Span tmp = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
+ bytes.AddRange(tmp.ToArray());
+ }
+
+ private static void WriteU16(List bytes, ushort value)
+ {
+ Span tmp = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
+ bytes.AddRange(tmp.ToArray());
+ }
+
+ private static void WritePackedDword(List bytes, uint value)
+ {
+ if (value <= 0x7FFF)
+ {
+ WriteU16(bytes, (ushort)value);
+ return;
+ }
+
+ WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
+ WriteU16(bytes, (ushort)(value & 0xFFFF));
+ }
+
+ private static void WriteString16L(List bytes, string value)
+ {
+ byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
+ WriteU16(bytes, checked((ushort)encoded.Length));
+ bytes.AddRange(encoded);
+ Align4(bytes);
+ }
+
+ private static void Align4(List bytes)
+ {
+ while ((bytes.Count & 3) != 0)
+ bytes.Add(0);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
new file mode 100644
index 0000000..b464cab
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
@@ -0,0 +1,39 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class DeleteObjectTests
+{
+ [Fact]
+ public void RejectsWrongOpcode()
+ {
+ Span body = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
+
+ Assert.Null(DeleteObject.TryParse(body));
+ }
+
+ [Fact]
+ public void RejectsTruncated()
+ {
+ Assert.Null(DeleteObject.TryParse(ReadOnlySpan.Empty));
+ Assert.Null(DeleteObject.TryParse(new byte[9]));
+ }
+
+ [Fact]
+ public void ParsesGuidAndInstanceSequence()
+ {
+ Span body = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000439u);
+ BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
+
+ var parsed = DeleteObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x80000439u, parsed!.Value.Guid);
+ Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
index 08de618..09f9eb9 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
@@ -185,7 +185,8 @@ public class UpdateMotionTests
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{
- // movementType != 0 means one of the Move* variants we don't parse.
+ // movementType != 0 means one of the Move* variants; a truncated
+ // non-Invalid payload still returns the outer state.
// The parser must still return a valid Parsed with the outer stance
// and a null ForwardCommand rather than failing the whole message.
var body = new byte[4 + 4 + 2 + 6 + 4];
@@ -194,7 +195,7 @@ public class UpdateMotionTests
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
- body[p++] = 1; // movementType = MoveToObject (non-Invalid)
+ body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
@@ -202,5 +203,152 @@ public class UpdateMotionTests
Assert.NotNull(result);
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
+ Assert.Equal((byte)7, result.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
+ }
+
+ [Fact]
+ public void ParsesMoveToPositionSpeedAndRunRate()
+ {
+ // Layout after MovementData's movementType/motionFlags/currentStyle:
+ // Origin: cell + xyz (16 bytes)
+ // MoveToParameters: flags, distance, min, fail, speed,
+ // walk/run threshold, desired heading (28 bytes)
+ // runRate: f32
+ var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6;
+ body[p++] = 7; // MoveToPosition
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
+
+ const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
+ Assert.Null(result.Value.MotionState.ForwardCommand);
+ Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
+ Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
+ Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
+ Assert.True(result.Value.MotionState.MoveToCanRun);
+ Assert.True(result.Value.MotionState.MoveTowards);
+
+ // Phase L.1c (2026-04-28): full path payload retained.
+ Assert.NotNull(result.Value.MotionState.MoveToPath);
+ var path = result.Value.MotionState.MoveToPath!.Value;
+ Assert.Null(path.TargetGuid);
+ Assert.Equal(0xA8B4000Eu, path.OriginCellId);
+ Assert.Equal(10f, path.OriginX);
+ Assert.Equal(20f, path.OriginY);
+ Assert.Equal(30f, path.OriginZ);
+ Assert.Equal(0.6f, path.DistanceToObject);
+ Assert.Equal(0.0f, path.MinDistance);
+ Assert.Equal(float.MaxValue, path.FailDistance);
+ Assert.Equal(15.0f, path.WalkRunThreshold);
+ Assert.Equal(90.0f, path.DesiredHeading);
+ }
+
+ [Fact]
+ public void ParsesAttackHigh1_AsActionForwardCommand()
+ {
+ // Phase L.1c followup (2026-04-28): regression that verifies the
+ // wire-format ACE uses for melee swings — mt=0 with
+ // ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and
+ // ForwardSpeed (typically the animSpeed). The receiver in
+ // GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy
+ // ForwardCommand into the body's InterpretedState so that
+ // get_state_velocity returns 0 (gate is RunForward||WalkForward).
+ var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6; // header padding
+
+ body[p++] = 0; // mt = Invalid (interpreted)
+ body[p++] = 0; // motion_flags
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat
+
+ // InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04)
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)0, result!.Value.MotionState.MovementType);
+ Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand);
+ Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
+ }
+
+ [Fact]
+ public void ParsesMoveToObjectTargetGuidAndOrigin()
+ {
+ // Type 6 (MoveToObject) prepends a u32 target guid before the
+ // standard Origin + MovementParameters + runRate payload.
+ // Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
+ var body = new byte[20 + 4 + 16 + 28 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6; // MovementData header padding
+
+ body[p++] = 6; // MoveToObject
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
+
+ const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.NotNull(result.Value.MotionState.MoveToPath);
+ var path = result.Value.MotionState.MoveToPath!.Value;
+ Assert.Equal(0x80001234u, path.TargetGuid);
+ Assert.Equal(0xA8B4000Eu, path.OriginCellId);
+ Assert.Equal(5f, path.OriginX);
+ Assert.Equal(6f, path.OriginY);
+ Assert.Equal(7f, path.OriginZ);
+ Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs
new file mode 100644
index 0000000..0bdd0be
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using AcDream.Core.Combat;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class WorldSessionCombatTests
+{
+ private static WorldSession NewSession()
+ {
+ var ep = new IPEndPoint(IPAddress.Loopback, 65000);
+ return new WorldSession(ep);
+ }
+
+ [Fact]
+ public void SendChangeCombatMode_UsesSequenceAndRetailModeValue()
+ {
+ using var session = NewSession();
+ byte[]? captured = null;
+ session.GameActionCapture = body => captured = body;
+
+ session.SendChangeCombatMode(CombatMode.Magic);
+
+ Assert.NotNull(captured);
+ Assert.Equal(CharacterActions.BuildChangeCombatMode(
+ 1,
+ CharacterActions.CombatMode.Magic), captured);
+ }
+
+ [Fact]
+ public void SendMeleeAttack_UsesRetailMeleeBuilder()
+ {
+ using var session = NewSession();
+ byte[]? captured = null;
+ session.GameActionCapture = body => captured = body;
+
+ session.SendMeleeAttack(0x50000002u, AttackHeight.High, 0.75f);
+
+ Assert.NotNull(captured);
+ Assert.Equal(AttackTargetRequest.BuildMelee(
+ 1,
+ 0x50000002u,
+ (uint)AttackHeight.High,
+ 0.75f), captured);
+ }
+
+ [Fact]
+ public void SendMissileAttack_UsesRetailMissileBuilder()
+ {
+ using var session = NewSession();
+ byte[]? captured = null;
+ session.GameActionCapture = body => captured = body;
+
+ session.SendMissileAttack(0x50000003u, AttackHeight.Low, 0.5f);
+
+ Assert.NotNull(captured);
+ Assert.Equal(AttackTargetRequest.BuildMissile(
+ 1,
+ 0x50000003u,
+ (uint)AttackHeight.Low,
+ 0.5f), captured);
+ }
+
+ [Fact]
+ public void SendCancelAttack_UsesRetailCancelBuilder()
+ {
+ using var session = NewSession();
+ byte[]? captured = null;
+ session.GameActionCapture = body => captured = body;
+
+ session.SendCancelAttack();
+
+ Assert.NotNull(captured);
+ Assert.Equal(AttackTargetRequest.BuildCancel(1), captured);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs
new file mode 100644
index 0000000..7a4a9a1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs
@@ -0,0 +1,89 @@
+using AcDream.Core.Combat;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Combat;
+
+public sealed class CombatAnimationPlannerTests
+{
+ [Theory]
+ [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed
+ [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh
+ [InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed
+ [InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow
+ [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
+ [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
+ [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
+ [InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6
+ [InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell
+ [InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff
+ [InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1
+ [InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward
+ [InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead
+ [InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat
+ [InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat
+ [InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat
+ public void ClassifyMotionCommand_RecognisesRetailCombatCommands(
+ uint command,
+ CombatAnimationKind expected)
+ {
+ Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
+ }
+
+ [Theory]
+ [InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh
+ [InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed
+ [InlineData(0x018B, 0x1000018Bu)] // AttackLow6
+ [InlineData(0x018E, 0x1000018Eu)] // PunchFastLow
+ public void MotionCommandResolver_UsesNamedRetailLateCombatCommands(
+ ushort wireCommand,
+ uint expectedFullCommand)
+ {
+ Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand));
+ }
+
+ [Fact]
+ public void PlanFromWireCommand_Swing_IsActionOverlay()
+ {
+ var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f);
+
+ Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind);
+ Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind);
+ Assert.Equal(0x10000058u, plan.MotionCommand);
+ Assert.Equal(1.25f, plan.SpeedMod);
+ Assert.True(plan.HasMotion);
+ }
+
+ [Fact]
+ public void PlanFromWireCommand_Dead_IsPersistentSubState()
+ {
+ var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011);
+
+ Assert.Equal(CombatAnimationKind.Death, plan.Kind);
+ Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind);
+ Assert.Equal(MotionCommand.Dead, plan.MotionCommand);
+ }
+
+ [Fact]
+ public void PlanFromWireCommand_Unknown_IsNone()
+ {
+ var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF);
+
+ Assert.Equal(CombatAnimationPlan.None, plan);
+ Assert.False(plan.HasMotion);
+ }
+
+ [Theory]
+ [InlineData(CombatAnimationEvent.CombatCommenceAttack)]
+ [InlineData(CombatAnimationEvent.AttackDone)]
+ [InlineData(CombatAnimationEvent.AttackerNotification)]
+ [InlineData(CombatAnimationEvent.DefenderNotification)]
+ [InlineData(CombatAnimationEvent.EvasionAttackerNotification)]
+ [InlineData(CombatAnimationEvent.EvasionDefenderNotification)]
+ [InlineData(CombatAnimationEvent.VictimNotification)]
+ [InlineData(CombatAnimationEvent.KillerNotification)]
+ public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent)
+ {
+ Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
new file mode 100644
index 0000000..c970d45
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
@@ -0,0 +1,43 @@
+using AcDream.Core.Combat;
+
+namespace AcDream.Core.Tests.Combat;
+
+public sealed class CombatInputPlannerTests
+{
+ [Fact]
+ public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
+ {
+ Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
+ Assert.Equal(
+ CombatMode.Missile,
+ CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
+ }
+
+ [Fact]
+ public void ToggleMode_FromCombat_ReturnsNonCombat()
+ {
+ Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
+ Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
+ }
+
+ [Theory]
+ [InlineData(CombatAttackAction.Low, AttackHeight.Low)]
+ [InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
+ [InlineData(CombatAttackAction.High, AttackHeight.High)]
+ public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
+ {
+ Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
+ }
+
+ [Theory]
+ [InlineData(CombatMode.Melee, true)]
+ [InlineData(CombatMode.Missile, true)]
+ [InlineData(CombatMode.NonCombat, false)]
+ [InlineData(CombatMode.Magic, false)]
+ public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
+ CombatMode mode,
+ bool expected)
+ {
+ Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
new file mode 100644
index 0000000..72e32a7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
@@ -0,0 +1,155 @@
+using AcDream.Core.Combat;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
+using DatAttackType = DatReaderWriter.Enums.AttackType;
+using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
+using DatMotionStance = DatReaderWriter.Enums.MotionStance;
+using Xunit;
+
+namespace AcDream.Core.Tests.Combat;
+
+public sealed class CombatManeuverSelectorTests
+{
+ [Fact]
+ public void SelectMotion_UsesFirstEntryAtOrAboveSubdivision()
+ {
+ var table = MakeTable(
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
+ DatAttackType.Slash, DatMotionCommand.SlashMed),
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
+ DatAttackType.Slash, DatMotionCommand.BackhandMed));
+
+ var atThreshold = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.Medium,
+ DatAttackType.Slash,
+ powerLevel: CombatManeuverSelector.DefaultSubdivision);
+
+ var highPower = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.Medium,
+ DatAttackType.Slash,
+ powerLevel: 1f);
+
+ Assert.Equal(DatMotionCommand.SlashMed, atThreshold.Motion);
+ Assert.Equal(DatMotionCommand.SlashMed, highPower.Motion);
+ }
+
+ [Fact]
+ public void SelectMotion_UsesSecondEntryBelowSubdivision()
+ {
+ var table = MakeTable(
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
+ DatAttackType.Slash, DatMotionCommand.SlashMed),
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
+ DatAttackType.Slash, DatMotionCommand.BackhandMed));
+
+ var selection = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.Medium,
+ DatAttackType.Slash,
+ powerLevel: 0.2f);
+
+ Assert.True(selection.Found);
+ Assert.Equal(DatMotionCommand.BackhandMed, selection.Motion);
+ Assert.Equal(DatAttackType.Slash, selection.EffectiveAttackType);
+ Assert.Equal(2, selection.Candidates.Count);
+ }
+
+ [Fact]
+ public void SelectMotion_ThrustSlashWeaponUsesTwoThirdsSubdivision()
+ {
+ var table = MakeTable(
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
+ DatAttackType.Slash, DatMotionCommand.SlashHigh),
+ Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
+ DatAttackType.Slash, DatMotionCommand.BackhandHigh));
+
+ var normal = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.High,
+ DatAttackType.Slash,
+ powerLevel: 0.5f);
+
+ var thrustSlash = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.High,
+ DatAttackType.Slash,
+ powerLevel: 0.5f,
+ isThrustSlashWeapon: true);
+
+ Assert.Equal(DatMotionCommand.SlashHigh, normal.Motion);
+ Assert.Equal(DatMotionCommand.BackhandHigh, thrustSlash.Motion);
+ Assert.Equal(CombatManeuverSelector.ThrustSlashSubdivision, thrustSlash.Subdivision);
+ }
+
+ [Fact]
+ public void SelectMotion_MissingLookupReturnsNone()
+ {
+ var table = MakeTable(
+ Entry(DatMotionStance.BowCombat, DatAttackHeight.High,
+ DatAttackType.Punch, DatMotionCommand.Shoot));
+
+ var selection = CombatManeuverSelector.SelectMotion(
+ table,
+ DatMotionStance.SwordCombat,
+ DatAttackHeight.High,
+ DatAttackType.Punch,
+ powerLevel: 0.5f);
+
+ Assert.Equal(CombatManeuverSelection.None, selection);
+ }
+
+ [Fact]
+ public void FindMotions_PreservesRetailTableOrder()
+ {
+ var table = MakeTable(
+ Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
+ DatAttackType.Kick, DatMotionCommand.AttackLow1),
+ Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
+ DatAttackType.Kick, (DatMotionCommand)0x1000018Eu),
+ Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
+ DatAttackType.Punch, DatMotionCommand.AttackLow2));
+
+ var motions = CombatManeuverSelector.FindMotions(
+ table,
+ DatMotionStance.HandCombat,
+ DatAttackHeight.Low,
+ DatAttackType.Kick);
+
+ Assert.Equal(new[]
+ {
+ DatMotionCommand.AttackLow1,
+ (DatMotionCommand)0x1000018Eu,
+ }, motions);
+ }
+
+ private static CombatTable MakeTable(params CombatManeuver[] maneuvers)
+ {
+ var table = new CombatTable();
+ table.CombatManeuvers.AddRange(maneuvers);
+ return table;
+ }
+
+ private static CombatManeuver Entry(
+ DatMotionStance stance,
+ DatAttackHeight height,
+ DatAttackType type,
+ DatMotionCommand motion)
+ {
+ return new CombatManeuver
+ {
+ Style = stance,
+ AttackHeight = height,
+ AttackType = type,
+ MinSkillLevel = 0,
+ Motion = motion,
+ };
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs
index d9baae8..0478c69 100644
--- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs
+++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs
@@ -27,6 +27,51 @@ public sealed class CombatStateTests
Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
}
+ [Fact]
+ public void CombatMode_UsesRetailAceBitValues()
+ {
+ Assert.Equal(1, (int)CombatMode.NonCombat);
+ Assert.Equal(2, (int)CombatMode.Melee);
+ Assert.Equal(4, (int)CombatMode.Missile);
+ Assert.Equal(8, (int)CombatMode.Magic);
+ }
+
+ [Fact]
+ public void AttackType_UsesNamedRetailBitValues()
+ {
+ Assert.Equal(0x0001u, (uint)AttackType.Punch);
+ Assert.Equal(0x0002u, (uint)AttackType.Thrust);
+ Assert.Equal(0x0004u, (uint)AttackType.Slash);
+ Assert.Equal(0x0008u, (uint)AttackType.Kick);
+ Assert.Equal(0x0010u, (uint)AttackType.OffhandPunch);
+ Assert.Equal(0x79E0u, (uint)AttackType.MultiStrike);
+ }
+
+ [Fact]
+ public void SetCombatMode_TracksCurrentMode_AndFiresEvent()
+ {
+ var state = new CombatState();
+ CombatMode? seen = null;
+ state.CombatModeChanged += mode => seen = mode;
+
+ state.SetCombatMode(CombatMode.Missile);
+
+ Assert.Equal(CombatMode.Missile, state.CurrentMode);
+ Assert.Equal(CombatMode.Missile, seen);
+ }
+
+ [Fact]
+ public void OnCombatCommenceAttack_FiresAttackCommenced()
+ {
+ var state = new CombatState();
+ bool seen = false;
+ state.AttackCommenced += () => seen = true;
+
+ state.OnCombatCommenceAttack();
+
+ Assert.True(seen);
+ }
+
[Fact]
public void OnVictimNotification_FiresDamageTaken()
{
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/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
index ac492dd..b5f584a 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
@@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests
}
}
+ [Fact]
+ public void HasCycle_PresentInTable_ReturnsTrue()
+ {
+ // Phase L.1c followup (2026-04-28): regression guard for
+ // "torso on the ground" — caller (GameWindow MoveTo path) needs
+ // to query the table before SetCycle to avoid the
+ // ClearCyclicTail wipe on a missing cycle.
+ const uint Style = 0x003Cu; // HandCombat
+ const uint Motion = 0x0003u; // Ready
+ const uint AnimId = 0x03000001u;
+
+ var setup = Fixtures.MakeSetup(2);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
+ var seq = new AnimationSequencer(setup, mt, loader);
+
+ // Caller passes the SAME shape SetCycle expects: full style with
+ // class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
+ Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
+ }
+
+ [Fact]
+ public void HasCycle_MissingFromTable_ReturnsFalse()
+ {
+ const uint Style = 0x003Cu;
+ const uint ReadyMotion = 0x0003u;
+ const uint AnimId = 0x03000001u;
+
+ var setup = Fixtures.MakeSetup(2);
+ var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
+ var seq = new AnimationSequencer(setup, mt, loader);
+
+ // RunForward (0x44000007) is NOT in the table — caller should
+ // see false and fall back to a known motion (WalkForward / Ready).
+ Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
+ }
+
[Fact]
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
{
@@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests
Assert.Equal(99f, fr[0].Origin.X, 1);
}
+ [Fact]
+ public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
+ {
+ // ACE broadcasts creature attacks as Action-class ForwardCommand
+ // values followed by Ready. Retail keeps currState.Substate at Ready
+ // while the action link drains, so the Ready echo must not abort the
+ // in-flight swing.
+ const uint Style = 0x003Du;
+ const uint IdleMotion = 0x41000003u;
+ const uint AttackMotion = 0x10000052u;
+ const uint IdleAnimId = 0x03000503u;
+ const uint AttackAnimId = 0x03000504u;
+
+ var setup = Fixtures.MakeSetup(1);
+ var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
+ int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
+ mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
+
+ int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
+ var cmdData = new MotionCommandData();
+ cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
+ mt.Links[linkOuter] = cmdData;
+
+ var loader = new FakeLoader();
+ loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
+ loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, IdleMotion);
+ seq.PlayAction(AttackMotion);
+
+ seq.SetCycle(Style, IdleMotion);
+
+ var fr = seq.Advance(0.01f);
+ Assert.Single(fr);
+ Assert.Equal(12f, fr[0].Origin.X, 1);
+ Assert.Equal(IdleMotion, seq.CurrentMotion);
+ }
+
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{
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()
{
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
new file mode 100644
index 0000000..39182cb
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
@@ -0,0 +1,296 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Phase L.1c (2026-04-28). Covers — the
+/// per-tick steering port of retail
+/// MoveToManager::HandleMoveToPosition for server-controlled remote
+/// creatures.
+///
+public class RemoteMoveToDriverTests
+{
+ private const float Epsilon = 1e-3f;
+
+ private static float Yaw(Quaternion q)
+ {
+ var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
+ return MathF.Atan2(-fwd.X, fwd.Y);
+ }
+
+ [Fact]
+ public void Drive_AlreadyAtTarget_ReportsArrived()
+ {
+ var bodyPos = new Vector3(10f, 20f, 0f);
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(10f, 20.3f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0.5f, distanceToObject: 0.6f,
+ dt: 0.016f, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
+ Assert.Equal(bodyRot, newOrient); // orientation untouched
+ }
+
+ [Fact]
+ public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
+ {
+ // ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
+ // Body at 0.5m from target should ARRIVE — not keep oscillating
+ // around the target the way it did pre-fix when only MinDistance
+ // was the gate. This is the "monster keeps running in different
+ // directions when it should be attacking" regression fix.
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(0f, 0.5f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0f, distanceToObject: 0.6f,
+ dt: 0.016f, moveTowards: true,
+ out _);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
+ }
+
+ [Fact]
+ public void Drive_FleeArrival_UsesMinDistance()
+ {
+ // Flee branch (moveTowards=false): arrival when dist >= MinDistance.
+ // Retail / ACE both use MinDistance for the flee-arrival threshold.
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(0f, 6f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 5.0f, distanceToObject: 0.6f,
+ dt: 0.016f, moveTowards: false,
+ out _);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
+ }
+
+ [Fact]
+ public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
+ {
+ // Regression: my earlier max(MinDistance, DistanceToObject) port
+ // would have arrived here because dist (1.5) <= MinDistance (2.0).
+ // Retail uses DistanceToObject for chase arrival, so a chase at
+ // dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(0f, 1.5f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 2.0f, distanceToObject: 0.6f,
+ dt: 0.016f, moveTowards: true,
+ out _);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
+ }
+
+ [Fact]
+ public void Drive_ChasingButNotInRange_ReportsSteering()
+ {
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var bodyRot = Quaternion.Identity; // facing +Y
+ var dest = new Vector3(0f, 50f, 0f); // straight ahead
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: 0.016f, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
+ // Already facing target → snap branch keeps yaw at 0.
+ Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
+ }
+
+ [Fact]
+ public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
+ {
+ // Body facing +Y; target at (1, 10, 0) — that's a small angle
+ // (about 5.7°), well within the 20° snap tolerance.
+ var bodyPos = Vector3.Zero;
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(1f, 10f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: 0.016f, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
+ // Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
+ float expectedYaw = MathF.Atan2(-1f, 10f);
+ Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
+
+ // Verify orientation actually transforms +Y onto the (1,10) line.
+ var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
+ Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
+ }
+
+ [Fact]
+ public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
+ {
+ // Body facing +Y; target at (-10, 0) — that's 90° to the left
+ // (well beyond the 20° snap tolerance), so we turn by at most
+ // TurnRateRadPerSec * dt this tick rather than snapping.
+ var bodyPos = Vector3.Zero;
+ var bodyRot = Quaternion.Identity; // yaw = 0
+ var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
+ const float dt = 0.1f;
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: dt, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
+ float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
+ // We should turn LEFT (positive yaw) toward the target.
+ Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
+ }
+
+ [Fact]
+ public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
+ {
+ // Body facing +Y; target directly behind at (0, -10, 0).
+ // |delta| = π, equally close either way; the implementation
+ // picks one (sign depends on float wobble) — just assert
+ // we made progress (yaw changed by exactly TurnRate * dt).
+ var bodyPos = Vector3.Zero;
+ var bodyRot = Quaternion.Identity;
+ var dest = new Vector3(0f, -10f, 0f);
+ const float dt = 0.1f;
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0f, distanceToObject: 0f,
+ dt: dt, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
+ float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
+ Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
+ }
+
+ [Fact]
+ public void Drive_PreservesOrientationAtArrival()
+ {
+ var bodyPos = new Vector3(5f, 5f, 0f);
+ var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
+ var dest = new Vector3(5.01f, 5.01f, 0f);
+
+ var result = RemoteMoveToDriver.Drive(
+ bodyPos, bodyRot, dest,
+ minDistance: 0.5f, distanceToObject: 0.6f,
+ dt: 0.016f, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
+ // Caller would zero velocity; orientation should be untouched
+ // so the body settles facing whatever direction it was already.
+ Assert.Equal(bodyRot, newOrient);
+ }
+
+ [Fact]
+ public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
+ {
+ // Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
+ // Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
+ // on the threshold. With threshold=0.6 and remaining=0.4, the
+ // clamp should let the full velocity through (advance == remaining).
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var dest = new Vector3(0f, 1f, 0f);
+ var vel = new Vector3(0f, 4f, 0f);
+
+ var clamped = RemoteMoveToDriver.ClampApproachVelocity(
+ bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
+
+ // Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
+ // remaining distance. The clamp may apply a 0.99999×-style
+ // tiny scale due to FP rounding — accept anything ≥ 99.9% of
+ // the input as "no meaningful overshoot prevention applied."
+ Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
+ Assert.Equal(0f, clamped.X);
+ Assert.Equal(0f, clamped.Z);
+ }
+
+ [Fact]
+ public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
+ {
+ // Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
+ // Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
+ // remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
+ // Velocity should be halved → 2 m/s.
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var dest = new Vector3(0f, 1f, 0f);
+ var vel = new Vector3(0f, 4f, 0f);
+
+ var clamped = RemoteMoveToDriver.ClampApproachVelocity(
+ bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
+
+ Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
+ Assert.Equal(0f, clamped.X);
+ }
+
+ [Fact]
+ public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
+ {
+ // Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
+ // Any horizontal velocity would overshoot; clamp must zero it.
+ var bodyPos = new Vector3(0f, 0f, 0f);
+ var dest = new Vector3(0f, 0.6f, 0f);
+ var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
+
+ var clamped = RemoteMoveToDriver.ClampApproachVelocity(
+ bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
+
+ Assert.Equal(0f, clamped.X);
+ Assert.Equal(0f, clamped.Y);
+ Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
+ }
+
+ [Fact]
+ public void ClampApproachVelocity_FleeBranch_NoOp()
+ {
+ // moveTowards=false (flee): no overshoot risk, return velocity unchanged.
+ var bodyPos = Vector3.Zero;
+ var dest = new Vector3(0f, 1f, 0f);
+ var vel = new Vector3(0f, -4f, 0f);
+
+ var clamped = RemoteMoveToDriver.ClampApproachVelocity(
+ bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
+
+ Assert.Equal(vel, clamped);
+ }
+
+ [Fact]
+ public void OriginToWorld_AppliesLandblockGridShift()
+ {
+ // Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
+ // at (0xA9, 0xB4), that's one landblock west and zero north,
+ // so origin (10, 20, 0) inside that landblock should map to
+ // (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
+ var w = RemoteMoveToDriver.OriginToWorld(
+ originCellId: 0xA8B4000Eu,
+ originX: 10f, originY: 20f, originZ: 0f,
+ liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
+
+ Assert.Equal(-182f, w.X);
+ Assert.Equal(20f, w.Y);
+ Assert.Equal(0f, w.Z);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs
new file mode 100644
index 0000000..65cc50d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs
@@ -0,0 +1,88 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class ServerControlledLocomotionTests
+{
+ [Fact]
+ public void PlanMoveToStart_SeedsImmediateRunCycle()
+ {
+ var plan = ServerControlledLocomotion.PlanMoveToStart();
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.RunForward, plan.Motion);
+ Assert.Equal(1.0f, plan.SpeedMod);
+ }
+
+ [Fact]
+ public void PlanMoveToStart_AppliesRetailRunRate()
+ {
+ var plan = ServerControlledLocomotion.PlanMoveToStart(
+ moveToSpeed: 1.25f,
+ runRate: 1.5f,
+ canRun: true);
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.RunForward, plan.Motion);
+ Assert.Equal(1.875f, plan.SpeedMod);
+ }
+
+ [Fact]
+ public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
+ {
+ var plan = ServerControlledLocomotion.PlanMoveToStart(
+ moveToSpeed: 0.75f,
+ runRate: 2.0f,
+ canRun: false);
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.WalkForward, plan.Motion);
+ Assert.Equal(0.75f, plan.SpeedMod);
+ }
+
+ [Fact]
+ public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
+ {
+ var plan = ServerControlledLocomotion.PlanFromVelocity(
+ new Vector3(0.10f, 0.12f, 3.0f));
+
+ Assert.False(plan.IsMoving);
+ Assert.Equal(MotionCommand.Ready, plan.Motion);
+ Assert.Equal(1.0f, plan.SpeedMod);
+ }
+
+ [Fact]
+ public void PlanFromVelocity_WalksForSlowServerControlledMotion()
+ {
+ var plan = ServerControlledLocomotion.PlanFromVelocity(
+ new Vector3(0.0f, 0.80f, 0.0f));
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.WalkForward, plan.Motion);
+ Assert.InRange(plan.SpeedMod, 0.25f, 0.27f);
+ }
+
+ [Fact]
+ public void PlanFromVelocity_RunsAtRetailRunScale()
+ {
+ var plan = ServerControlledLocomotion.PlanFromVelocity(
+ new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f));
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.RunForward, plan.Motion);
+ Assert.Equal(1.0f, plan.SpeedMod, precision: 4);
+ }
+
+ [Fact]
+ public void PlanFromVelocity_ClampsVeryFastSnapshots()
+ {
+ var plan = ServerControlledLocomotion.PlanFromVelocity(
+ new Vector3(0.0f, 30.0f, 0.0f));
+
+ Assert.True(plan.IsMoving);
+ Assert.Equal(MotionCommand.RunForward, plan.Motion);
+ Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs
new file mode 100644
index 0000000..48597b0
--- /dev/null
+++ b/tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs
@@ -0,0 +1,9 @@
+using Xunit;
+
+namespace AcDream.Core.Tests.World;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class DerethDateTimeCollection
+{
+ public const string Name = "DerethDateTime global offset";
+}
diff --git a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
index 86fb5a9..c665917 100644
--- a/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
+++ b/tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs
@@ -3,6 +3,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
+[Collection(DerethDateTimeCollection.Name)]
public sealed class DerethDateTimeTests
{
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half
diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
index bd3bc73..1c67720 100644
--- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs
+++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
@@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
+[Collection(DerethDateTimeCollection.Name)]
public sealed class SkyStateTests
{
[Fact]
diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
index 4fde925..f05f64a 100644
--- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
+++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
@@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
+[Collection(DerethDateTimeCollection.Name)]
public sealed class WorldTimeDebugTests
{
[Fact]