refactor(runtime): own combat and magic intent

Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 11:56:40 +02:00
parent 81b31857c6
commit 20df9d155d
49 changed files with 1949 additions and 634 deletions

View file

@ -0,0 +1,189 @@
# Slice J5.3 — retail combat and magic intent ownership
## Scope
This note pins the retail ordering that must remain unchanged while the
presentation-independent combat and casting state moves from `AcDream.App` to
`AcDream.Runtime`. The move changes ownership and command types only. ACE
remains authoritative for combat mode settlement, attacks, casts, damage,
motion, component consumption, effects, and completion.
Primary oracle:
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
Cross-checks:
- the ACE/ACViewer request-layout cross-checks pinned in
`docs/research/2026-07-11-retail-combat-bar-pseudocode.md` and
`docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`;
- the ACE corpse/death-state cross-check pinned in
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`;
- existing conformance ports in
`src/AcDream.Core/Combat/CombatModel.cs` and
`src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs`
## Named retail mechanisms
### `ClientCombatSystem::Begin @ 0x0056A460`
Pseudocode:
```text
combatMode = NonCombat
pendingCombatMode = Undefined
clear jump and power-bar timing
clear request, server-pending, queued-request, attack, and repeat state
requestedPower = 0
requestedHeight = Medium
desiredUiPower = 0.5
trackingTarget = false
advancedCombat = false
```
This is the session-reset state of the Runtime combat-intent owner.
### `ClientCombatSystem::StartAttackRequest @ 0x0056C040`
Pseudocode:
```text
target = GetAttackTarget()
if advanced combat or (target exists and ObjectIsAttackable(target)):
attackRequestInProgress = true
requestedAttackPower = 1.0
FinishJump()
MaybeStopCompletely()
currentBuildIsAutomatic = false
AttemptStartBuildingAttack()
else:
display the retail invalid-target notice
```
The request flags commit before the shared movement owner is asked to finish
jump/stop. The host operation preserves that exact boundary and may publish
the resulting movement packet before a later attack send.
### `StartPowerBarBuild @ 0x0056ADB0`,
`EndAttackRequest @ 0x0056C0E0`, and `UseTime @ 0x0056C1F0`
Pseudocode:
```text
StartPowerBarBuild:
buildInProgress = true
buildStartTime = current game time
publish level 0
latestPowerBarLevel = 0
EndAttackRequest:
if no request: return
requestInProgress = false
currentLevel = GetPowerBarLevel()
requestedPower = explicit power, advanced current level,
or min(desiredUiPower, currentLevel)
if server response pending:
remember one queued release
else if desired power reached or repeating:
ExecuteAttack()
UseTime:
while building, publish the current level
if the player is not ready, cancel/reset according to repeat state
after release, execute once the captured requested level is reached
```
### `AbortAutomaticAttack @ 0x0056AE90`
If a request, attack, server response, or repeat is active, retail sends
`Event_CancelAttack`, clears repeat, and hides the combat power build.
`ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0` invokes this before the
new forward movement enters the interpreter. Runtime therefore receives a
typed movement-abort command at that existing ordering boundary.
### `HandleAttackDoneEvent @ 0x0056C500`
Retail releases the attack busy reference (when held), clears server-pending
state, aborts repeat on authoritative error, starts the next automatic build
or attack only when repeat remains enabled, and finally drains the one queued
release. Runtime must never predict completion.
### `GetDefaultCombatMode @ 0x0056B310` and
`ToggleCombatMode @ 0x0056C8C0`
From non-combat, retail scans equipped contents in inventory-placement order:
missile equipment selects Missile, a caster in Held selects Magic, ordinary
melee/two-handed equipment selects Melee, and an incompatible Held item
rejects the transition with its item-name notice. With no weapon it selects
unarmed Melee. From any combat mode, Toggle selects NonCombat without running
the default scan.
The exact decision remains the existing Core port
`CombatInputPlanner.GetDefaultCombatModeDecision`; Runtime owns the command
state and App supplies ordered equipment plus transport/presentation sinks.
### `RecvNotice_SelectionChanged @ 0x0056BD80` and
`AutoTarget @ 0x0056BC80`
When selection becomes zero in Melee/Missile mode and Auto Target is enabled,
retail enters its next-target selection path. An authoritative Dead motion
makes the current target unavailable and reaches the same selection-change
path. Runtime owns the identity transition; App supplies the world query that
selects the closest retail-compatible hostile.
### `ClientMagicSystem::CastSpell @ 0x00568040`
Pseudocode:
```text
resolve the exact known spell metadata
if components are required:
resolve the appropriate formula
reject when a required component is unavailable
resolve self, untargeted, or selected target
validate the target against the spell target mask
FreeHandsAndCastSpell(spellId, targetId)
```
### `ClientMagicSystem::FreeHandsAndCastSpell @ 0x00566EF0`
Pseudocode:
```text
MaybeStopCompletely()
if targetId == 0:
Event_CastUntargetedSpell(spellId)
else:
Event_CastTargetedSpell(targetId, spellId)
IncrementBusyCount()
```
The cast owner therefore stops first, emits exactly one packet, and increments
the shared busy reference only after a successful send. It does not locally
turn the avatar: retail sends after `MaybeStopCompletely`, and the
authoritative server turn/cast motion supplies the visible facing. Send
failure clears the remembered request and cannot increment busy.
`ClientMagicSystem::OnEndCharacterSession @ 0x00566F50` clears selected and
targeting spell state, which maps to Runtime session reset.
## Ownership result
Runtime owns:
- attack build/request/repeat/queued-release state and time;
- typed attack commands and movement-abort intent;
- combat-mode command policy;
- authoritative target-death/auto-target identity transitions;
- cast validation intent and last requested spell/target;
- reset, teardown, revisions, and presentation-independent snapshots.
App retains:
- input-key and retained-widget mapping;
- ordered equipped-object and closest-hostile world queries;
- local movement preparation and outbound transport;
- target/spell compatibility data that depends on the live object table;
- DAT spell catalog/formulas/components/icons;
- messages, bars, animations, audio, particles, and camera presentation.
No new retail divergence is introduced by this ownership move.