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>
This commit is contained in:
Erik 2026-07-12 14:27:44 +02:00
parent 9b97102c67
commit 8be933fc94
9 changed files with 335 additions and 64 deletions

View file

@ -46,6 +46,39 @@ Copy this block when adding a new issue:
---
## #204 — Replacement corpses replay the death transition and stand back up
**Status:** DONE — 2026-07-12, user visually confirmed multiple corpses remain fallen
**Severity:** HIGH
**Component:** live entities / animation / CreateObject lifecycle
**Description:** A killed monster played its death animation correctly, but
when ACE replaced the victim object with its lootable corpse, the body visibly
returned to the standing pose and replayed the fall.
**Root cause:** Six-object live trace proved ACE sent every corpse CreateObject
with the correct `NonCombat + Dead` motion. A follow-up trace after the first
correction showed the replacement corpse nevertheless completed `Ready`: its
persistent Dead rest pose is static, so it bypassed the normal multi-frame
spawn branch and entered the broader #187 reactive branch. That branch had
been generalized from doors by its gate but still hard-coded Door On/Off from
`PhysicsState`; for a corpse those commands did not select a cycle, leaving the
motion-table default Ready and producing the hard upright pop.
**Resolution:** Both live-spawn branches now use one data-driven initializer.
It installs the motion-table default, applies the authoritative MovementData
stance/command, and then calls `MotionTableManager.HandleEnterWorld()` exactly
like retail's description-then-enter-world lifecycle. No corpse or door type
switch remains. App tests pin Dead and Door On/Off wire-state resolution; a
Core conformance test pins removal of the detached Ready→Dead link before the
first rendered tick.
**Research:** `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`
**Acceptance:** Kill several monsters. Each falls once, is replaced by a
lootable corpse in the same fallen pose, and never visibly stands between the
victim deletion and corpse display.
## #203 — Unequipping armor freezes character and world animations — FIXED
**Status:** DONE — 2026-07-11 (this change)

View file

@ -497,7 +497,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the right-to-left red charge meter, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the right-to-left red charge meter, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.

View file

@ -466,7 +466,12 @@ include dungeons.
meter, silent AttackDone control status, target-frame Keep in View with
retained manual orbit, persistent corpse motion protected from the AP-80
velocity adaptation, and post-death nearest-target acquisition when Auto
Target is enabled. See `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`.
Target is enabled. Both multi-frame and static/reactive spawns now share
retail's wire-driven description-before-enter-world lifecycle: the corpse's
authoritative Dead state is retained and `HandleEnterWorld` strips its
Ready→Dead transition before the first rendered tick. Corpse persistence was
visually confirmed live on 2026-07-12. See
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`.
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:**

View file

@ -7,6 +7,12 @@ Sources: Sept 2013 EoR named retail client.
- `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
@ -27,6 +33,84 @@ 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