acdream/docs/research/2026-07-12-death-and-auto-target-pseudocode.md
Erik 8be933fc94 fix(animation): preserve wire motion on every spawn path
Replace the door-specific static-animation seed with one retail description-then-enter-world initializer shared by normal and reactive spawns. This preserves authoritative Dead and On/Off states, prevents replacement corpses from returning to Ready, and pins the lifecycle with Core and App tests.

Co-Authored-By: Codex <codex@openai.com>
2026-07-12 14:27:44 +02:00

5.6 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.