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