Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side behavior for server-controlled creature locomotion and combat engagement. Shipped as MVP after live visual verification across multiple iteration rounds with the user. Highlights: -186a584— initial Phase L.1c port: extracts Origin / target guid / MovementParameters block from MoveTo packets (movementType 6/7), adds RemoteMoveToDriver per-tick body-orientation steering with ±20° aux-turn-equivalent snap tolerance. -d247aef— corrected arrival predicate semantics + 1.5 s stale-destination timeout for entities leaving the streaming view. -f794832— root-caused "creature won't stop to attack" via two research subagents converging on retail CMotionInterp::move_to_interpreted_state's unconditional forward_command bulk-copy. Lifted ServerMoveToActive flag clearing + InterpretedState bulk-copy out of substate-only branch so Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear stale MoveTo state and zero forward velocity. -ff6d3d0— RemoteMoveToDriver.ClampApproachVelocity caps horizontal velocity at the final-approach tick so body lands EXACTLY at DistanceToObject instead of overshooting through the player. -37de771— bulk-copy ForwardCommand for MoveTo packets too (closed the regression where MoveTo creatures stayed at default ForwardCommand=Ready in InterpretedState and only translated via UpdatePosition snaps). -34d7f4d+e71ed73— AnimationSequencer.HasCycle query + fallback chain (requested → WalkForward → Ready → no-op) at BOTH the OnLiveMotionUpdated path AND the spawn handler. Prevents ClearCyclicTail from wiping the body's cyclic tail when ACE CreateObject carries CurrentMotionState.ForwardCommand pointing to an Action-class motion (e.g. AttackHigh1 from a mid-swing creature) which has no cyclic-table entry — was the "torso on the ground" symptom for monsters seen in combat by a fresh observer. Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt (MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80, CMotionInterp::move_to_interpreted_state 0x00528xxx, MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/ ACE.Server/Physics/Animation/MoveToManager.cs (port aid), references/holtburger/ (cross-check on snapshot-only client behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md (the Phase L.1c pseudocode doc). Tests: 1404 → 1422 (parser type-7 path retention, type-6 target guid retention, driver arrival semantics, retail-faithful chase/flee branches, approach-velocity clamp scenarios, HasCycle present/missing, AttackHigh1 wire layout). Pending follow-ups (filed for future): target-guid live resolution for type 6 packets (residual chase lag), StickToObject sticky-target guid trailing field, full MoveToManager state machine port (CheckProgressMade stall detector, Sticky/StickTo, use_final_heading, pending_actions queue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b93dfe95d8
44 changed files with 4580 additions and 301 deletions
|
|
@ -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)
|
### Phase J — Long-tail (deferred / low-priority)
|
||||||
|
|
||||||
Not detailed here; each gets its own brainstorm when it becomes relevant.
|
Not detailed here; each gets its own brainstorm when it becomes relevant.
|
||||||
|
|
|
||||||
557
docs/plans/animation-system-audit.md
Normal file
557
docs/plans/animation-system-audit.md
Normal file
|
|
@ -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.
|
||||||
113
docs/research/2026-04-28-combat-animation-planner.md
Normal file
113
docs/research/2026-04-28-combat-animation-planner.md
Normal file
|
|
@ -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.
|
||||||
285
docs/research/2026-04-28-remote-moveto-pseudocode.md
Normal file
285
docs/research/2026-04-28-remote-moveto-pseudocode.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -226,6 +226,71 @@ public sealed class GameWindow : IDisposable
|
||||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||||
public System.Numerics.Vector3 LastServerPos;
|
public System.Numerics.Vector3 LastServerPos;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Vector3 ServerVelocity;
|
||||||
|
public bool HasServerVelocity;
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||||||
|
/// the InterpretedMotionState path.
|
||||||
|
/// </summary>
|
||||||
|
public bool ServerMoveToActive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||||||
|
/// has been parsed and the world-converted destination is stored on
|
||||||
|
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||||||
|
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||||||
|
/// Phase L.1c (2026-04-28).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasMoveToDestination;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// World-space destination from the most recent MoveTo packet's
|
||||||
|
/// <c>Origin</c> field, converted via the same landblock-grid
|
||||||
|
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||||||
|
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||||||
|
/// the chase-arrival threshold per retail
|
||||||
|
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||||||
|
/// </summary>
|
||||||
|
public float MoveToMinDistance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||||||
|
/// the flee branch (<c>move_away</c>); chase uses
|
||||||
|
/// <see cref="MoveToMinDistance"/>.
|
||||||
|
/// </summary>
|
||||||
|
public float MoveToDistanceToObject;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||||||
|
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||||||
|
/// chase. False = flee (<c>move_away</c>) or static target.
|
||||||
|
/// </summary>
|
||||||
|
public bool MoveToMoveTowards;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||||||
|
/// — typically because the entity left our streaming view and
|
||||||
|
/// the server stopped broadcasting its MoveTo updates.
|
||||||
|
/// </summary>
|
||||||
|
public double LastMoveToPacketTime;
|
||||||
|
/// <summary>
|
||||||
/// Legacy field — no longer used for slerp (retail hard-snaps
|
/// Legacy field — no longer used for slerp (retail hard-snaps
|
||||||
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -532,6 +597,13 @@ public sealed class GameWindow : IDisposable
|
||||||
/// keys the render list; this parallel dictionary keys by server guid.
|
/// keys the render list; this parallel dictionary keys by server guid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||||||
|
private readonly Dictionary<uint, LiveEntityInfo> _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 _liveSpawnReceived; // diagnostics
|
||||||
private int _liveSpawnHydrated;
|
private int _liveSpawnHydrated;
|
||||||
private int _liveDropReasonNoPos;
|
private int _liveDropReasonNoPos;
|
||||||
|
|
@ -1315,6 +1387,7 @@ public sealed class GameWindow : IDisposable
|
||||||
Console.WriteLine($"live: connecting to {endpoint} as {user}");
|
Console.WriteLine($"live: connecting to {endpoint} as {user}");
|
||||||
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
|
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
|
||||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||||
|
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||||
|
|
@ -1666,20 +1739,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// For a respawn, drop the previous rendering state here before we
|
// For a respawn, drop the previous rendering state here before we
|
||||||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||||||
// its value is the live WorldEntity we need to dispose.
|
// its value is the live WorldEntity we need to dispose.
|
||||||
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
|
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||||
{
|
|
||||||
_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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log every spawn that arrives so we can inventory what the server
|
// Log every spawn that arrives so we can inventory what the server
|
||||||
// sends (including the ones we can't render yet). The Name field
|
// sends (including the ones we can't render yet). The Name field
|
||||||
|
|
@ -1691,12 +1751,19 @@ public sealed class GameWindow : IDisposable
|
||||||
: "no-pos";
|
: "no-pos";
|
||||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
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 animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
$"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
|
// Target the statue specifically for full diagnostic dump: Name match
|
||||||
// is cheap and gives us exactly one entity's worth of log regardless
|
// 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)
|
if (mtable is not null)
|
||||||
{
|
{
|
||||||
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
||||||
uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
|
uint seqStyle = stanceOverride is > 0
|
||||||
uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u;
|
? (0x80000000u | (uint)stanceOverride.Value)
|
||||||
sequencer.SetCycle(seqStyle, seqMotion);
|
: (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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
/// 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
|
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
|
||||||
|
|
@ -2186,11 +2343,13 @@ public sealed class GameWindow : IDisposable
|
||||||
&& update.Guid != _playerServerGuid)
|
&& update.Guid != _playerServerGuid)
|
||||||
{
|
{
|
||||||
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
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 seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
|
||||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||||
Console.WriteLine(
|
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}");
|
$"| 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 == null → retail stop signal → Ready
|
||||||
// command.Value == 0 → explicit 0 (rare) → Ready
|
// command.Value == 0 → explicit 0 (rare) → Ready
|
||||||
// otherwise → resolve class byte and use full cmd
|
// otherwise → resolve class byte and use full cmd
|
||||||
|
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||||
uint fullMotion;
|
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;
|
fullMotion = 0x41000003u;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -2262,8 +2438,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
||||||
// produces "slow walk that never stops" — exactly what the
|
// produces "slow walk that never stops" — exactly what the
|
||||||
// stop bug looked like.
|
// stop bug looked like.
|
||||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
|
||||||
|
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||||
&& update.Guid != _playerServerGuid)
|
&& update.Guid != _playerServerGuid)
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
@ -2295,6 +2469,125 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
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 <c>copy_movement_from</c> 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:
|
// Pick which cycle to play on the sequencer. Priority:
|
||||||
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
||||||
// 2. Else sidestep cmd if active — legs strafe.
|
// 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
|
// the post-resolve landing path restores the cycle to
|
||||||
// whatever the interpreted state says when the body
|
// whatever the interpreted state says when the body
|
||||||
// lands.
|
// lands.
|
||||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
|
||||||
&& rmCheck.Airborne;
|
|
||||||
if (!remoteIsAirborne)
|
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
|
// Retail runs the full MotionInterp state machine on every
|
||||||
// remote. Route each wire command (forward, sidestep, turn)
|
// remote. Route each wire command (forward, sidestep, turn)
|
||||||
|
|
@ -2359,12 +2705,17 @@ public sealed class GameWindow : IDisposable
|
||||||
// FUN_00528f70 DoInterpretedMotion
|
// FUN_00528f70 DoInterpretedMotion
|
||||||
// FUN_00528960 get_state_velocity
|
// FUN_00528960 get_state_velocity
|
||||||
// FUN_00529210 apply_current_movement
|
// 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.
|
// Sidestep axis.
|
||||||
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
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),
|
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||||
|
|
@ -2452,57 +2804,20 @@ public sealed class GameWindow : IDisposable
|
||||||
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
|
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route the Commands list — one-shot Actions, Modifiers, and
|
// Route command-list entries through the shared Core router.
|
||||||
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
|
// Retail/ACE send these as 16-bit MotionCommand lows in
|
||||||
// live in the motion table's Links / Modifiers dicts, not
|
// InterpretedMotionState.Commands[]; the router reconstructs the
|
||||||
// Cycles, and are played on top of the current cycle via
|
// class byte and chooses PlayAction for actions/modifiers/emotes
|
||||||
// PlayAction which resolves the right dict and interleaves the
|
// or SetCycle for persistent substates.
|
||||||
// action frames before the cyclic tail.
|
|
||||||
//
|
|
||||||
// A typical NPC wave looks like:
|
|
||||||
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
|
|
||||||
// [{0x0003=Ready, ...}]
|
|
||||||
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
|
|
||||||
// standard SetCycle path (for 0x40 SubState). We leave SubState
|
|
||||||
// commands to fall through to the next UpdateMotion; that's how
|
|
||||||
// retail handles transition sequences (Wave → Ready).
|
|
||||||
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
||||||
{
|
{
|
||||||
foreach (var item in cmds)
|
foreach (var item in cmds)
|
||||||
{
|
{
|
||||||
// Restore the 32-bit MotionCommand from the wire's 16-bit
|
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
||||||
// truncation by OR-ing class bits. The class is encoded
|
ae.Sequencer,
|
||||||
// in the low byte's high nibble via command ranges:
|
fullStyle,
|
||||||
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
|
item.Command,
|
||||||
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
|
item.Speed);
|
||||||
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
|
|
||||||
//
|
|
||||||
// The retail MotionCommand enum carries the class byte in
|
|
||||||
// bits 24-31. DatReaderWriter's enum values match. For
|
|
||||||
// broadcasts, servers emit only low 16 bits (ACE
|
|
||||||
// InterpretedMotionState.cs:139). We reconstruct via a
|
|
||||||
// range-based lookup. See MotionCommand.generated.cs.
|
|
||||||
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
|
|
||||||
if (fullCmd == 0) continue;
|
|
||||||
|
|
||||||
// Action class: play through the link dict then drop back
|
|
||||||
// to the current cycle. Modifier class: resolve from the
|
|
||||||
// Modifiers dict and combine on top. SubState: cycle
|
|
||||||
// change; route through SetCycle so the style-specific
|
|
||||||
// cycle fallback applies.
|
|
||||||
uint cls = fullCmd & 0xFF000000u;
|
|
||||||
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|
|
||||||
|| cls == 0x12000000u || cls == 0x13000000u)
|
|
||||||
{
|
|
||||||
ae.Sequencer.PlayAction(fullCmd, item.Speed);
|
|
||||||
}
|
|
||||||
else if ((cls & 0x40000000u) != 0)
|
|
||||||
{
|
|
||||||
// Substate in the command list — typically the "and
|
|
||||||
// then return to Ready" item. Update the cycle.
|
|
||||||
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
|
|
||||||
}
|
|
||||||
// else: Style / UI / Toggle class — not animation-driving.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
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)
|
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||||
{
|
{
|
||||||
// Phase A.1: track the most recently updated entity's landblock so the
|
// 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.
|
// slerp doesn't visibly rotate from Identity to truth.
|
||||||
rmState.Body.Orientation = rot;
|
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;
|
rmState.Body.Position = worldPos;
|
||||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||||
|
|
@ -2712,7 +3080,7 @@ public sealed class GameWindow : IDisposable
|
||||||
rmState.Body.Orientation = rot;
|
rmState.Body.Orientation = rot;
|
||||||
rmState.TargetOrientation = rot;
|
rmState.TargetOrientation = rot;
|
||||||
rmState.LastServerPos = worldPos;
|
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
|
// Align the body's physics clock with our clock so update_object
|
||||||
// doesn't sub-step a huge initial gap.
|
// doesn't sub-step a huge initial gap.
|
||||||
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
||||||
|
|
@ -2737,6 +3105,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// carries no stop information for our ACE.
|
// carries no stop information for our ACE.
|
||||||
if (svel.LengthSquared() < 0.04f)
|
if (svel.LengthSquared() < 0.04f)
|
||||||
{
|
{
|
||||||
|
rmState.ServerMoveToActive = false;
|
||||||
rmState.Motion.StopCompletely();
|
rmState.Motion.StopCompletely();
|
||||||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||||||
&& aeForStop.Sequencer is not null)
|
&& 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.Position = rmState.Body.Position;
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
|
|
@ -5009,7 +5393,114 @@ public sealed class GameWindow : IDisposable
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
| 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
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -6081,6 +6572,26 @@ public sealed class GameWindow : IDisposable
|
||||||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||||
break;
|
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:
|
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||||
if (_cameraController?.IsFlyMode == true)
|
if (_cameraController?.IsFlyMode == true)
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_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}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||||||
/// subscriber can call it. Same body as the previous Tab branch in
|
/// subscriber can call it. Same body as the previous Tab branch in
|
||||||
|
|
|
||||||
|
|
@ -156,22 +156,20 @@ public static class GameEventWiring
|
||||||
dispatcher.Register(GameEventType.VictimNotification, e =>
|
dispatcher.Register(GameEventType.VictimNotification, e =>
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
|
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
|
||||||
if (p is not null) combat.OnVictimNotification(
|
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
|
||||||
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
|
|
||||||
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
|
|
||||||
});
|
});
|
||||||
dispatcher.Register(GameEventType.DefenderNotification, e =>
|
dispatcher.Register(GameEventType.DefenderNotification, e =>
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
|
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
|
||||||
if (p is not null) combat.OnDefenderNotification(
|
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);
|
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
|
||||||
});
|
});
|
||||||
dispatcher.Register(GameEventType.AttackerNotification, e =>
|
dispatcher.Register(GameEventType.AttackerNotification, e =>
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
|
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
|
||||||
if (p is not null) combat.OnAttackerNotification(
|
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 =>
|
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
|
||||||
{
|
{
|
||||||
|
|
@ -188,12 +186,15 @@ public static class GameEventWiring
|
||||||
var p = GameEvents.ParseAttackDone(e.Payload.Span);
|
var p = GameEvents.ParseAttackDone(e.Payload.Span);
|
||||||
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
|
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 =>
|
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);
|
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 ────────────────────────────────────────────────
|
// ── Spells ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -3,60 +3,79 @@ using System.Buffers.Binary;
|
||||||
namespace AcDream.Core.Net.Messages;
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbound <c>0x0008 AttackTargetRequest</c> GameAction.
|
/// Outbound combat attack GameActions.
|
||||||
|
///
|
||||||
|
/// Retail/ACE use distinct payloads for melee and missile:
|
||||||
///
|
///
|
||||||
/// <para>
|
|
||||||
/// Wire layout (inside the <c>0xF7B1</c> GameAction envelope):
|
|
||||||
/// <code>
|
/// <code>
|
||||||
/// u32 0xF7B1 // GameAction envelope opcode
|
/// u32 0xF7B1 // GameAction envelope opcode
|
||||||
/// u32 gameActionSequence // client sequence
|
/// u32 gameActionSequence // client sequence
|
||||||
/// u32 0x0008 // sub-opcode
|
/// u32 0x0008 // TargetedMeleeAttack
|
||||||
/// u32 targetGuid // who to attack
|
/// u32 targetGuid
|
||||||
/// f32 powerLevel // [0.0, 1.0] — the power bar position
|
|
||||||
/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons
|
|
||||||
/// u32 attackHeight // 1=High, 2=Medium, 3=Low
|
/// 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]
|
||||||
/// </code>
|
/// </code>
|
||||||
/// </para>
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
|
||||||
/// The server ALREADY knows the attacker (it's the session's player),
|
/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
|
||||||
/// so this message only carries the target + attack params. The server
|
/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
|
||||||
/// then rolls damage, picks a body part, and broadcasts
|
/// holtburger protocol game_action.rs.
|
||||||
/// <see cref="GameEventType.VictimNotification"/> / AttackerNotification
|
|
||||||
/// / DefenderNotification / EvasionAttackerNotification /
|
|
||||||
/// EvasionDefenderNotification with the result.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AttackTargetRequest
|
public static class AttackTargetRequest
|
||||||
{
|
{
|
||||||
public const uint GameActionEnvelope = 0xF7B1u;
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Build the wire body for a targeted melee attack.</summary>
|
||||||
/// Build the wire body for an attack request.
|
public static byte[] BuildMelee(
|
||||||
/// </summary>
|
|
||||||
/// <param name="powerLevel">[0..1] melee power bar position.</param>
|
|
||||||
/// <param name="accuracyLevel">[0..1] missile accuracy bar position; pass 0 for melee.</param>
|
|
||||||
/// <param name="attackHeight">1=High, 2=Medium, 3=Low.</param>
|
|
||||||
public static byte[] Build(
|
|
||||||
uint gameActionSequence,
|
uint gameActionSequence,
|
||||||
uint targetGuid,
|
uint targetGuid,
|
||||||
float powerLevel,
|
uint attackHeight,
|
||||||
float accuracyLevel,
|
float powerLevel)
|
||||||
uint attackHeight)
|
|
||||||
{
|
{
|
||||||
byte[] body = new byte[28];
|
byte[] body = new byte[24];
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build the wire body for a targeted missile attack.</summary>
|
||||||
|
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.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build the wire body for cancelling an active attack request.</summary>
|
||||||
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,17 @@ public static class CharacterActions
|
||||||
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
|
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
|
||||||
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
|
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
|
||||||
|
|
||||||
|
[Flags]
|
||||||
public enum CombatMode : uint
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>
|
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// 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
|
/// palettes, texture overrides, animation frames, velocity, ...) are
|
||||||
/// consumed-but-ignored so the parse position ends up wherever the
|
/// consumed-but-ignored so the parse position ends up wherever the
|
||||||
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
||||||
/// the end of the body to return useful output. We stop after PhysicsData
|
/// the end of the body to return useful output. We read through the fixed
|
||||||
/// since that's the last segment containing fields acdream cares about
|
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||||||
/// in this phase.
|
/// tails.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -51,6 +51,8 @@ public static class CreateObject
|
||||||
public const uint PaletteTypePrefix = 0x04000000u;
|
public const uint PaletteTypePrefix = 0x04000000u;
|
||||||
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
||||||
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
||||||
|
/// <summary>Icon dat id type prefix.</summary>
|
||||||
|
public const uint IconTypePrefix = 0x06000000u;
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum PhysicsDescriptionFlag : uint
|
public enum PhysicsDescriptionFlag : uint
|
||||||
|
|
@ -78,9 +80,9 @@ public static class CreateObject
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three fields acdream cares about. Position and SetupTableId are
|
/// The spawn fields acdream currently cares about. Position and
|
||||||
/// nullable because their corresponding physics-description-flag bits
|
/// SetupTableId are nullable because their corresponding
|
||||||
/// may not be set on every CreateObject.
|
/// physics-description-flag bits may not be set on every CreateObject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct Parsed(
|
public readonly record struct Parsed(
|
||||||
uint Guid,
|
uint Guid,
|
||||||
|
|
@ -92,6 +94,7 @@ public static class CreateObject
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name,
|
string? Name,
|
||||||
|
uint? ItemType,
|
||||||
ServerMotionState? MotionState,
|
ServerMotionState? MotionState,
|
||||||
uint? MotionTableId,
|
uint? MotionTableId,
|
||||||
ushort InstanceSequence = 0,
|
ushort InstanceSequence = 0,
|
||||||
|
|
@ -136,7 +139,59 @@ public static class CreateObject
|
||||||
ushort? SideStepCommand = null,
|
ushort? SideStepCommand = null,
|
||||||
float? SideStepSpeed = null,
|
float? SideStepSpeed = null,
|
||||||
ushort? TurnCommand = 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)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
|
||||||
|
|
||||||
|
public bool MoveToCanRun => !MoveToParameters.HasValue
|
||||||
|
|| (MoveToParameters.Value & 0x2u) != 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
|
||||||
|
/// chasing its target. Cross-checked against acclient.h:31423-31443
|
||||||
|
/// (named retail) + ACE <c>MovementParamFlags.MoveTowards</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool MoveTowards => MoveToParameters.HasValue
|
||||||
|
&& (MoveToParameters.Value & 0x200u) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
|
||||||
|
/// Wire layout per <c>MovementParameters::UnPackNet</c> @ <c>0x0052ac50</c>
|
||||||
|
/// + the leading <c>Origin</c> + optional target guid for type 6:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>type 6 (MoveToObject) only: u32 <c>TargetGuid</c></item>
|
||||||
|
/// <item>Origin: u32 <c>cellId</c>, then 3 floats (local x/y/z within the landblock)</item>
|
||||||
|
/// <item>MovementParameters (28 bytes, exact retail order):
|
||||||
|
/// u32 flags, f32 <c>distance_to_object</c>, f32 <c>min_distance</c>,
|
||||||
|
/// f32 <c>fail_distance</c>, f32 <c>speed</c>, f32 <c>walk_run_threshhold</c>,
|
||||||
|
/// f32 <c>desired_heading</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// (The trailing <c>runRate</c> float is captured separately on
|
||||||
|
/// <see cref="ServerMotionState.MoveToRunRate"/>.)
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||||
|
|
@ -390,27 +445,39 @@ public static class CreateObject
|
||||||
pos += 9 * 2;
|
pos += 9 * 2;
|
||||||
AlignTo4(ref pos);
|
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;
|
string? name = null;
|
||||||
|
uint? itemType = null;
|
||||||
if (body.Length - pos >= 4)
|
if (body.Length - pos >= 4)
|
||||||
{
|
{
|
||||||
pos += 4; // skip weenieFlags u32
|
pos += 4; // skip weenieFlags u32
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
name = ReadString16L(body, ref pos);
|
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 */ }
|
catch { /* truncated name — partial result is still useful */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Parsed(guid, position, setupTableId, animParts,
|
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);
|
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
|
||||||
|
|
||||||
// Local helper: if we ran out of fields past PhysicsData, still
|
// Local helper: if we ran out of fields past PhysicsData, still
|
||||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||||
Parsed PartialResult() => new(
|
Parsed PartialResult() => new(
|
||||||
guid, position, setupTableId, animParts,
|
guid, position, setupTableId, animParts,
|
||||||
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
|
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -528,6 +595,9 @@ public static class CreateObject
|
||||||
float? sidestepSpeed = null;
|
float? sidestepSpeed = null;
|
||||||
ushort? turnCommand = null;
|
ushort? turnCommand = null;
|
||||||
float? turnSpeed = null;
|
float? turnSpeed = null;
|
||||||
|
uint? moveToParameters = null;
|
||||||
|
float? moveToSpeed = null;
|
||||||
|
float? moveToRunRate = null;
|
||||||
List<MotionItem>? commands = null;
|
List<MotionItem>? commands = null;
|
||||||
|
|
||||||
// 0 = Invalid is the only union variant we care about for static
|
// 0 = Invalid is the only union variant we care about for static
|
||||||
|
|
@ -630,14 +700,62 @@ public static class CreateObject
|
||||||
}
|
}
|
||||||
done:;
|
done:;
|
||||||
}
|
}
|
||||||
|
else if (movementType is 6 or 7)
|
||||||
|
{
|
||||||
|
TryParseMoveToPayload(
|
||||||
|
mv,
|
||||||
|
p,
|
||||||
|
movementType,
|
||||||
|
out moveToParameters,
|
||||||
|
out moveToSpeed,
|
||||||
|
out moveToRunRate);
|
||||||
|
}
|
||||||
|
|
||||||
return new ServerMotionState(
|
return new ServerMotionState(
|
||||||
currentStyle, forwardCommand, forwardSpeed, commands,
|
currentStyle, forwardCommand, forwardSpeed, commands,
|
||||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
|
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
||||||
|
movementType,
|
||||||
|
moveToParameters,
|
||||||
|
moveToSpeed,
|
||||||
|
moveToRunRate);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseMoveToPayload(
|
||||||
|
ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>ObjectDelete</c> GameMessage (opcode <c>0xF747</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail dispatch path:
|
||||||
|
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 reads guid from
|
||||||
|
/// <c>buf+4</c> and instance sequence from <c>buf+8</c>, then calls
|
||||||
|
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0. ACE emits the same
|
||||||
|
/// layout from <c>GameMessageDeleteObject</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DeleteObject
|
||||||
|
{
|
||||||
|
public const uint Opcode = 0xF747u;
|
||||||
|
|
||||||
|
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
|
||||||
|
/// 4-byte opcode, matching every other parser in this namespace.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,56 +147,34 @@ public static class GameEvents
|
||||||
|
|
||||||
// ── Combat notifications ────────────────────────────────────────────────
|
// ── Combat notifications ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>0x01AC VictimNotification — "you got hit for X".</summary>
|
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
|
||||||
public readonly record struct VictimNotification(
|
public readonly record struct VictimNotification(string DeathMessage);
|
||||||
string AttackerName,
|
|
||||||
uint AttackerGuid,
|
|
||||||
uint DamageType,
|
|
||||||
uint Damage,
|
|
||||||
uint HitQuadrant,
|
|
||||||
uint Critical,
|
|
||||||
uint AttackType);
|
|
||||||
|
|
||||||
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
|
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
try
|
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01AD KillerNotification — "you killed X".</summary>
|
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
|
||||||
public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
|
public readonly record struct KillerNotification(string DeathMessage);
|
||||||
|
|
||||||
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
|
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
try
|
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01B1 AttackerNotification — "you hit X for Y%".</summary>
|
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
|
||||||
public readonly record struct AttackerNotification(
|
public readonly record struct AttackerNotification(
|
||||||
string DefenderName,
|
string DefenderName,
|
||||||
uint DamageType,
|
uint DamageType,
|
||||||
|
double HealthPercent,
|
||||||
uint Damage,
|
uint Damage,
|
||||||
float DamagePercent);
|
uint Critical,
|
||||||
|
ulong AttackConditions);
|
||||||
|
|
||||||
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
|
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
|
|
@ -204,23 +182,26 @@ public static class GameEvents
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string name = ReadString16L(payload, ref pos);
|
string name = ReadString16L(payload, ref pos);
|
||||||
if (payload.Length - pos < 12) return null;
|
if (payload.Length - pos < 28) return null;
|
||||||
uint damageType = 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;
|
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
|
||||||
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4;
|
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
return new AttackerNotification(name, damageType, damage, pct);
|
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; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01B2 DefenderNotification — "X hit you for Y".</summary>
|
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
|
||||||
public readonly record struct DefenderNotification(
|
public readonly record struct DefenderNotification(
|
||||||
string AttackerName,
|
string AttackerName,
|
||||||
uint AttackerGuid,
|
|
||||||
uint DamageType,
|
uint DamageType,
|
||||||
|
double HealthPercent,
|
||||||
uint Damage,
|
uint Damage,
|
||||||
uint HitQuadrant,
|
uint HitQuadrant,
|
||||||
uint Critical);
|
uint Critical,
|
||||||
|
ulong AttackConditions);
|
||||||
|
|
||||||
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
|
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
|
|
@ -228,40 +209,42 @@ public static class GameEvents
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string name = ReadString16L(payload, ref pos);
|
string name = ReadString16L(payload, ref pos);
|
||||||
if (payload.Length - pos < 20) return null;
|
if (payload.Length - pos < 32) return null;
|
||||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
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 dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
uint quad = 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 crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
return new DefenderNotification(name, guid, dtype, dmg, quad, crit);
|
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
|
||||||
|
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01B3 EvasionAttackerNotification — "X evaded".</summary>
|
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
|
||||||
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
|
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01B4 EvasionDefenderNotification — "you evaded X".</summary>
|
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
|
||||||
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
|
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x01A7 AttackDone — (attackSequence, weenieError).</summary>
|
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
|
||||||
|
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
|
||||||
|
|
||||||
|
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
|
||||||
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
|
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
|
||||||
|
|
||||||
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
|
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
if (payload.Length < 8) return null;
|
if (payload.Length < 4) return null;
|
||||||
return new AttackDone(
|
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Spell enchantments ──────────────────────────────────────────────────
|
// ── Spell enchantments ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,10 @@ public static class UpdateMotion
|
||||||
float? sidestepSpeed = null;
|
float? sidestepSpeed = null;
|
||||||
ushort? turnCommand = null;
|
ushort? turnCommand = null;
|
||||||
float? turnSpeed = null;
|
float? turnSpeed = null;
|
||||||
|
uint? moveToParameters = null;
|
||||||
|
float? moveToSpeed = null;
|
||||||
|
float? moveToRunRate = null;
|
||||||
|
CreateObject.MoveToPathData? moveToPath = null;
|
||||||
List<CreateObject.MotionItem>? commands = null;
|
List<CreateObject.MotionItem>? commands = null;
|
||||||
|
|
||||||
if (movementType == 0)
|
if (movementType == 0)
|
||||||
|
|
@ -135,7 +139,7 @@ public static class UpdateMotion
|
||||||
// MovementInvalid branch, just reached via the header'd path.
|
// MovementInvalid branch, just reached via the header'd path.
|
||||||
// Includes the Commands list (MotionItem[]) that carries
|
// Includes the Commands list (MotionItem[]) that carries
|
||||||
// Actions, emotes, and other one-shots not in ForwardCommand.
|
// 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));
|
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
uint flags = packed & 0x7Fu;
|
uint flags = packed & 0x7Fu;
|
||||||
|
|
@ -158,13 +162,13 @@ public static class UpdateMotion
|
||||||
|
|
||||||
if ((flags & 0x1u) != 0)
|
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));
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
}
|
}
|
||||||
if ((flags & 0x2u) != 0)
|
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));
|
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
}
|
}
|
||||||
|
|
@ -221,14 +225,108 @@ public static class UpdateMotion
|
||||||
}
|
}
|
||||||
done:;
|
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(
|
return new Parsed(guid, new CreateObject.ServerMotionState(
|
||||||
currentStyle, forwardCommand, forwardSpeed, commands,
|
currentStyle, forwardCommand, forwardSpeed, commands,
|
||||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
|
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
||||||
|
movementType,
|
||||||
|
moveToParameters,
|
||||||
|
moveToSpeed,
|
||||||
|
moveToRunRate,
|
||||||
|
moveToPath));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseMoveToPayload(
|
||||||
|
ReadOnlySpan<byte> 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
|
||||||
|
// <see cref="CreateObject.MoveToPathData"/> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Net.Cryptography;
|
using AcDream.Core.Net.Cryptography;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
|
@ -53,12 +54,23 @@ public sealed class WorldSession : IDisposable
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name,
|
string? Name,
|
||||||
|
uint? ItemType,
|
||||||
CreateObject.ServerMotionState? MotionState,
|
CreateObject.ServerMotionState? MotionState,
|
||||||
uint? MotionTableId);
|
uint? MotionTableId);
|
||||||
|
|
||||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||||
public event Action<EntitySpawn>? EntitySpawned;
|
public event Action<EntitySpawn>? EntitySpawned;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires when the session parses a 0xF747 ObjectDelete game message.
|
||||||
|
/// Retail routes this through
|
||||||
|
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
|
||||||
|
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
|
||||||
|
/// an object leaves the world, including the living creature object
|
||||||
|
/// after its corpse is created.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<DeleteObject.Parsed>? EntityDeleted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||||
/// whose motion changed and its new server-side stance + forward command.
|
/// 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.BasePaletteId,
|
||||||
parsed.Value.ObjScale,
|
parsed.Value.ObjScale,
|
||||||
parsed.Value.Name,
|
parsed.Value.Name,
|
||||||
|
parsed.Value.ItemType,
|
||||||
parsed.Value.MotionState,
|
parsed.Value.MotionState,
|
||||||
parsed.Value.MotionTableId));
|
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)
|
else if (op == UpdateMotion.Opcode)
|
||||||
{
|
{
|
||||||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||||||
|
|
@ -909,6 +928,48 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(body);
|
SendGameAction(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||||
|
public void SendChangeCombatMode(CombatMode mode)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
byte[] body = CharacterActions.BuildChangeCombatMode(
|
||||||
|
seq,
|
||||||
|
(CharacterActions.CombatMode)(uint)mode);
|
||||||
|
SendGameAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
|
||||||
|
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
byte[] body = AttackTargetRequest.BuildMelee(
|
||||||
|
seq,
|
||||||
|
targetGuid,
|
||||||
|
(uint)attackHeight,
|
||||||
|
powerLevel);
|
||||||
|
SendGameAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail TargetedMissileAttack (0x000A).</summary>
|
||||||
|
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
byte[] body = AttackTargetRequest.BuildMissile(
|
||||||
|
seq,
|
||||||
|
targetGuid,
|
||||||
|
(uint)attackHeight,
|
||||||
|
accuracyLevel);
|
||||||
|
SendGameAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail CancelAttack (0x01B7).</summary>
|
||||||
|
public void SendCancelAttack()
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
byte[] body = AttackTargetRequest.BuildCancel(seq);
|
||||||
|
SendGameAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
|
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
|
||||||
/// global community room (General / Trade / LFG / Roleplay /
|
/// global community room (General / Trade / LFG / Roleplay /
|
||||||
|
|
|
||||||
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Combat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail-faithful combat animation planner for server-sent motion commands.
|
||||||
|
///
|
||||||
|
/// Retail evidence:
|
||||||
|
/// - <c>ClientCombatSystem::ExecuteAttack</c> (0x0056BB70) only sends the
|
||||||
|
/// targeted melee/missile GameAction and sets response state; it does not
|
||||||
|
/// locally choose or play a swing animation.
|
||||||
|
/// - <c>ClientCombatSystem::HandleCommenceAttackEvent</c> (0x0056AD20)
|
||||||
|
/// updates the power bar/busy state; it carries no MotionCommand.
|
||||||
|
/// - ACE <c>Player_Melee.DoSwingMotion</c> chooses a swing via
|
||||||
|
/// <c>CombatManeuverTable.GetMotion</c> and broadcasts that MotionCommand
|
||||||
|
/// in <c>UpdateMotion</c>.
|
||||||
|
///
|
||||||
|
/// So acdream treats combat GameEvents as state/UI signals and treats
|
||||||
|
/// UpdateMotion command IDs as the animation authority.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects combat swing motions from the retail <c>CombatTable</c> DBObj.
|
||||||
|
///
|
||||||
|
/// Retail evidence:
|
||||||
|
/// - <c>CombatManeuverTable::Get</c> (0x0056AB60) loads DB type
|
||||||
|
/// <c>0x1000000D</c> for a 0x30xxxxxx combat table id.
|
||||||
|
/// - ACE <c>CombatManeuverTable.GetMotion</c> indexes maneuvers by
|
||||||
|
/// stance, attack height, and attack type, returning all matching motions.
|
||||||
|
/// - ACE <c>Player_Melee.GetSwingAnimation</c> then chooses
|
||||||
|
/// <c>motions[1]</c> when more than one motion exists and power is below
|
||||||
|
/// the subdivision threshold; otherwise it uses <c>motions[0]</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<DatMotionCommand> FindMotions(
|
||||||
|
CombatTable table,
|
||||||
|
DatMotionStance stance,
|
||||||
|
DatAttackHeight attackHeight,
|
||||||
|
DatAttackType attackType)
|
||||||
|
{
|
||||||
|
var result = new List<DatMotionCommand>();
|
||||||
|
|
||||||
|
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<DatMotionCommand> Candidates,
|
||||||
|
DatAttackType EffectiveAttackType,
|
||||||
|
float Subdivision)
|
||||||
|
{
|
||||||
|
public static CombatManeuverSelection None { get; } = new(
|
||||||
|
Found: false,
|
||||||
|
Motion: DatMotionCommand.Invalid,
|
||||||
|
Candidates: Array.Empty<DatMotionCommand>(),
|
||||||
|
EffectiveAttackType: DatAttackType.Undef,
|
||||||
|
Subdivision: 0f);
|
||||||
|
}
|
||||||
|
|
@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
|
||||||
// Full research: docs/research/deepdives/r02-combat-system.md
|
// Full research: docs/research/deepdives/r02-combat-system.md
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Flags]
|
||||||
public enum CombatMode
|
public enum CombatMode
|
||||||
{
|
{
|
||||||
Undef = 0,
|
Undef = 0,
|
||||||
NonCombat = 1,
|
NonCombat = 0x01,
|
||||||
Melee = 2,
|
Melee = 0x02,
|
||||||
Missile = 3,
|
Missile = 0x04,
|
||||||
Magic = 4,
|
Magic = 0x08,
|
||||||
Peaceful = 5,
|
|
||||||
|
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||||
|
CombatCombat = Melee | Missile | Magic,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AttackHeight
|
public enum AttackHeight
|
||||||
|
|
@ -24,6 +27,51 @@ public enum AttackHeight
|
||||||
Low = 3,
|
Low = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum CombatAttackAction
|
||||||
|
{
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
||||||
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
||||||
|
|
@ -31,20 +79,26 @@ public enum AttackHeight
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum AttackType : uint
|
public enum AttackType : uint
|
||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
Punch = 0x0001,
|
Punch = 0x0001,
|
||||||
Kick = 0x0002,
|
Thrust = 0x0002,
|
||||||
Thrust = 0x0004,
|
Slash = 0x0004,
|
||||||
Slash = 0x0008,
|
Kick = 0x0008,
|
||||||
DoubleSlash = 0x0010,
|
OffhandPunch = 0x0010,
|
||||||
TripleSlash = 0x0020,
|
DoubleSlash = 0x0020,
|
||||||
DoubleThrust = 0x0040,
|
TripleSlash = 0x0040,
|
||||||
TripleThrust = 0x0080,
|
DoubleThrust = 0x0080,
|
||||||
Offhand = 0x0100,
|
TripleThrust = 0x0100,
|
||||||
OffhandSlash = 0x0200,
|
OffhandThrust = 0x0200,
|
||||||
OffhandThrust = 0x0400,
|
OffhandSlash = 0x0400,
|
||||||
ThrustSlash = 0x0800,
|
OffhandDoubleSlash = 0x0800,
|
||||||
// more in r02 §2
|
OffhandTripleSlash = 0x1000,
|
||||||
|
OffhandDoubleThrust = 0x2000,
|
||||||
|
OffhandTripleThrust = 0x4000,
|
||||||
|
Unarmed = Punch | Kick | OffhandPunch,
|
||||||
|
MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust
|
||||||
|
| OffhandDoubleSlash | OffhandTripleSlash
|
||||||
|
| OffhandDoubleThrust | OffhandTripleThrust,
|
||||||
}
|
}
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ public sealed class CombatState
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
|
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
|
||||||
|
|
||||||
|
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
|
||||||
|
|
||||||
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
|
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
|
||||||
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
|
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
|
||||||
|
|
||||||
|
|
@ -57,6 +59,12 @@ public sealed class CombatState
|
||||||
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
|
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
|
||||||
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
|
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
|
||||||
|
|
||||||
|
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
|
||||||
|
public event Action? AttackCommenced;
|
||||||
|
|
||||||
|
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
|
||||||
|
public event Action<CombatMode>? CombatModeChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires when the server confirms the player landed a killing blow
|
/// Fires when the server confirms the player landed a killing blow
|
||||||
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
|
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
|
||||||
|
|
@ -94,6 +102,15 @@ public sealed class CombatState
|
||||||
HealthChanged?.Invoke(targetGuid, healthPercent);
|
HealthChanged?.Invoke(targetGuid, healthPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetCombatMode(CombatMode mode)
|
||||||
|
{
|
||||||
|
if (CurrentMode == mode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CurrentMode = mode;
|
||||||
|
CombatModeChanged?.Invoke(mode);
|
||||||
|
}
|
||||||
|
|
||||||
public void OnVictimNotification(
|
public void OnVictimNotification(
|
||||||
string attackerName, uint attackerGuid, uint damageType, uint damage,
|
string attackerName, uint attackerGuid, uint damageType, uint damage,
|
||||||
uint hitQuadrant, uint critical, uint attackType)
|
uint hitQuadrant, uint critical, uint attackType)
|
||||||
|
|
@ -140,5 +157,8 @@ public sealed class CombatState
|
||||||
public void OnAttackDone(uint attackSequence, uint weenieError)
|
public void OnAttackDone(uint attackSequence, uint weenieError)
|
||||||
=> AttackDone?.Invoke(attackSequence, weenieError);
|
=> AttackDone?.Invoke(attackSequence, weenieError);
|
||||||
|
|
||||||
|
public void OnCombatCommenceAttack()
|
||||||
|
=> AttackCommenced?.Invoke();
|
||||||
|
|
||||||
public void Clear() => _healthByGuid.Clear();
|
public void Clear() => _healthByGuid.Clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnimationCommandRouter
|
||||||
|
{
|
||||||
|
private const uint ActionMask = 0x10000000u;
|
||||||
|
private const uint ModifierMask = 0x20000000u;
|
||||||
|
private const uint SubStateMask = 0x40000000u;
|
||||||
|
private const uint ClassMask = 0xFF000000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies a reconstructed full MotionCommand.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs and routes a 16-bit wire command.
|
||||||
|
/// </summary>
|
||||||
|
public static AnimationCommandRouteKind RouteWireCommand(
|
||||||
|
AnimationSequencer sequencer,
|
||||||
|
uint currentStyle,
|
||||||
|
ushort wireCommand,
|
||||||
|
float speedMod = 1f)
|
||||||
|
{
|
||||||
|
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
|
||||||
|
return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a full MotionCommand to the matching sequencer API.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
@ -330,6 +330,33 @@ public sealed class AnimationSequencer
|
||||||
/// makes the jump look delayed (legs stand still for ~100 ms while
|
/// makes the jump look delayed (legs stand still for ~100 ms while
|
||||||
/// the link drains, then fold into Falling). Defaults to false to
|
/// the link drains, then fold into Falling). Defaults to false to
|
||||||
/// preserve normal smooth transitions for everything else.</param>
|
/// preserve normal smooth transitions for everything else.</param>
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <c>WalkForward</c> →
|
||||||
|
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
|
||||||
|
/// unconditional <c>ClearCyclicTail</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
|
||||||
{
|
{
|
||||||
// ── adjust_motion: remap left→right / backward→forward variants ───
|
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,24 @@ public static class MotionCommandResolver
|
||||||
result[lo] = full;
|
result[lo] = full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyNamedRetailOverrides(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,20 @@ public static class MotionCommand
|
||||||
/// regular SetCycle transition.
|
/// regular SetCycle transition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint FallDown = 0x10000050u;
|
public const uint FallDown = 0x10000050u;
|
||||||
/// <summary>0x10000057 — Dead.</summary>
|
/// <summary>0x40000011 - persistent dead substate.</summary>
|
||||||
public const uint Dead = 0x10000057u;
|
public const uint Dead = 0x40000011u;
|
||||||
|
/// <summary>0x10000057 - Sanctuary death-trigger action.</summary>
|
||||||
|
public const uint Sanctuary = 0x10000057u;
|
||||||
|
/// <summary>0x41000012 - crouching substate.</summary>
|
||||||
|
public const uint Crouch = 0x41000012u;
|
||||||
|
/// <summary>0x41000013 - sitting substate.</summary>
|
||||||
|
public const uint Sitting = 0x41000013u;
|
||||||
|
/// <summary>0x41000014 - sleeping substate.</summary>
|
||||||
|
public const uint Sleeping = 0x41000014u;
|
||||||
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
|
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
|
||||||
public const uint CrouchLowerBound = 0x41000011u;
|
public const uint CrouchLowerBound = 0x41000011u;
|
||||||
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
|
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
|
||||||
public const uint CrouchUpperBound = 0x41000014u;
|
public const uint CrouchUpperExclusive = 0x41000015u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -819,7 +827,7 @@ public sealed class MotionInterpreter
|
||||||
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
|
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
|
||||||
/// return 0x49
|
/// return 0x49
|
||||||
/// uVar1 = InterpretedState.ForwardCommand
|
/// uVar1 = InterpretedState.ForwardCommand
|
||||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
|
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
|
||||||
/// return 0x48
|
/// return 0x48
|
||||||
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
||||||
/// return 0x48
|
/// return 0x48
|
||||||
|
|
@ -850,7 +858,7 @@ public sealed class MotionInterpreter
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
||||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
|
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
||||||
|
|
|
||||||
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-tick steering for server-controlled remote creatures while a
|
||||||
|
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
||||||
|
/// is the active locomotion source.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
||||||
|
/// stabilizer. With the full MoveTo path payload now captured on
|
||||||
|
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail references:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
||||||
|
/// per-tick driver. Computes heading-to-target, fires an aux
|
||||||
|
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
||||||
|
/// orientation when within tolerance, and tests arrival via
|
||||||
|
/// <c>dist <= min_distance</c> (chase) or
|
||||||
|
/// <c>dist >= distance_to_object</c> (flee).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
||||||
|
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
||||||
|
/// MoveToManager itself does NOT touch the body. The body's actual
|
||||||
|
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
||||||
|
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
||||||
|
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
||||||
|
/// the body's orientation.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>set_heading(true)</c> snap-on-aligned fudge), and
|
||||||
|
/// arrival detection via <c>min_distance</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// ACE divergence: ACE swaps the chase/flee arrival predicates
|
||||||
|
/// (<c>dist <= DistanceToObject</c> vs retail's <c>dist <= MinDistance</c>).
|
||||||
|
/// We follow retail.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class RemoteMoveToDriver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Heading tolerance below which we snap orientation directly to the
|
||||||
|
/// target heading (ACE's <c>set_heading(target, true)</c>
|
||||||
|
/// server-tic-rate fudge). Above tolerance we rotate at
|
||||||
|
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
||||||
|
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
||||||
|
/// </summary>
|
||||||
|
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default angular rate for in-motion heading correction when delta
|
||||||
|
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
||||||
|
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
||||||
|
/// when the per-creature value differs, the future port can wire it
|
||||||
|
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
||||||
|
/// </summary>
|
||||||
|
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Float-comparison slack for the arrival predicate. With
|
||||||
|
/// <c>min_distance == 0</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public const float ArrivalEpsilon = 0.05f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public const double StaleDestinationSeconds = 1.5;
|
||||||
|
|
||||||
|
public enum DriveResult
|
||||||
|
{
|
||||||
|
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
||||||
|
Arrived,
|
||||||
|
/// <summary>Steering active — caller should let
|
||||||
|
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
||||||
|
Steering,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
||||||
|
/// and report whether the body has arrived or should keep running.
|
||||||
|
/// Pure function — emits the updated orientation via
|
||||||
|
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
||||||
|
/// caller assigns the new value back to its body).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="minDistance">
|
||||||
|
/// <c>min_distance</c> from the wire's MovementParameters block —
|
||||||
|
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="distanceToObject">
|
||||||
|
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
||||||
|
/// threshold (default 0.6 m, the melee range). The actual arrival
|
||||||
|
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
||||||
|
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
||||||
|
/// ACE puts the value in <c>distance_to_object</c> with
|
||||||
|
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
||||||
|
/// 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).
|
||||||
|
/// </param>
|
||||||
|
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 <c>max(MinDistance, DistanceToObject)</c> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a landblock-local Origin from a MoveTo packet
|
||||||
|
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
||||||
|
/// into acdream's render world space using the same arithmetic as
|
||||||
|
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
||||||
|
/// from the live-mode center.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cap horizontal velocity so the body lands exactly at
|
||||||
|
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
||||||
|
/// it during the final tick of approach. Without this clamp, a body
|
||||||
|
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> 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).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The clamp is a strict scale-down of the horizontal component
|
||||||
|
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
||||||
|
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
||||||
|
/// no-op since fleeing has no overshoot risk — the body wants to
|
||||||
|
/// move AWAY from the destination.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chooses the visible locomotion cycle for server-controlled remotes whose
|
||||||
|
/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an
|
||||||
|
/// InterpretedMotionState.
|
||||||
|
///
|
||||||
|
/// Retail references:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>MovementManager::PerformMovement</c> (0x00524440) dispatches movement
|
||||||
|
/// types 6/7 into <c>MoveToManager::MoveToObject/MoveToPosition</c> instead
|
||||||
|
/// of unpacking an InterpretedMotionState.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>MovementParameters::UnPackNet</c> (0x0052AC50) shows MoveTo packets
|
||||||
|
/// carry movement params + run rate, not a ForwardCommand field.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// ACE <c>MovementData.Write</c> uses the same movement type union; holtburger
|
||||||
|
/// documents the matching <c>MovementType::MoveToPosition = 7</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -241,28 +241,32 @@ public sealed class GameEventWiringTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
|
public void WireAll_KillerNotification_AppendsCombatLine()
|
||||||
{
|
{
|
||||||
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
|
var (d, _, _, _, chat) = MakeAll();
|
||||||
// existed but was never registered for dispatch until 2026-04-25.
|
byte[] payload = MakeString16L("You killed the drudge!");
|
||||||
// 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 env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
||||||
d.Dispatch(env!.Value);
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
Assert.Equal("Drudge", gotVictimName);
|
Assert.Equal(1, chat.Count);
|
||||||
Assert.Equal(0x80001234u, gotVictimGuid);
|
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<byte>()));
|
||||||
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
Assert.True(commenced);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,13 @@ public sealed class CharacterActionsTests
|
||||||
Assert.Equal(2u, // Melee = 2
|
Assert.Equal(2u, // Melee = 2
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Text;
|
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
public sealed class CombatEventTests
|
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]
|
[Fact]
|
||||||
public void AttackTargetRequest_Build_EmitsCorrectWireBytes()
|
public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
|
||||||
{
|
{
|
||||||
byte[] body = AttackTargetRequest.Build(
|
byte[] body = AttackTargetRequest.BuildMelee(
|
||||||
gameActionSequence: 3,
|
gameActionSequence: 3,
|
||||||
targetGuid: 0x12345678u,
|
targetGuid: 0x12345678u,
|
||||||
powerLevel: 0.75f,
|
attackHeight: 2,
|
||||||
accuracyLevel: 0.5f,
|
powerLevel: 0.75f);
|
||||||
attackHeight: 2);
|
|
||||||
|
|
||||||
Assert.Equal(28, body.Length);
|
Assert.Equal(24, body.Length);
|
||||||
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
|
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||||
Assert.Equal(3u,
|
Assert.Equal(3u,
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||||
Assert.Equal(AttackTargetRequest.SubOpcode,
|
Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||||
Assert.Equal(0x12345678u,
|
Assert.Equal(0x12345678u,
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||||
|
Assert.Equal(2u,
|
||||||
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||||
Assert.Equal(0.75f,
|
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,
|
Assert.Equal(0.5f,
|
||||||
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
|
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
|
||||||
Assert.Equal(2u,
|
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseVictimNotification_RoundTrip()
|
public void AttackTargetRequest_BuildCancel_HasNoPayload()
|
||||||
{
|
{
|
||||||
byte[] name = MakeString16L("Attacker");
|
byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
|
||||||
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[] payload = new byte[name.Length + tail.Length];
|
Assert.Equal(12, body.Length);
|
||||||
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
|
Assert.Equal(AttackTargetRequest.CancelAttackOpcode,
|
||||||
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
|
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.NotNull(parsed);
|
||||||
Assert.Equal("Attacker", parsed!.Value.AttackerName);
|
Assert.Equal(0u, parsed!.Value.AttackSequence);
|
||||||
Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
|
Assert.Equal(0x36u, parsed.Value.WeenieError);
|
||||||
Assert.Equal(42u, parsed.Value.Damage);
|
}
|
||||||
|
|
||||||
|
[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(1u, parsed.Value.Critical);
|
||||||
|
Assert.Equal(6ul, parsed.Value.AttackConditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseAttackerNotification_RoundTrip()
|
public void ParseDefenderNotification_HoltburgerFixture()
|
||||||
{
|
{
|
||||||
byte[] name = MakeString16L("Drudge");
|
var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
|
||||||
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
|
|
||||||
|
|
||||||
byte[] payload = new byte[name.Length + tail.Length];
|
var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
|
||||||
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
|
|
||||||
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
|
|
||||||
|
|
||||||
var parsed = GameEvents.ParseAttackerNotification(payload);
|
|
||||||
Assert.NotNull(parsed);
|
Assert.NotNull(parsed);
|
||||||
Assert.Equal("Drudge", parsed!.Value.DefenderName);
|
Assert.Equal("Banderling", parsed!.Value.AttackerName);
|
||||||
Assert.Equal(30u, parsed.Value.Damage);
|
Assert.Equal(0x10u, parsed.Value.DamageType);
|
||||||
Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
|
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]
|
[Fact]
|
||||||
public void ParseEvasionAttackerNotification_RoundTrip()
|
public void ParseEvasionNotifications_HoltburgerFixtures()
|
||||||
{
|
{
|
||||||
byte[] payload = MakeString16L("Thrower");
|
var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
|
||||||
Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
|
var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
|
||||||
|
|
||||||
|
Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
|
||||||
|
Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseAttackDone_RoundTrip()
|
public void ParseCombatCommenceAttack_HoltburgerFixture()
|
||||||
{
|
{
|
||||||
byte[] payload = new byte[8];
|
var env = ParseFixture("B0F700000000000005000000B8010000");
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
|
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
|
|
||||||
|
|
||||||
var parsed = GameEvents.ParseAttackDone(payload);
|
Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType);
|
||||||
Assert.NotNull(parsed);
|
Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span));
|
||||||
Assert.Equal(42u, parsed!.Value.AttackSequence);
|
}
|
||||||
Assert.Equal(0u, parsed.Value.WeenieError);
|
|
||||||
|
[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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Normal file
99
tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Normal file
|
|
@ -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<byte>();
|
||||||
|
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<byte> bytes, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
|
||||||
|
bytes.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteU16(List<byte> bytes, ushort value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
|
||||||
|
bytes.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePackedDword(List<byte> 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<byte> 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<byte> bytes)
|
||||||
|
{
|
||||||
|
while ((bytes.Count & 3) != 0)
|
||||||
|
bytes.Add(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
Normal file
39
tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
Normal file
|
|
@ -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<byte> body = stackalloc byte[12];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||||||
|
|
||||||
|
Assert.Null(DeleteObject.TryParse(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsTruncated()
|
||||||
|
{
|
||||||
|
Assert.Null(DeleteObject.TryParse(ReadOnlySpan<byte>.Empty));
|
||||||
|
Assert.Null(DeleteObject.TryParse(new byte[9]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesGuidAndInstanceSequence()
|
||||||
|
{
|
||||||
|
Span<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -185,7 +185,8 @@ public class UpdateMotionTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
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
|
// The parser must still return a valid Parsed with the outer stance
|
||||||
// and a null ForwardCommand rather than failing the whole message.
|
// and a null ForwardCommand rather than failing the whole message.
|
||||||
var body = new byte[4 + 4 + 2 + 6 + 4];
|
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.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
p += 6;
|
p += 6;
|
||||||
body[p++] = 1; // movementType = MoveToObject (non-Invalid)
|
body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
|
||||||
body[p++] = 0;
|
body[p++] = 0;
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
|
||||||
|
|
||||||
|
|
@ -202,5 +203,152 @@ public class UpdateMotionTests
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
|
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
|
||||||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs
Normal file
77
tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
Normal file
155
tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,51 @@ public sealed class CombatStateTests
|
||||||
Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
|
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]
|
[Fact]
|
||||||
public void OnVictimNotification_FiresDamageTaken()
|
public void OnVictimNotification_FiresDamageTaken()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
[Fact]
|
||||||
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
||||||
{
|
{
|
||||||
|
|
@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests
|
||||||
Assert.Equal(99f, fr[0].Origin.X, 1);
|
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]
|
[Fact]
|
||||||
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ public class MotionCommandResolverTests
|
||||||
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
|
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
|
||||||
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
|
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
|
||||||
[InlineData(0x0015, 0x40000015u)] // Falling
|
[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
|
// Action-class one-shots: melee attacks, death, portals
|
||||||
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
|
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
|
||||||
[InlineData(0x0058, 0x10000058u)] // ThrustMed
|
[InlineData(0x0058, 0x10000058u)] // ThrustMed
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests
|
||||||
Assert.False(allowed);
|
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]
|
[Fact]
|
||||||
public void ContactAllowsMove_CrouchRange_RejectsMove()
|
public void ContactAllowsMove_CrouchRange_RejectsMove()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
296
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
296
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
|
||||||
|
/// per-tick steering port of retail
|
||||||
|
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
|
||||||
|
/// creatures.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.World;
|
namespace AcDream.Core.Tests.World;
|
||||||
|
|
||||||
|
[Collection(DerethDateTimeCollection.Name)]
|
||||||
public sealed class DerethDateTimeTests
|
public sealed class DerethDateTimeTests
|
||||||
{
|
{
|
||||||
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half
|
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.World;
|
namespace AcDream.Core.Tests.World;
|
||||||
|
|
||||||
|
[Collection(DerethDateTimeCollection.Name)]
|
||||||
public sealed class SkyStateTests
|
public sealed class SkyStateTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.World;
|
namespace AcDream.Core.Tests.World;
|
||||||
|
|
||||||
|
[Collection(DerethDateTimeCollection.Name)]
|
||||||
public sealed class WorldTimeDebugTests
|
public sealed class WorldTimeDebugTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue