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

7 KiB

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

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:

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:

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

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:

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.