acdream/docs/research/2026-07-12-death-and-auto-target-pseudocode.md
Erik 0f2d98c501 fix(combat): synchronize auto-target presentation
Make the retained toolbar consume retail's payload-free selection notice from canonical live state so a reentrant Auto Target replacement cannot be erased by the outer clear. Restrict automatic acquisition to the requested hostile non-player monster policy and record the intentional PK edge divergence.

Co-Authored-By: Codex <codex@openai.com>
2026-07-12 20:26:00 +02:00

178 lines
7 KiB
Markdown

# Persistent death and post-death Auto Target pseudocode
Sources: Sept 2013 EoR named retail client.
- `ClientCombatSystem::AutoTarget` (`0x0056BC80`)
- `ClientCombatSystem::RecvNotice_SelectionChanged` (`0x0056BD80`)
- `CPlayerSystem::SelectNext` (`0x0055F9A0`)
- `MovementManager::unpack_movement` (`0x00524440`)
- `CPhysicsObj::MoveOrTeleport` (`0x00516330`)
- `SmartBox::HandleCreateObject` (`0x00454C80`)
- `ACCObjectMaint::CreateObject` (`0x00558870`)
- `CPhysicsObj::set_description` (`0x00514F40`)
- `CPhysicsObj::enter_world` (`0x00516170`)
- `CPartArray::HandleEnterWorld` (`0x00517D70`)
- `MotionTableManager::HandleEnterWorld` (`0x0051BDD0`)
## Animation authority
```text
on UpdateMotion(interpretedState):
pass the state through MovementManager and CMotionInterp
Dead is a persistent substate selected by the motion table
on UpdatePosition(position, velocity):
update the physics pose and velocity
do not select Ready, Walk, Run, or any other animation
```
acdream retains a documented AP-80 adaptation for ACE actors that move only
through position snapshots: velocity may choose a visible locomotion cycle.
That adaptation is now restricted to replacing only Ready/Walk/Run-family
states. An authoritative action or substate such as attack, hit reaction, or
Dead always wins. This preserves the adaptation without allowing a late
position delta to make a corpse stand again.
## CreateObject motion lifecycle
ACE creates a new corpse object after the victim's death animation finishes.
Its CreateObject packet correctly carries `NonCombat + Dead`; this was verified
in the 2026-07-12 live trace for six corpse GUIDs. The remaining stand-up was
not a parser or server-authority bug. The decisive follow-up trace showed the
replacement corpse completing `Ready` immediately after spawn. Dead is a
static persistent rest pose for this setup, so the corpse entered acdream's
"reactive" static-animation branch rather than the normal multi-frame branch.
That branch's gate had been generalized from doors to every nonzero motion
table, but its initializer was still door-specific: it inferred On/Off from
`PhysicsState`, discarded the wire's Dead command, and left the corpse on the
table's Ready default.
Retail performs these operations in this order:
```text
SmartBox::HandleCreateObject(packet):
object = ACCObjectMaint::CreateObject(...)
ACCObjectMaint::CreateObject(...):
object.set_description(physicsDesc, applyMovement=true)
CPhysicsObj::set_description(...):
SetMotionTableID(physicsDesc.motionTable)
-> initialize the motion table's default Ready state
unpack_movement(physicsDesc.movement)
-> apply the wire's NonCombat + Dead state
-> temporarily build [Ready-to-Dead transition link, Dead cycle]
SmartBox::HandleCreateObject(...), after CreateObject returns:
object.enter_world(position)
CPhysicsObj::enter_world(...):
partArray.HandleEnterWorld()
CPartArray::HandleEnterWorld():
motionTableManager.HandleEnterWorld()
MotionTableManager::HandleEnterWorld():
sequence.remove_all_link_animations()
while pendingAnimations is not empty:
AnimationDone(success=false)
```
Therefore the first in-world corpse pose comes from the persistent Dead cycle,
not the Ready-to-Dead transition that the already-deleted victim just played.
This is a generic CreateObject lifecycle rule for every motion-table object;
there is no corpse-name or corpse-type special case in the animation path.
acdream must apply this sequence identically in both of its render-registration
paths:
```text
initialize_spawn_motion(motionTable, wireMovementData):
sequencer.initialize_state() // table default
sequencer.set_cycle(wire.stance, wire.motion)
sequencer.handle_enter_world() // strip transient links
normal multi-frame spawn:
sequencer = initialize_spawn_motion(...)
static/reactive spawn (doors, corpses, gates, ...):
sequencer = initialize_spawn_motion(...)
```
Cross-checks:
- ACE `WorldObject_Networking.SerializePhysicsData` writes the corpse's
`CurrentMotionState` into CreateObject MovementData.
- ACE `Corpse.SetEphemeralValues` sets that state to `NonCombat + Dead`.
- ACE `Door.SetEphemeralValues` likewise supplies `NonCombat + On/Off`; the
client does not need to infer door motion from `PhysicsState`.
- The existing acdream `MotionTableManager.HandleEnterWorld` port and its
#174 conformance tests already implement the retail strip-and-drain
semantics. The defect was that the static/reactive spawn path neither used
the wire motion generically nor called that lifecycle step.
## Auto Target after the selected creature dies
```text
when authoritative Dead motion is applied to the selected object:
clear selected object
publish SelectionChanged
RecvNotice_SelectionChanged():
update target tracking
if selected object is zero
and combat mode is Melee or Missile
and Auto Target is enabled:
AutoTarget()
AutoTarget():
if a recent valid last-attacked object exists:
select it
else:
SelectNext(forward=true, includeCurrent=true, COMPASS_ITEM)
SelectNext(... COMPASS_ITEM) while in Melee/Missile:
reject non-attackable, fellowship, hidden, and out-of-radar candidates
rank eligible candidates by the retail weighted spatial distance
select the best candidate
```
The current M2 world-state projection has no retail `lastAttacked` instance-id
quality, so its existing closest eligible creature scan implements the
fallback branch. The important lifecycle behavior is exact: authoritative
death clears selection, and the SelectionChanged consumer reacquires only
when Auto Target is enabled in Melee/Missile combat.
### Selection-notice reentrancy
Retail's selection notice has no selected-object payload:
```text
ACCWeenieObject::SetSelectedObject(newId):
selectedID = newId
CM_UI::SendNotice_SelectionChanged()
CM_UI::SendNotice_SelectionChanged():
for each registered handler:
handler.RecvNotice_SelectionChanged()
gmToolbarUI::HandleSelectionChanged():
id = ACCWeenieObject::selectedID // read current global state
clear and repopulate selected-object strip from id
```
If `ClientCombatSystem::RecvNotice_SelectionChanged` runs first for a clear,
its `AutoTarget()` may select a replacement reentrantly. A later toolbar
handler reads the replacement from global state; it does not receive and apply
the stale outer clear. acdream's retained toolbar must likewise read the
canonical `SelectionState.SelectedObjectId` when notified rather than use the
captured transition payload.
### acdream automatic monster narrowing
Retail's combat-mode `SelectNext(COMPASS_ITEM)` rejects candidates that fail
`ClientCombatSystem::ObjectIsAttackable @ 0x0056A600`; that predicate rejects
friendly NPCs, pets, corpses, and non-creatures but can accept enemy players in
compatible PK states. By explicit product direction, acdream narrows automatic
acquisition further to non-player hostile monsters. Manual player selection
remains separate. This intentional PK edge divergence is register IA-19.