From 1c69670392d5e2f3b1f257dd7716559208515b20 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:38:58 +0200 Subject: [PATCH 01/21] docs(anim): Phase L.1a animation system audit --- docs/plans/2026-04-11-roadmap.md | 43 +++ docs/plans/animation-system-audit.md | 557 +++++++++++++++++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 docs/plans/animation-system-audit.md diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9435f82..ecff132 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -305,6 +305,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. From 460f95cb42e7398082e656075f95598970d6ef72 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:46:22 +0200 Subject: [PATCH 02/21] fix(anim): Phase L.1b route motion commands --- src/AcDream.App/Rendering/GameWindow.cs | 57 ++--------- .../Physics/AnimationCommandRouter.cs | 97 +++++++++++++++++++ src/AcDream.Core/Physics/MotionInterpreter.cs | 20 ++-- .../Physics/AnimationCommandRouterTests.cs | 66 +++++++++++++ .../Physics/MotionCommandResolverTests.cs | 4 + .../Physics/MotionInterpreterTests.cs | 27 ++++++ 6 files changed, 218 insertions(+), 53 deletions(-) create mode 100644 src/AcDream.Core/Physics/AnimationCommandRouter.cs create mode 100644 tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 874aa94..d252c9b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2435,57 +2435,20 @@ public sealed class GameWindow : IDisposable dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } - // Route the Commands list — one-shot Actions, Modifiers, and - // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These - // live in the motion table's Links / Modifiers dicts, not - // Cycles, and are played on top of the current cycle via - // PlayAction which resolves the right dict and interleaves the - // action frames before the cyclic tail. - // - // A typical NPC wave looks like: - // ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}] - // [{0x0003=Ready, ...}] - // Each item runs through PlayAction (for 0x10/0x20 mask) or the - // standard SetCycle path (for 0x40 SubState). We leave SubState - // commands to fall through to the next UpdateMotion; that's how - // retail handles transition sequences (Wave → Ready). + // Route command-list entries through the shared Core router. + // Retail/ACE send these as 16-bit MotionCommand lows in + // InterpretedMotionState.Commands[]; the router reconstructs the + // class byte and chooses PlayAction for actions/modifiers/emotes + // or SetCycle for persistent substates. if (update.MotionState.Commands is { Count: > 0 } cmds) { foreach (var item in cmds) { - // Restore the 32-bit MotionCommand from the wire's 16-bit - // truncation by OR-ing class bits. The class is encoded - // in the low byte's high nibble via command ranges: - // 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx) - // 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx) - // 0x0051-0x00A1 — Action class (0x10xx xxxx) - // - // The retail MotionCommand enum carries the class byte in - // bits 24-31. DatReaderWriter's enum values match. For - // broadcasts, servers emit only low 16 bits (ACE - // InterpretedMotionState.cs:139). We reconstruct via a - // range-based lookup. See MotionCommand.generated.cs. - uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command); - if (fullCmd == 0) continue; - - // Action class: play through the link dict then drop back - // to the current cycle. Modifier class: resolve from the - // Modifiers dict and combine on top. SubState: cycle - // change; route through SetCycle so the style-specific - // cycle fallback applies. - uint cls = fullCmd & 0xFF000000u; - if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0 - || cls == 0x12000000u || cls == 0x13000000u) - { - ae.Sequencer.PlayAction(fullCmd, item.Speed); - } - else if ((cls & 0x40000000u) != 0) - { - // Substate in the command list — typically the "and - // then return to Ready" item. Update the cycle. - ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed); - } - // else: Style / UI / Toggle class — not animation-driving. + AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( + ae.Sequencer, + fullStyle, + item.Command, + item.Speed); } } return; diff --git a/src/AcDream.Core/Physics/AnimationCommandRouter.cs b/src/AcDream.Core/Physics/AnimationCommandRouter.cs new file mode 100644 index 0000000..1101c03 --- /dev/null +++ b/src/AcDream.Core/Physics/AnimationCommandRouter.cs @@ -0,0 +1,97 @@ +namespace AcDream.Core.Physics; + +/// +/// Central routing for full retail MotionCommand values after the wire's +/// 16-bit command id has been reconstructed. +/// +/// Retail/ACE split motion commands by class mask: +/// - Action and ChatEmote commands play through link/action data. +/// - Modifier commands play through modifier data. +/// - SubState commands become the new cyclic state. +/// - Style/UI/Toggle commands do not directly drive an animation overlay here. +/// +/// References: +/// CMotionTable::GetObjectSequence 0x00522860, +/// CMotionInterp::DoInterpretedMotion 0x00528360, +/// ACE MotionTable.GetObjectSequence, and +/// docs/research/deepdives/r03-motion-animation.md section 3. +/// +public static class AnimationCommandRouter +{ + private const uint ActionMask = 0x10000000u; + private const uint ModifierMask = 0x20000000u; + private const uint SubStateMask = 0x40000000u; + private const uint ClassMask = 0xFF000000u; + + /// + /// Classifies a reconstructed full MotionCommand. + /// + public static AnimationCommandRouteKind Classify(uint fullCommand) + { + if (fullCommand == 0) + return AnimationCommandRouteKind.None; + + uint cls = fullCommand & ClassMask; + if (cls == 0x12000000u || cls == 0x13000000u) + return AnimationCommandRouteKind.ChatEmote; + + if ((fullCommand & ModifierMask) != 0) + return AnimationCommandRouteKind.Modifier; + + if ((fullCommand & ActionMask) != 0) + return AnimationCommandRouteKind.Action; + + if ((fullCommand & SubStateMask) != 0) + return AnimationCommandRouteKind.SubState; + + return AnimationCommandRouteKind.Ignored; + } + + /// + /// Reconstructs and routes a 16-bit wire command. + /// + public static AnimationCommandRouteKind RouteWireCommand( + AnimationSequencer sequencer, + uint currentStyle, + ushort wireCommand, + float speedMod = 1f) + { + uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand); + return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod); + } + + /// + /// Routes a full MotionCommand to the matching sequencer API. + /// + public static AnimationCommandRouteKind RouteFullCommand( + AnimationSequencer sequencer, + uint currentStyle, + uint fullCommand, + float speedMod = 1f) + { + var route = Classify(fullCommand); + switch (route) + { + case AnimationCommandRouteKind.Action: + case AnimationCommandRouteKind.Modifier: + case AnimationCommandRouteKind.ChatEmote: + sequencer.PlayAction(fullCommand, speedMod); + break; + case AnimationCommandRouteKind.SubState: + sequencer.SetCycle(currentStyle, fullCommand, speedMod); + break; + } + + return route; + } +} + +public enum AnimationCommandRouteKind +{ + None = 0, + Action, + Modifier, + ChatEmote, + SubState, + Ignored, +} diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 81d8201..038f675 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -72,12 +72,20 @@ public static class MotionCommand /// regular SetCycle transition. /// public const uint FallDown = 0x10000050u; - /// 0x10000057 — Dead. - public const uint Dead = 0x10000057u; + /// 0x40000011 - persistent dead substate. + public const uint Dead = 0x40000011u; + /// 0x10000057 - Sanctuary death-trigger action. + public const uint Sanctuary = 0x10000057u; + /// 0x41000012 - crouching substate. + public const uint Crouch = 0x41000012u; + /// 0x41000013 - sitting substate. + public const uint Sitting = 0x41000013u; + /// 0x41000014 - sleeping substate. + public const uint Sleeping = 0x41000014u; /// 0x41000011 — Crouch lower bound for blocked-jump check. public const uint CrouchLowerBound = 0x41000011u; - /// 0x41000014 — upper bound of crouch/sit/sleep range. - public const uint CrouchUpperBound = 0x41000014u; + /// 0x41000015 - exclusive upper bound of crouch/sit/sleep range. + public const uint CrouchUpperExclusive = 0x41000015u; } /// @@ -819,7 +827,7 @@ public sealed class MotionInterpreter /// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false: /// return 0x49 /// uVar1 = InterpretedState.ForwardCommand - /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead): + /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead): /// return 0x48 /// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range): /// return 0x48 @@ -850,7 +858,7 @@ public sealed class MotionInterpreter return false; // Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015). - if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound) + if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive) return false; // Need Gravity flag + Contact + OnWalkable for ground-based motion. diff --git a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs new file mode 100644 index 0000000..83ca7d0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs @@ -0,0 +1,66 @@ +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class AnimationCommandRouterTests +{ + private const uint NonCombat = 0x8000003Du; + + [Theory] + [InlineData(0x00000000u, AnimationCommandRouteKind.None)] + [InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary + [InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump + [InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave + [InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready + [InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead + [InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style + public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected) + { + Assert.Equal(expected, AnimationCommandRouter.Classify(command)); + } + + [Fact] + public void RouteWireCommand_SubState_UsesSetCycle() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011); + + Assert.Equal(AnimationCommandRouteKind.SubState, route); + Assert.Equal(NonCombat, seq.CurrentStyle); + Assert.Equal(MotionCommand.Dead, seq.CurrentMotion); + } + + [Fact] + public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057); + + Assert.Equal(AnimationCommandRouteKind.Action, route); + Assert.Equal(0u, seq.CurrentMotion); + } + + [Fact] + public void RouteWireCommand_Wave_IsChatEmote() + { + var seq = MakeEmptySequencer(); + + var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087); + + Assert.Equal(AnimationCommandRouteKind.ChatEmote, route); + } + + private static AnimationSequencer MakeEmptySequencer() + { + return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs index a233b02..c436f7e 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs @@ -21,6 +21,10 @@ public class MotionCommandResolverTests [InlineData(0x000E, 0x6500000Eu)] // TurnLeft [InlineData(0x000F, 0x6500000Fu)] // SideStepRight [InlineData(0x0015, 0x40000015u)] // Falling + [InlineData(0x0011, 0x40000011u)] // Dead + [InlineData(0x0012, 0x41000012u)] // Crouch + [InlineData(0x0013, 0x41000013u)] // Sitting + [InlineData(0x0014, 0x41000014u)] // Sleeping // Action-class one-shots: melee attacks, death, portals [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) [InlineData(0x0058, 0x10000058u)] // ThrustMed diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index e41b679..1892611 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests Assert.False(allowed); } + [Fact] + public void ContactAllowsMove_DeadState_RejectsMove() + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = MotionCommand.Dead; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } + + [Theory] + [InlineData(MotionCommand.Crouch)] + [InlineData(MotionCommand.Sitting)] + [InlineData(MotionCommand.Sleeping)] + public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand) + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = postureCommand; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } + [Fact] public void ContactAllowsMove_CrouchRange_RejectsMove() { From 29afc94b94bf0a0d0182900a543d0592e538d438 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:54:50 +0200 Subject: [PATCH 03/21] fix(net): Phase L.1c conform combat wire events --- src/AcDream.Core.Net/GameEventWiring.cs | 17 +- .../Messages/AttackTargetRequest.cs | 89 ++++++---- .../Messages/CharacterActions.cs | 10 +- src/AcDream.Core.Net/Messages/GameEvents.cs | 91 ++++------ src/AcDream.Core/Combat/CombatState.cs | 6 + .../GameEventWiringTests.cs | 38 +++-- .../Messages/CharacterActionsTests.cs | 9 + .../Messages/CombatEventTests.cs | 158 +++++++++++------- 8 files changed, 241 insertions(+), 177 deletions(-) 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/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/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 93a5094..7f6772a 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -57,6 +57,9 @@ 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; + /// /// Fires when the server confirms the player landed a killing blow /// (GameEvent KillerNotification (0x01AD)). Event payload is @@ -140,5 +143,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/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; } } From 25b96167037c010ec6543b7c8bc402125fe0949d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:57:12 +0200 Subject: [PATCH 04/21] feat(combat): Phase L.1c add outbound combat actions --- src/AcDream.Core.Net/WorldSession.cs | 43 +++++++++++ src/AcDream.Core/Combat/CombatModel.cs | 13 ++-- src/AcDream.Core/Combat/CombatState.cs | 14 ++++ .../WorldSessionCombatTests.cs | 77 +++++++++++++++++++ .../Combat/CombatStateTests.cs | 34 ++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3389fb7..5c2bf20 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; @@ -909,6 +910,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/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index a70d6d7..693fe52 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 diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 7f6772a..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; @@ -60,6 +62,9 @@ public sealed class CombatState /// 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 @@ -97,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) 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/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index d9baae8..d55f4ee 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -27,6 +27,40 @@ 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 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() { From 268af82e28a717103b80ae03392f3eead7f1c1fa Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:59:29 +0200 Subject: [PATCH 05/21] fix(combat): Phase L.1c align attack type flags --- src/AcDream.Core/Combat/CombatModel.cs | 34 +++++++++++-------- .../Combat/CombatStateTests.cs | 11 ++++++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index 693fe52..246ddab 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -34,20 +34,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/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index d55f4ee..0478c69 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -36,6 +36,17 @@ public sealed class CombatStateTests 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() { From 831392a7b2c7b5e9868270de262d647c813dca82 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:37:49 +0200 Subject: [PATCH 06/21] feat(anim): Phase L.1c classify combat animation commands --- .../2026-04-28-combat-animation-planner.md | 68 +++++ .../Combat/CombatAnimationPlanner.cs | 278 ++++++++++++++++++ .../Combat/CombatAnimationPlannerTests.cs | 74 +++++ 3 files changed, 420 insertions(+) create mode 100644 docs/research/2026-04-28-combat-animation-planner.md create mode 100644 src/AcDream.Core/Combat/CombatAnimationPlanner.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs 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..cd08388 --- /dev/null +++ b/docs/research/2026-04-28-combat-animation-planner.md @@ -0,0 +1,68 @@ +# 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 +``` + +## 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/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs new file mode 100644 index 0000000..80b4ea5 --- /dev/null +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -0,0 +1,278 @@ +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.BowCombat + or CombatAnimationMotionCommands.CrossbowCombat + or CombatAnimationMotionCommands.SlingCombat + 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 => 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 TwoHandedSwordCombat = 0x80000046u; + public const uint SlingCombat = 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 = 0x10000173u; + public const uint OffhandSlashMed = 0x10000174u; + public const uint OffhandSlashLow = 0x10000175u; + public const uint OffhandThrustHigh = 0x10000176u; + public const uint OffhandThrustMed = 0x10000177u; + public const uint OffhandThrustLow = 0x10000178u; + public const uint OffhandDoubleSlashLow = 0x10000179u; + public const uint OffhandDoubleSlashMed = 0x1000017Au; + public const uint OffhandDoubleSlashHigh = 0x1000017Bu; + public const uint OffhandTripleSlashLow = 0x1000017Cu; + public const uint OffhandTripleSlashMed = 0x1000017Du; + public const uint OffhandTripleSlashHigh = 0x1000017Eu; + public const uint OffhandDoubleThrustLow = 0x1000017Fu; + public const uint OffhandDoubleThrustMed = 0x10000180u; + public const uint OffhandDoubleThrustHigh = 0x10000181u; + public const uint OffhandTripleThrustLow = 0x10000182u; + public const uint OffhandTripleThrustMed = 0x10000183u; + public const uint OffhandTripleThrustHigh = 0x10000184u; + public const uint OffhandKick = 0x10000185u; + public const uint AttackHigh4 = 0x10000186u; + public const uint AttackMed4 = 0x10000187u; + public const uint AttackLow4 = 0x10000188u; + public const uint AttackHigh5 = 0x10000189u; + public const uint AttackMed5 = 0x1000018Au; + public const uint AttackLow5 = 0x1000018Bu; + public const uint AttackHigh6 = 0x1000018Cu; + public const uint AttackMed6 = 0x1000018Du; + public const uint AttackLow6 = 0x1000018Eu; +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs new file mode 100644 index 0000000..6a40308 --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -0,0 +1,74 @@ +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(0x10000180u, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot + [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload + [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 + [InlineData(0x1000018Eu, 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 + public void ClassifyMotionCommand_RecognisesRetailCombatCommands( + uint command, + CombatAnimationKind expected) + { + Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); + } + + [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)); + } +} From 646246ba84f1dee7ebd4a195bca25e9f49ed7676 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:44:17 +0200 Subject: [PATCH 07/21] feat(anim): Phase L.1c select combat maneuvers --- .../2026-04-28-combat-animation-planner.md | 45 +++++ .../Combat/CombatAnimationPlanner.cs | 92 +++++++---- .../Combat/CombatManeuverSelector.cs | 89 ++++++++++ .../Physics/MotionCommandResolver.cs | 18 ++ .../Combat/CombatAnimationPlannerTests.cs | 19 ++- .../Combat/CombatManeuverSelectorTests.cs | 155 ++++++++++++++++++ 6 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 src/AcDream.Core/Combat/CombatManeuverSelector.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs diff --git a/docs/research/2026-04-28-combat-animation-planner.md b/docs/research/2026-04-28-combat-animation-planner.md index cd08388..f557659 100644 --- a/docs/research/2026-04-28-combat-animation-planner.md +++ b/docs/research/2026-04-28-combat-animation-planner.md @@ -60,6 +60,51 @@ ClassifyMotionCommand(fullCommand): 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` diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs index 80b4ea5..bf67a85 100644 --- a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -53,9 +53,12 @@ public static class CombatAnimationPlanner 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, @@ -99,7 +102,19 @@ public static class CombatAnimationPlanner or CombatAnimationMotionCommands.OffhandTripleThrustLow or CombatAnimationMotionCommands.OffhandTripleThrustMed or CombatAnimationMotionCommands.OffhandTripleThrustHigh - or CombatAnimationMotionCommands.OffhandKick => CombatAnimationKind.MeleeSwing, + 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 @@ -192,8 +207,11 @@ internal static class CombatAnimationMotionCommands public const uint BowCombat = 0x8000003Fu; public const uint SwordShieldCombat = 0x80000040u; public const uint CrossbowCombat = 0x80000041u; - public const uint TwoHandedSwordCombat = 0x80000046u; - public const uint SlingCombat = 0x80000047u; + 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; @@ -247,32 +265,44 @@ internal static class CombatAnimationMotionCommands public const uint TripleThrustMed = 0x10000129u; public const uint TripleThrustHigh = 0x1000012Au; - public const uint OffhandSlashHigh = 0x10000173u; - public const uint OffhandSlashMed = 0x10000174u; - public const uint OffhandSlashLow = 0x10000175u; - public const uint OffhandThrustHigh = 0x10000176u; - public const uint OffhandThrustMed = 0x10000177u; - public const uint OffhandThrustLow = 0x10000178u; - public const uint OffhandDoubleSlashLow = 0x10000179u; - public const uint OffhandDoubleSlashMed = 0x1000017Au; - public const uint OffhandDoubleSlashHigh = 0x1000017Bu; - public const uint OffhandTripleSlashLow = 0x1000017Cu; - public const uint OffhandTripleSlashMed = 0x1000017Du; - public const uint OffhandTripleSlashHigh = 0x1000017Eu; - public const uint OffhandDoubleThrustLow = 0x1000017Fu; - public const uint OffhandDoubleThrustMed = 0x10000180u; - public const uint OffhandDoubleThrustHigh = 0x10000181u; - public const uint OffhandTripleThrustLow = 0x10000182u; - public const uint OffhandTripleThrustMed = 0x10000183u; - public const uint OffhandTripleThrustHigh = 0x10000184u; - public const uint OffhandKick = 0x10000185u; - public const uint AttackHigh4 = 0x10000186u; - public const uint AttackMed4 = 0x10000187u; - public const uint AttackLow4 = 0x10000188u; - public const uint AttackHigh5 = 0x10000189u; - public const uint AttackMed5 = 0x1000018Au; - public const uint AttackLow5 = 0x1000018Bu; - public const uint AttackHigh6 = 0x1000018Cu; - public const uint AttackMed6 = 0x1000018Du; - public const uint AttackLow6 = 0x1000018Eu; + 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/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/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs index 6a40308..7a4a9a1 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -9,17 +9,20 @@ public sealed class CombatAnimationPlannerTests [Theory] [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh - [InlineData(0x10000180u, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [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(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 + [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) @@ -27,6 +30,18 @@ public sealed class CombatAnimationPlannerTests 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() { 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, + }; + } +} From d1fb68f41901f671cf680e39e3860df0d7f004b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:58:50 +0200 Subject: [PATCH 08/21] test(world): serialize DerethDateTime offset tests --- .../AcDream.Core.Tests/World/DerethDateTimeCollection.cs | 9 +++++++++ tests/AcDream.Core.Tests/World/DerethDateTimeTests.cs | 1 + tests/AcDream.Core.Tests/World/SkyStateTests.cs | 1 + tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs | 1 + 4 files changed, 12 insertions(+) create mode 100644 tests/AcDream.Core.Tests/World/DerethDateTimeCollection.cs 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 7acf0d1..60ead10 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] From 4874d8595a2d3e436ee50457c74937b1cd4958d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:58:57 +0200 Subject: [PATCH 09/21] feat(combat): Phase L.1c wire live attack input --- src/AcDream.App/Rendering/GameWindow.cs | 154 +++++++++++++++++- src/AcDream.Core.Net/Messages/CreateObject.cs | 35 ++-- src/AcDream.Core.Net/WorldSession.cs | 2 + src/AcDream.Core/Combat/CombatModel.cs | 45 +++++ .../Messages/CreateObjectTests.cs | 99 +++++++++++ .../Combat/CombatInputPlannerTests.cs | 43 +++++ 6 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d252c9b..1f03458 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -522,6 +522,11 @@ 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 int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -1662,6 +1667,9 @@ public sealed class GameWindow : IDisposable // UpdatePosition look like a 2m-residual soft-snap. _remoteDeadReckon.Remove(spawn.Guid); _remoteLastMove.Remove(spawn.Guid); + _liveEntityInfoByGuid.Remove(spawn.Guid); + if (_selectedTargetGuid == spawn.Guid) + _selectedTargetGuid = null; } // Log every spawn that arrives so we can inventory what the server @@ -1674,12 +1682,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 @@ -5915,6 +5930,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 @@ -5932,6 +5967,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/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 1541e07..3b4e90a 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, @@ -390,27 +393,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 { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5c2bf20..580b1b9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -54,6 +54,7 @@ public sealed class WorldSession : IDisposable uint? BasePaletteId, float? ObjScale, string? Name, + uint? ItemType, CreateObject.ServerMotionState? MotionState, uint? MotionTableId); @@ -635,6 +636,7 @@ public sealed class WorldSession : IDisposable parsed.Value.BasePaletteId, parsed.Value.ObjScale, parsed.Value.Name, + parsed.Value.ItemType, parsed.Value.MotionState, parsed.Value.MotionTableId)); } diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index 246ddab..a57d37d 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -27,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. 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.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)); + } +} From b96b680a2054a656301fefd5479c0163ee27b6fd Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 19:21:02 +0200 Subject: [PATCH 10/21] fix(anim): Phase L.1c route creature actions and despawns Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn. Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence. Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures. --- src/AcDream.App/Rendering/GameWindow.cs | 123 +++++++++++++++--- src/AcDream.Core.Net/Messages/DeleteObject.cs | 39 ++++++ src/AcDream.Core.Net/WorldSession.cs | 16 +++ .../Messages/DeleteObjectTests.cs | 39 ++++++ .../Physics/AnimationSequencerTests.cs | 39 ++++++ 5 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/DeleteObject.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1f03458..fe550c7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -216,6 +216,14 @@ 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; + /// /// Legacy field — no longer used for slerp (retail hard-snaps /// per FUN_00514b90 set_frame). Kept to avoid churn. /// @@ -527,6 +535,7 @@ public sealed class GameWindow : IDisposable private readonly record struct LiveEntityInfo( string? Name, AcDream.Core.Items.ItemType ItemType); + private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -1303,6 +1312,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; @@ -1654,23 +1664,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); - _liveEntityInfoByGuid.Remove(spawn.Guid); - if (_selectedTargetGuid == spawn.Guid) - _selectedTargetGuid = null; - } + 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 @@ -2150,6 +2144,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 @@ -2293,6 +2322,32 @@ 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. + 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. @@ -2340,8 +2395,6 @@ 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); @@ -2419,6 +2472,7 @@ public sealed class GameWindow : IDisposable } } } + } // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), // stamp the _remoteLastMove timestamp to "now". Without this, @@ -2634,6 +2688,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) @@ -2673,7 +2747,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; @@ -2710,6 +2784,10 @@ public sealed class GameWindow : IDisposable } } } + else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity) + { + rmState.Body.Velocity = rmState.ServerVelocity; + } entity.Position = rmState.Body.Position; entity.Rotation = rmState.Body.Orientation; @@ -4858,7 +4936,10 @@ 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) + rm.Body.Velocity = rm.ServerVelocity; + else + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } else { 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/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 580b1b9..885ec63 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -61,6 +61,16 @@ public sealed class WorldSession : IDisposable /// 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. @@ -641,6 +651,12 @@ public sealed class WorldSession : IDisposable 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 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.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index ac492dd..471af2c 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1313,6 +1313,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() { From 7656fe0970db577b7b9f53a7d5bc1c011f37804c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 19:38:52 +0200 Subject: [PATCH 11/21] fix(anim): Phase L.1c animate server-controlled chase --- src/AcDream.App/Rendering/GameWindow.cs | 94 +++++++++++++++++-- src/AcDream.Core.Net/Messages/CreateObject.cs | 15 ++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 9 +- .../Physics/ServerControlledLocomotion.cs | 63 +++++++++++++ .../Messages/UpdateMotionTests.cs | 4 +- .../ServerControlledLocomotionTests.cs | 52 ++++++++++ 6 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 src/AcDream.Core/Physics/ServerControlledLocomotion.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fe550c7..329abd8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -536,6 +536,7 @@ public sealed class GameWindow : IDisposable 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; @@ -2037,8 +2038,22 @@ 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; + 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; + } sequencer.SetCycle(seqStyle, seqMotion); } } @@ -2217,7 +2232,7 @@ public sealed class GameWindow : IDisposable 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}"); } @@ -2259,9 +2274,18 @@ public sealed class GameWindow : IDisposable // command.Value == 0 → explicit 0 (rare) → Ready // otherwise → resolve class byte and use full cmd uint fullMotion; - if (!command.HasValue || command.Value == 0) + if ((!command.HasValue || command.Value == 0) + && update.MotionState.IsServerControlledMoveTo) + { + // MoveTo packets preserve the current cycle until velocity + // chooses the visible walk/run/ready state. + uint current = ae.Sequencer.CurrentMotion; + fullMotion = (current & 0xFF000000u) != 0 + ? current + : AcDream.Core.Physics.MotionCommand.Ready; + } + else if (!command.HasValue || command.Value == 0) { - // Stop — return to the style's default substate (Ready). fullMotion = 0x41000003u; } else @@ -2619,6 +2643,34 @@ 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; + + 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 @@ -2789,6 +2841,17 @@ public sealed class GameWindow : IDisposable 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; } @@ -4937,9 +5000,28 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active; if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) - rm.Body.Velocity = rm.ServerVelocity; + { + 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 + { rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } } else { diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 3b4e90a..be6dc9b 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -139,7 +139,17 @@ public static class CreateObject ushort? SideStepCommand = null, float? SideStepSpeed = null, ushort? TurnCommand = null, - float? TurnSpeed = null); + float? TurnSpeed = null, + byte MovementType = 0) + { + /// + /// 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; + } /// /// One entry in the InterpretedMotionState's Commands list (MotionItem). @@ -648,7 +658,8 @@ public static class CreateObject return new ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 65791a7..d2062ac 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -135,7 +135,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 +158,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; } @@ -224,7 +224,8 @@ public static class UpdateMotion return new Parsed(guid, new CreateObject.ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, - sidestepCommand, sidestepSpeed, turnCommand, turnSpeed)); + sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, + movementType)); } catch { diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs new file mode 100644 index 0000000..992a597 --- /dev/null +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -0,0 +1,63 @@ +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; + + 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); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 08de618..b47168a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -194,7 +194,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 +202,7 @@ 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); } } diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs new file mode 100644 index 0000000..78f3f0e --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class ServerControlledLocomotionTests +{ + [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); + } +} From 4dd8d4b46e00e167ed526f9672ee885b0a08ab53 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 19:48:12 +0200 Subject: [PATCH 12/21] fix(anim): Phase L.1c seed move-to locomotion Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop. --- src/AcDream.App/Rendering/GameWindow.cs | 26 ++++++++++++++----- .../Physics/ServerControlledLocomotion.cs | 7 +++++ .../ServerControlledLocomotionTests.cs | 10 +++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 329abd8..0142fe6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2273,16 +2273,30 @@ 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) && update.MotionState.IsServerControlledMoveTo) { - // MoveTo packets preserve the current cycle until velocity - // chooses the visible walk/run/ready state. uint current = ae.Sequencer.CurrentMotion; - fullMotion = (current & 0xFF000000u) != 0 - ? current - : AcDream.Core.Physics.MotionCommand.Ready; + if (IsRemoteLocomotion(current)) + { + // MoveTo packets preserve an active locomotion cycle; + // position velocity will refine the speed. + fullMotion = current; + } + else + { + // Retail MoveToManager::BeginMoveForward calls + // MovementParameters::get_command (0x0052AA00), then + // _DoMotion -> adjust_motion. With default CanRun and + // enough distance, WalkForward + HoldKey_Run becomes + // RunForward immediately, before the next position echo. + var seed = AcDream.Core.Physics.ServerControlledLocomotion + .PlanMoveToStart(); + fullMotion = seed.Motion; + speedMod = seed.SpeedMod; + } } else if (!command.HasValue || command.Value == 0) { @@ -2313,8 +2327,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( diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs index 992a597..ce59998 100644 --- a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -32,6 +32,13 @@ public static class ServerControlledLocomotion 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() + { + return new LocomotionCycle(MotionCommand.RunForward, 1f, true); + } + public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity) { float horizontalSpeed = MathF.Sqrt( diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs index 78f3f0e..448f1f8 100644 --- a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -6,6 +6,16 @@ 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 PlanFromVelocity_StopsBelowRetailNoiseThreshold() { From 9812965183c1630c7e591571c82e2b2d3ea91337 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 20:58:22 +0200 Subject: [PATCH 13/21] fix(anim): Phase L.1c match MoveTo run speed Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity. --- src/AcDream.App/Rendering/GameWindow.cs | 57 +++++++++++------- src/AcDream.Core.Net/Messages/CreateObject.cs | 60 ++++++++++++++++++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 59 +++++++++++++++++- .../Physics/ServerControlledLocomotion.cs | 21 ++++++- .../Messages/UpdateMotionTests.cs | 49 ++++++++++++++- .../ServerControlledLocomotionTests.cs | 26 ++++++++ 6 files changed, 246 insertions(+), 26 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0142fe6..333b948 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -224,6 +224,13 @@ public sealed class GameWindow : IDisposable 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 using the packet's runRate; deriving velocity + /// from sparse UpdatePosition deltas under-speeds combat chases. + /// + public bool ServerMoveToActive; + /// /// Legacy field — no longer used for slerp (retail hard-snaps /// per FUN_00514b90 set_frame). Kept to avoid churn. /// @@ -2228,7 +2235,9 @@ 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( @@ -2278,25 +2287,19 @@ public sealed class GameWindow : IDisposable if ((!command.HasValue || command.Value == 0) && update.MotionState.IsServerControlledMoveTo) { - uint current = ae.Sequencer.CurrentMotion; - if (IsRemoteLocomotion(current)) - { - // MoveTo packets preserve an active locomotion cycle; - // position velocity will refine the speed. - fullMotion = current; - } - else - { - // Retail MoveToManager::BeginMoveForward calls - // MovementParameters::get_command (0x0052AA00), then - // _DoMotion -> adjust_motion. With default CanRun and - // enough distance, WalkForward + HoldKey_Run becomes - // RunForward immediately, before the next position echo. - var seed = AcDream.Core.Physics.ServerControlledLocomotion - .PlanMoveToStart(); - fullMotion = seed.Motion; - speedMod = seed.SpeedMod; - } + // 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) { @@ -2448,6 +2451,18 @@ public sealed class GameWindow : IDisposable // FUN_00529210 apply_current_movement if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { + remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; + if (remoteMot.ServerMoveToActive && !IsPlayerGuid(update.Guid)) + { + // Retail MoveTo packets already carry enough state + // for CMotionInterp to drive velocity. A velocity + // inferred from sparse UpdatePosition packets lags + // during combat chases and visibly under-speeds the + // run cycle until the next hard snap. + remoteMot.HasServerVelocity = false; + remoteMot.ServerVelocity = System.Numerics.Vector3.Zero; + } + // Forward axis (Ready / WalkForward / RunForward / WalkBackward). remoteMot.Motion.DoInterpretedMotion( fullMotion, speedMod, modifyInterpretedState: true); @@ -2756,6 +2771,7 @@ public sealed class GameWindow : IDisposable System.Numerics.Vector3? serverVelocity = update.Velocity; if (serverVelocity is null && !IsPlayerGuid(update.Guid) + && !rmState.ServerMoveToActive && rmState.LastServerPosTime > 0.0) { double elapsed = nowSec - rmState.LastServerPosTime; @@ -2836,6 +2852,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) diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index be6dc9b..847f1c2 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -140,7 +140,10 @@ public static class CreateObject float? SideStepSpeed = null, ushort? TurnCommand = null, float? TurnSpeed = null, - byte MovementType = 0) + byte MovementType = 0, + uint? MoveToParameters = null, + float? MoveToSpeed = null, + float? MoveToRunRate = null) { /// /// ACE/retail movement types 6 and 7 are server-controlled @@ -149,6 +152,9 @@ public static class CreateObject /// is not a stop signal. /// public bool IsServerControlledMoveTo => MovementType is 6 or 7; + + public bool MoveToCanRun => !MoveToParameters.HasValue + || (MoveToParameters.Value & 0x2u) != 0; } /// @@ -553,6 +559,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 @@ -655,15 +664,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, - movementType); + 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/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index d2062ac..6cc76cc 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -127,6 +127,9 @@ public static class UpdateMotion float? sidestepSpeed = null; ushort? turnCommand = null; float? turnSpeed = null; + uint? moveToParameters = null; + float? moveToSpeed = null; + float? moveToRunRate = null; List? commands = null; if (movementType == 0) @@ -221,15 +224,69 @@ public static class UpdateMotion } done:; } + else if (movementType is 6 or 7) + { + TryParseMoveToPayload( + body, + pos, + movementType, + out moveToParameters, + out moveToSpeed, + out moveToRunRate); + } return new Parsed(guid, new CreateObject.ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, - movementType)); + 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; + + // 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. + 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/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs index ce59998..af4d14d 100644 --- a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -34,9 +34,21 @@ public static class ServerControlledLocomotion // Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command // (0x0052AA00) seeds forward motion before the next position update. - public static LocomotionCycle PlanMoveToStart() + public static LocomotionCycle PlanMoveToStart( + float moveToSpeed = 1f, + float runRate = 1f, + bool canRun = true) { - return new LocomotionCycle(MotionCommand.RunForward, 1f, 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) @@ -67,4 +79,9 @@ public static class ServerControlledLocomotion 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/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index b47168a..ad0f01a 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]; @@ -205,4 +206,50 @@ public class UpdateMotionTests 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); + } } diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs index 448f1f8..65cc50d 100644 --- a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -16,6 +16,32 @@ public sealed class ServerControlledLocomotionTests 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() { From 882a07cfde08da3ce09a3fc9730b7b83b90491f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 21:12:03 +0200 Subject: [PATCH 14/21] fix(anim): Phase L.1c anchor monster MoveTo prediction Keep the retail MoveTo speed/runRate parsing from 9812965 for animation playback, but do not use the partial MoveTo state as a body-position solver. Until the full retail MoveToManager target path is ported, retain UpdatePosition-derived velocity for server-controlled creature position and prevent that velocity from clobbering the packet-derived animation cycle speed. --- src/AcDream.App/Rendering/GameWindow.cs | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 333b948..353f8b4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -226,8 +226,9 @@ public sealed class GameWindow : IDisposable /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager - /// and CMotionInterp using the packet's runRate; deriving velocity - /// from sparse UpdatePosition deltas under-speeds combat chases. + /// and CMotionInterp using the packet's runRate; until we port the + /// full target solver, use this only to protect packet-derived + /// animation speed from velocity-cycle clobbering. /// public bool ServerMoveToActive; /// @@ -2452,16 +2453,6 @@ public sealed class GameWindow : IDisposable if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; - if (remoteMot.ServerMoveToActive && !IsPlayerGuid(update.Guid)) - { - // Retail MoveTo packets already carry enough state - // for CMotionInterp to drive velocity. A velocity - // inferred from sparse UpdatePosition packets lags - // during combat chases and visibly under-speeds the - // run cycle until the next hard snap. - remoteMot.HasServerVelocity = false; - remoteMot.ServerVelocity = System.Numerics.Vector3.Zero; - } // Forward axis (Ready / WalkForward / RunForward / WalkBackward). remoteMot.Motion.DoInterpretedMotion( @@ -2685,6 +2676,11 @@ public sealed class GameWindow : IDisposable 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); @@ -2771,7 +2767,6 @@ public sealed class GameWindow : IDisposable System.Numerics.Vector3? serverVelocity = update.Velocity; if (serverVelocity is null && !IsPlayerGuid(update.Guid) - && !rmState.ServerMoveToActive && rmState.LastServerPosTime > 0.0) { double elapsed = nowSec - rmState.LastServerPosTime; @@ -5047,6 +5042,15 @@ public sealed class GameWindow : IDisposable rm.Body.Velocity = rm.ServerVelocity; } } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) + { + // We only parse enough of MoveTo to recover retail + // animation speed. Do not let apply_current_movement + // extrapolate position from an incomplete target + // solver; hold until the next UpdatePosition-derived + // velocity arrives. + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } else { rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); From 186a5844045db44f2a9aa6c66b43c5cc7e19896c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 21:49:22 +0200 Subject: [PATCH 15/21] feat(anim): Phase L.1c port MoveTo path data + per-tick steer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-causing the user-reported "monsters disappearing some time + laggy/jittery locomotion" via systematic-debugging Phase 1: our UpdateMotion parser kept only speed/runRate/flags from a movementType 6/7 packet and discarded Origin (destination), targetGuid, and the distance/walkRunThreshold/desiredHeading half of MovementParameters. The integrator consequently held Body.Velocity at zero during MoveTo ("incomplete state" stabilizer 882a07c), so the body froze with legs animating until UpdatePosition snap-teleported it — sometimes outside the visible window (disappearing) — and constant-velocity drift along the old heading between snaps produced jitter on every UP correction. The 882a07c stabilizer was deliberately conservative because the state WAS incomplete. Completing the data plumbing makes its restriction moot: with the full MoveTo payload captured, the body solver has every field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads. Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204 all show different cell/xyz floats). Those are heading updates we'd been throwing away. With the full payload retained, the per-tick driver steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s turn rate above tolerance) and lets apply_current_movement fill in Velocity from the existing RunForward cycle — no new motion path, just the right heading. Scope is the minimum viable subset: target re-tracking, sticky/StickTo, fail-distance progress detector, and sphere-cylinder distance are server-side concerns we don't need (server's emit cadence handles all of them). MoveToObject_Internal target-guid resolution is also skipped — Origin is refreshed each packet, so the effective target tracks the real entity even without a guid lookup. Cross-references: - docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command (0x00527be0). 18,366 named PDB symbols make this the primary oracle. - references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs — port aid; flagged divergences (WalkRunThreshold default, set_heading snap, inRange one-shot) called out in the new pseudocode doc. - docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode + ACE divergence flags + out-of-scope list per CLAUDE.md mandatory workflow (decompile → cross-reference → pseudocode → port). Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid retention; driver arrival, in-tolerance snap, beyond-tolerance step, behind-target shortest-path turn, arrival preserves orientation, Origin→world landblock-grid arithmetic). Pending visual sign-off — handoff stabilizer 882a07c was the last commit the user tested. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-28-remote-moveto-pseudocode.md | 285 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 126 +++++++- src/AcDream.Core.Net/Messages/CreateObject.cs | 38 ++- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 60 +++- .../Physics/RemoteMoveToDriver.cs | 204 +++++++++++++ .../Messages/UpdateMotionTests.cs | 64 ++++ .../Physics/RemoteMoveToDriverTests.cs | 159 ++++++++++ 7 files changed, 917 insertions(+), 19 deletions(-) create mode 100644 docs/research/2026-04-28-remote-moveto-pseudocode.md create mode 100644 src/AcDream.Core/Physics/RemoteMoveToDriver.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs 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 353f8b4..aae7478 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -226,11 +226,50 @@ public sealed class GameWindow : IDisposable /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager - /// and CMotionInterp using the packet's runRate; until we port the - /// full target solver, use this only to protect packet-derived - /// animation speed from velocity-cycle clobbering. + /// 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; /// /// Legacy field — no longer used for slerp (retail hard-snaps /// per FUN_00514b90 set_frame). Kept to avoid churn. @@ -2454,6 +2493,37 @@ public sealed class GameWindow : IDisposable { remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; + // Phase L.1c (2026-04-28): capture the full MoveTo path + // payload so the per-tick remote driver can steer the + // body toward Origin instead of holding velocity at zero + // between sparse UpdatePosition snaps. Retail + // MoveToManager::MoveToPosition stores the same fields + // (acclient_2013_pseudo_c.txt:307521-307593). + 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; + } + else if (!update.MotionState.IsServerControlledMoveTo) + { + // Cycle changed off MoveTo — clear stale destination + // so the per-tick driver doesn't keep steering after + // the server has switched us back to interpreted + // motion. + remoteMot.HasMoveToDestination = false; + } + // Forward axis (Ready / WalkForward / RunForward / WalkBackward). remoteMot.Motion.DoInterpretedMotion( fullMotion, speedMod, modifyInterpretedState: true); @@ -5042,13 +5112,53 @@ public sealed class GameWindow : IDisposable 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. + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( + rm.Body.Position, + rm.Body.Orientation, + rm.MoveToDestinationWorld, + rm.MoveToMinDistance, + (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); + } + } else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) { - // We only parse enough of MoveTo to recover retail - // animation speed. Do not let apply_current_movement - // extrapolate position from an incomplete target - // solver; hold until the next UpdatePosition-derived - // velocity arrives. + // 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 diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 847f1c2..39b30cd 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -143,7 +143,8 @@ public static class CreateObject byte MovementType = 0, uint? MoveToParameters = null, float? MoveToSpeed = null, - float? MoveToRunRate = null) + float? MoveToRunRate = null, + MoveToPathData? MoveToPath = null) { /// /// ACE/retail movement types 6 and 7 are server-controlled @@ -155,8 +156,43 @@ public static class CreateObject 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). /// The server packs 0..many of these per broadcast: emotes, attacks, diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 6cc76cc..8756281 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -130,6 +130,7 @@ public static class UpdateMotion uint? moveToParameters = null; float? moveToSpeed = null; float? moveToRunRate = null; + CreateObject.MoveToPathData? moveToPath = null; List? commands = null; if (movementType == 0) @@ -232,7 +233,8 @@ public static class UpdateMotion movementType, out moveToParameters, out moveToSpeed, - out moveToRunRate); + out moveToRunRate, + out moveToPath); } return new Parsed(guid, new CreateObject.ServerMotionState( @@ -241,7 +243,8 @@ public static class UpdateMotion movementType, moveToParameters, moveToSpeed, - moveToRunRate)); + moveToRunRate, + moveToPath)); } catch { @@ -255,11 +258,13 @@ public static class UpdateMotion byte movementType, out uint? movementParameters, out float? speed, - out float? runRate) + out float? runRate, + out CreateObject.MoveToPathData? path) { movementParameters = null; speed = null; runRate = null; + path = null; // Retail MovementManager::PerformMovement (0x00524440) consumes // MoveToObject/MoveToPosition as: @@ -268,25 +273,60 @@ public static class UpdateMotion // 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; - pos += 4; // target guid + targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; } if (body.Length - pos < 16 + 28 + 4) return false; - pos += 16; // Origin + + 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; - pos += 4; // distanceToObject - pos += 4; // minDistance - pos += 4; // failDistance + 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; - pos += 4; // walkRunThreshold - pos += 4; // desiredHeading + 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/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs new file mode 100644 index 0000000..0b6a675 --- /dev/null +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -0,0 +1,204 @@ +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; + + 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 (within + /// ) 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). + /// + public static DriveResult Drive( + Vector3 bodyPosition, + Quaternion bodyOrientation, + Vector3 destinationWorld, + float minDistance, + 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 + // (chase: dist ≤ min_distance; flee branch is unused here, but + // we honor the moveTowards flag for symmetry). + if (moveTowards && dist <= minDistance + 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); + } + + /// 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/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index ad0f01a..da042f0 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -251,5 +251,69 @@ public class UpdateMotionTests 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 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.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs new file mode 100644 index 0000000..7624734 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -0,0 +1,159 @@ +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, dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + Assert.Equal(bodyRot, newOrient); // orientation untouched + } + + [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, 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, 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, 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, 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, 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 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); + } +} From d247aef2e47f4cbe90b181870e5e5e84d0718b59 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 08:32:54 +0200 Subject: [PATCH 16/21] fix(anim): Phase L.1c chase arrival + stale destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed regressions on commit 186a584: 1. "Monster keeps running in different directions when it should be attacking" — chase oscillates around the player at melee range instead of stopping. Root cause: arrival check used MinDistance only (retail's algorithm), but ACE puts the melee threshold in DistanceToObject (default 0.6) and leaves MinDistance at 0. So our check was never satisfied; body kept re-targeting around the player as each MoveTo refresh moved the destination. Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon. Honors retail when retail sets MinDistance > 0; falls through to ACE's DistanceToObject when MinDistance is 0. Confirmed by independent research (named retail decomp, ACE wire writers, holtburger client) that DistanceToObject is the documented chase threshold in ACE; retail's MinDistance is only meaningful when server config overrides the default 0. 2. "Monster disappears, then runs in place" — entity left our streaming view, server stopped emitting MoveTo, last destination stayed cached. When entity re-entered view, body still steered toward the stale point, eventually arrived (V=0), animation kept playing → "running on the spot." Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at ~1 Hz during active chase; if no fresh packet for 1.5 s, the entity has either left view, transitioned off MoveTo without us seeing the cancel UM, or had its move cancelled server-side. Clear destination + zero velocity so the next interpreted-motion UM (or fresh MoveTo) drives the body cleanly. Also confirmed (via dispatched research subagent against ACE writer side, named retail MovementManager::PerformMovement, and holtburger): the wire's "Origin" field IS the destination, not the start position. My driver's interpretation was correct; the symptoms were arrival threshold + staleness, not a misread of the wire. Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival). Origin-stale lag during active chase remains — server's Origin is the target's position at packet-emit time, ~1 s behind the player. For type 6 MoveToObject, the retail-faithful fix is target-guid live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per the pseudocode doc's "out of scope" list. For type 7 there's no fix without target-velocity prediction. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 77 +++++++++++++------ .../Physics/RemoteMoveToDriver.cs | 50 ++++++++++-- .../Physics/RemoteMoveToDriverTests.cs | 59 ++++++++++++-- 3 files changed, 150 insertions(+), 36 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index aae7478..300919e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -270,6 +270,16 @@ public sealed class GameWindow : IDisposable /// 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. @@ -2514,6 +2524,8 @@ public sealed class GameWindow : IDisposable 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) { @@ -5121,35 +5133,54 @@ public sealed class GameWindow : IDisposable // server-supplied destination, then let // apply_current_movement set Velocity from the // RunForward cycle through the now-correct heading. - var driveResult = AcDream.Core.Physics.RemoteMoveToDriver - .Drive( - rm.Body.Position, - rm.Body.Orientation, - rm.MoveToDestinationWorld, - rm.MoveToMinDistance, - (float)dt, - rm.MoveToMoveTowards, - out var steeredOrientation); - rm.Body.Orientation = steeredOrientation; - if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver - .DriveResult.Arrived) + // 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) { - // 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.HasMoveToDestination = false; 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); + 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); + } } } else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 0b6a675..54152b0 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -84,6 +84,21 @@ public static class RemoteMoveToDriver /// 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. @@ -95,17 +110,33 @@ public static class RemoteMoveToDriver /// /// Steer body orientation toward - /// and report whether the body has arrived (within - /// ) or should keep running. Pure - /// function — emits the updated orientation via + /// 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) @@ -116,10 +147,15 @@ public static class RemoteMoveToDriver float dy = destinationWorld.Y - bodyPosition.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); - // Arrival predicate per retail MoveToManager::HandleMoveToPosition - // (chase: dist ≤ min_distance; flee branch is unused here, but - // we honor the moveTowards flag for symmetry). - if (moveTowards && dist <= minDistance + ArrivalEpsilon) + // Arrival predicate. Retail (named decomp): dist ≤ min_distance. + // ACE port: dist ≤ DistanceToObject. ACE's wire put the melee + // threshold in DistanceToObject (default 0.6) and left + // MinDistance=0; retail server config presumably set MinDistance. + // Defensive port: take the larger so we honor whichever field is + // populated. Flee branch is unused here but we honor the + // moveTowards flag for symmetry. + float arrivalThreshold = MathF.Max(minDistance, distanceToObject); + if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon) { newOrientation = bodyOrientation; return DriveResult.Arrived; diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index 7624734..3274702 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -30,13 +30,55 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0.5f, dt: 0.016f, moveTowards: true, + 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_RetailMinDistanceWins_WhenLargerThanDistanceToObject() + { + // Hypothetical retail packet: MinDistance=2.0 (set explicitly), + // DistanceToObject=0.6 (default). Arrival should fire at 2 m + // because retail's algorithm uses MinDistance and it's the larger + // of the two. + 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.Arrived, result); + } + [Fact] public void Drive_ChasingButNotInRange_ReportsSteering() { @@ -46,7 +88,8 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0f, dt: 0.016f, moveTowards: true, + minDistance: 0f, distanceToObject: 0f, + dt: 0.016f, moveTowards: true, out var newOrient); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); @@ -65,7 +108,8 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0f, dt: 0.016f, moveTowards: true, + minDistance: 0f, distanceToObject: 0f, + dt: 0.016f, moveTowards: true, out var newOrient); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); @@ -91,7 +135,8 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0f, dt: dt, moveTowards: true, + minDistance: 0f, distanceToObject: 0f, + dt: dt, moveTowards: true, out var newOrient); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); @@ -114,7 +159,8 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0f, dt: dt, moveTowards: true, + minDistance: 0f, distanceToObject: 0f, + dt: dt, moveTowards: true, out var newOrient); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); @@ -131,7 +177,8 @@ public class RemoteMoveToDriverTests var result = RemoteMoveToDriver.Drive( bodyPos, bodyRot, dest, - minDistance: 0.5f, dt: 0.016f, moveTowards: true, + minDistance: 0.5f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: true, out var newOrient); Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); From f794832ebc5a45d916ab669d45b48763c92b3f36 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:02:53 +0200 Subject: [PATCH 17/21] fix(anim): Phase L.1c clear MoveTo state + bulk-copy ForwardCommand on overlay UMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed regression on commit d247aef: creature reaches melee range and "just runs" instead of stopping to attack. Two independent research subagents converged on the same root cause. When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags =StickToObject, and a trailing 4-byte sticky-target guid — there is NO preceding cmd=Ready. The swing UM IS the stop signal. Retail's CMotionInterp::move_to_interpreted_state (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command from the wire into InterpretedState UNCONDITIONALLY, regardless of motion class. With forward_command=AttackHigh1, get_state_velocity (:305172-305180) returns velocity.Y=0 because its gate is RunForward||WalkForward — body stops moving forward. The animation overlay (the swing) is appended on top of whatever cyclic tail is active. Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed Action-class commands through PlayAction (animation overlay only) and SKIPPED: - ServerMoveToActive flag update — stale RunForward MoveTo state persisted, the per-tick driver kept steering toward the prior Origin and calling apply_current_movement. - InterpretedState.ForwardCommand bulk-copy — even if the flag had been cleared, the body's InterpretedState.ForwardCommand stayed at RunForward from the prior MoveTo cycle, so apply_current_movement kept producing forward velocity. - MoveToPath capture — staleness-timeout band-aid masked this. Fix: lift the _remoteDeadReckon state-update block out of the substate-only `else` branch so it runs for both overlay and substate paths. For non-MoveTo packets, write fullMotion + speedMod directly to InterpretedState.ForwardCommand/ForwardSpeed (bypassing ApplyMotionToInterpretedState, which is a heuristic helper that silently no-ops for Action class — see MotionInterpreter.cs:941-970). This matches retail's copy_movement_from (acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics. Also corrected RemoteMoveToDriver arrival predicate to retail-faithful: chase = dist <= DistanceToObject; flee = dist >= MinDistance. The prior max(MinDistance, DistanceToObject) defensive port happened to compute the right value for ACE's wire defaults but had wrong semantics (would have failed for any retail config with MinDistance > DistanceToObject). Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout; new driver tests for retail-faithful chase/flee arrival. Defers: target-guid live resolution for type 6 packets (chase-lag mitigation, symptom #3), StickToObject sticky-target guid trailing field, full MoveToManager port (CheckProgressMade, pending_actions queue, Sticky/StickTo, use_final_heading). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 126 ++++++++++++------ .../Physics/RemoteMoveToDriver.cs | 28 ++-- .../Messages/UpdateMotionTests.cs | 35 +++++ .../Physics/RemoteMoveToDriverTests.cs | 30 ++++- 4 files changed, 165 insertions(+), 54 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 300919e..1633f4a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2424,6 +2424,82 @@ public sealed class GameWindow : IDisposable // 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; + + 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; + + // Bulk-copy the wire's resolved ForwardCommand + speed + // into InterpretedState. For Action commands this + // makes apply_current_movement return zero velocity + // on the next tick (gate fails). For substate + // commands (Run/Walk/Ready), this is identical to + // what DoInterpretedMotion would have written. + remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; + remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod; + } + } + if (forwardIsOverlay) { if (!remoteIsAirborne) @@ -2499,47 +2575,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) { - remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; - - // Phase L.1c (2026-04-28): capture the full MoveTo path - // payload so the per-tick remote driver can steer the - // body toward Origin instead of holding velocity at zero - // between sparse UpdatePosition snaps. Retail - // MoveToManager::MoveToPosition stores the same fields - // (acclient_2013_pseudo_c.txt:307521-307593). - 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) - { - // Cycle changed off MoveTo — clear stale destination - // so the per-tick driver doesn't keep steering after - // the server has switched us back to interpreted - // motion. - remoteMot.HasMoveToDestination = false; - } - - // Forward axis (Ready / WalkForward / RunForward / WalkBackward). - remoteMot.Motion.DoInterpretedMotion( - fullMotion, speedMod, modifyInterpretedState: true); - // Sidestep axis. if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 54152b0..0981666 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -147,19 +147,31 @@ public static class RemoteMoveToDriver float dy = destinationWorld.Y - bodyPosition.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); - // Arrival predicate. Retail (named decomp): dist ≤ min_distance. - // ACE port: dist ≤ DistanceToObject. ACE's wire put the melee - // threshold in DistanceToObject (default 0.6) and left - // MinDistance=0; retail server config presumably set MinDistance. - // Defensive port: take the larger so we honor whichever field is - // populated. Flee branch is unused here but we honor the - // moveTowards flag for symmetry. - float arrivalThreshold = MathF.Max(minDistance, distanceToObject); + // 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) diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index da042f0..09f9eb9 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -268,6 +268,41 @@ public class UpdateMotionTests 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() { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index 3274702..ece3f9b 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -60,12 +60,30 @@ public class RemoteMoveToDriverTests } [Fact] - public void Drive_RetailMinDistanceWins_WhenLargerThanDistanceToObject() + public void Drive_FleeArrival_UsesMinDistance() { - // Hypothetical retail packet: MinDistance=2.0 (set explicitly), - // DistanceToObject=0.6 (default). Arrival should fire at 2 m - // because retail's algorithm uses MinDistance and it's the larger - // of the two. + // 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); @@ -76,7 +94,7 @@ public class RemoteMoveToDriverTests dt: 0.016f, moveTowards: true, out _); - Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); } [Fact] From ff6d3d0c947f095bb721bb23a0d27b4864c7b742 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:14:35 +0200 Subject: [PATCH 18/21] fix(anim): Phase L.1c clamp approach velocity to prevent overshoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed residual after f794832: creature stops to attack but still runs slightly through the player before stopping. Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a 60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist falls just below the 0.6 m DistanceToObject arrival threshold, the arrival predicate fires and zeroes velocity — but the body has already advanced one full tick INTO the threshold zone. That last tick is the "running through" the user sees, especially when combined with a player visual radius of ~0.5 m. Fix: cap horizontal velocity in the steering branch so the body lands EXACTLY at the arrival threshold instead of overshooting it. Pure function in RemoteMoveToDriver (ClampApproachVelocity) so it's testable; called from GameWindow.cs after apply_current_movement sets RunForward velocity from the active cycle. The clamp is a strict scale-down of the X/Y components; Z is left to gravity / terrain handling. No-op for the flee branch — fleeing has no overshoot risk by definition. Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP tolerance), would-overshoot scale-down, already-at-threshold zeroing, flee no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 +++++ .../Physics/RemoteMoveToDriver.cs | 52 ++++++++++++++ .../Physics/RemoteMoveToDriverTests.cs | 72 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1633f4a..9ce420c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5226,6 +5226,23 @@ public sealed class GameWindow : IDisposable // 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); } } } diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 0981666..90a0388 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -240,6 +240,58 @@ public static class RemoteMoveToDriver 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) { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index ece3f9b..39182cb 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -205,6 +205,78 @@ public class RemoteMoveToDriverTests 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() { From 37de7717788e2419c302bed5eb1d86e0205064aa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:25:37 +0200 Subject: [PATCH 19/21] fix(anim): Phase L.1c bulk-copy ForwardCommand for MoveTo packets too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed regression on commit ff6d3d0: at login, monsters appear as "just a torso on the ground" until they start moving. Cause: f794832 lifted the InterpretedState bulk-copy to apply to BOTH overlay and substate packets, but gated it on `!IsServerControlledMoveTo`. The original substate-only DoInterpretedMotion call I removed had previously updated InterpretedState for MoveTo packets too (fullMotion=RunForward seed from PlanMoveToStart routed through ApplyMotionToInterpretedState's RunForward case). My replacement only fired for non-MoveTo packets, silently regressing MoveTo creatures to a default ForwardCommand=Ready InterpretedState. Consequence: chasing creatures had ForwardCommand=Ready in their InterpretedState even though the cycle on the sequencer was RunForward. apply_current_movement (gate: RunForward||WalkForward) returned zero velocity — body never advanced via the steering branch's velocity integration. The body ONLY translated when an UpdatePosition hard-snap arrived (every ~200ms server tick), producing the "torso on the ground at spawn" pose before the first UP snap landed and "running on the spot" between snaps. Fix: drop the IsServerControlledMoveTo gate. Bulk-copy InterpretedState.ForwardCommand=fullMotion and ForwardSpeed=speedMod UNCONDITIONALLY for any packet that reaches OnLiveMotionUpdated. Matches retail's copy_movement_from (acclient_2013_pseudo_c.txt:293301-293311) which doesn't filter by movement type — for MoveTo, RunForward/speed*runRate; for substate, the wire's command/speed; for overlay, Attack/animSpeed (and get_state_velocity gates correctly to zero, the desired stop). Tests still 1420 green — the existing parser/driver tests cover the data; this is a code-path completeness fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 35 ++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9ce420c..542359a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2465,6 +2465,32 @@ public sealed class GameWindow : IDisposable { 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) { @@ -2488,15 +2514,6 @@ public sealed class GameWindow : IDisposable // Off MoveTo — clear stale destination so the per-tick // driver doesn't keep steering. remoteMot.HasMoveToDestination = false; - - // Bulk-copy the wire's resolved ForwardCommand + speed - // into InterpretedState. For Action commands this - // makes apply_current_movement return zero velocity - // on the next tick (gate fails). For substate - // commands (Run/Walk/Ready), this is identical to - // what DoInterpretedMotion would have written. - remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; - remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod; } } From 34d7f4def270a4f76443a86c228db7ff496183db Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:33:48 +0200 Subject: [PATCH 20/21] fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed regression on commit 37de771: monsters in combat with another client appear as "just a torso on the ground" until they move. User correctly identified this as a regression I introduced. Cause traced to the SEQUENCER side, not the InterpretedState side. AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396) unconditionally calls ClearCyclicTail() BEFORE looking up the requested cycle in the MotionTable. If the cycle is missing (_mtable.Cycles.TryGetValue returns false), the body is left without ANY cyclic tail at all — and every part snaps to its setup-default offset on the next Advance(). Most creatures' setup-defaults put all limbs at the torso origin, so the visual collapses to "just a torso on the ground" until a different (working) cycle arrives. This is specifically a regression from commit 186a584 (Phase L.1c port). Pre-fix, MoveTo packets fell through to fullMotion=Ready (every MotionTable contains a Ready cycle). Post-fix, MoveTo packets seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance creatures (e.g. monsters in HandCombat 0x003C) have no (combat, RunForward) cycle in their MotionTable — they're meant to walk in combat, with retail's apply_run_to_command upgrading WalkForward → RunForward at the velocity layer rather than the animation-cycle layer. Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it. Fall back chain: requested motion → WalkForward → Ready → no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy (commit 37de771) is unchanged — body still gets RunForward velocity even when the visible animation falls back to WalkForward or Ready. Tests: 1420 → 1422. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 48 ++++++++++++++++++- .../Physics/AnimationSequencer.cs | 27 +++++++++++ .../Physics/AnimationSequencerTests.cs | 40 ++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 542359a..100ea54 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2578,7 +2578,53 @@ public sealed class GameWindow : IDisposable // whatever the interpreted state says when the body // lands. 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)) + { + // 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 (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) 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/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 471af2c..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() { From e71ed73aa9bb96b9edb2603c2095c937d6dd4b8e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:39:43 +0200 Subject: [PATCH 21/21] fix(anim): Phase L.1c spawn-time cycle fallback + diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports same "torso on the ground" symptom after 34d7f4d. Likely cause: my fallback only covered the OnLiveMotionUpdated path, not the spawn handler at the CreateObject boundary. If the spawn-time SetCycle requests a (style, motion) pair the MotionTable lacks, ClearCyclicTail wipes the cyclic tail at line 396 of AnimationSequencer.cs and every body part snaps to its setup-default offset until the first OnLiveMotionUpdated UM applies the path's fallback there. Apply the same fallback chain (requested → WalkForward → Ready → no-op-don't-clear) at the spawn handler. Also add a one-line diagnostic dump (under ACDREAM_DUMP_MOTION=1) on both code paths so the next launch confirms whether the fallback is actually firing and what (mtable, style, motion) tuples are missing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 52 ++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 100ea54..4ea7f0a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2111,7 +2111,48 @@ public sealed class GameWindow : IDisposable { seqMotion = AcDream.Core.Physics.MotionCommand.Ready; } - sequencer.SetCycle(seqStyle, seqMotion); + + // 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); } } } @@ -2603,6 +2644,7 @@ public sealed class GameWindow : IDisposable 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)) @@ -2621,6 +2663,14 @@ public sealed class GameWindow : IDisposable { 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);