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:
parent
9b97102c67
commit
8be933fc94
9 changed files with 335 additions and 64 deletions
|
|
@ -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
|
## #203 — Unequipping armor freezes character and world animations — FIXED
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-11 (this change)
|
**Status:** DONE — 2026-07-11 (this change)
|
||||||
|
|
|
||||||
|
|
@ -497,7 +497,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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.
|
- **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 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 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 — 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.
|
- **✓ 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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -466,7 +466,12 @@ include dungeons.
|
||||||
meter, silent AttackDone control status, target-frame Keep in View with
|
meter, silent AttackDone control status, target-frame Keep in View with
|
||||||
retained manual orbit, persistent corpse motion protected from the AP-80
|
retained manual orbit, persistent corpse motion protected from the AP-80
|
||||||
velocity adaptation, and post-death nearest-target acquisition when Auto
|
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).
|
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
||||||
|
|
||||||
**Freeze on landing:**
|
**Freeze on landing:**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ Sources: Sept 2013 EoR named retail client.
|
||||||
- `CPlayerSystem::SelectNext` (`0x0055F9A0`)
|
- `CPlayerSystem::SelectNext` (`0x0055F9A0`)
|
||||||
- `MovementManager::unpack_movement` (`0x00524440`)
|
- `MovementManager::unpack_movement` (`0x00524440`)
|
||||||
- `CPhysicsObj::MoveOrTeleport` (`0x00516330`)
|
- `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
|
## 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
|
Dead always wins. This preserves the adaptation without allowing a late
|
||||||
position delta to make a corpse stand again.
|
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
|
## Auto Target after the selected creature dies
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,25 @@ Regression history: treating `0xF625` as despawn/respawn severed the player
|
||||||
controller from the active animation state, so unequipping armor froze character
|
controller from the active animation state, so unequipping armor froze character
|
||||||
animation and made animation-dependent door interaction appear frozen. Fixed as
|
animation and made animation-dependent door interaction appear frozen. Fixed as
|
||||||
issue #203 on 2026-07-11 and user-gated in a Release client.
|
issue #203 on 2026-07-11 and user-gated in a Release client.
|
||||||
|
|
||||||
|
## CreateObject enter-world invariant
|
||||||
|
|
||||||
|
The wire motion is applied while the new physics object is still detached, and
|
||||||
|
only then is the object placed in the world:
|
||||||
|
|
||||||
|
`ACCObjectMaint::CreateObject` → `CPhysicsObj::set_description` (`0x00514F40`)
|
||||||
|
→ apply MovementData → `CPhysicsObj::enter_world` (`0x00516170`) →
|
||||||
|
`CPartArray::HandleEnterWorld` (`0x00517D70`) →
|
||||||
|
`MotionTableManager::HandleEnterWorld` (`0x0051BDD0`).
|
||||||
|
|
||||||
|
The final call strips every description-time transition link and drains the
|
||||||
|
manager queue. This is not optional cleanup. A corpse CreateObject carries
|
||||||
|
`NonCombat + Dead`, while a door carries `NonCombat + On/Off`; both come from
|
||||||
|
the same wire MovementData path.
|
||||||
|
|
||||||
|
Issue #204 exposed a structural trap: the persistent Dead rest pose can be
|
||||||
|
static, so corpses use the same static/reactive registration branch as doors.
|
||||||
|
That branch had been data-driven at its gate but door-hardcoded in its
|
||||||
|
initializer, which discarded Dead and left Ready playing. Every spawn branch
|
||||||
|
must call the shared wire-driven description-then-enter-world initializer,
|
||||||
|
keyed by data and never by object name/type. 2026-07-12.
|
||||||
|
|
|
||||||
|
|
@ -3648,35 +3648,8 @@ public sealed class GameWindow : IDisposable
|
||||||
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||||
if (mtable is not null)
|
if (mtable is not null)
|
||||||
{
|
{
|
||||||
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
sequencer = SpawnMotionInitializer.Create(
|
||||||
uint seqStyle = stanceOverride is > 0
|
setup, mtable, _animLoader, spawn.MotionState);
|
||||||
? (0x80000000u | (uint)stanceOverride.Value)
|
|
||||||
: (uint)mtable.DefaultStyle;
|
|
||||||
uint seqMotion;
|
|
||||||
if (commandOverride is > 0)
|
|
||||||
{
|
|
||||||
uint resolved = AcDream.Core.Physics.MotionCommandResolver
|
|
||||||
.ReconstructFullCommand(commandOverride.Value);
|
|
||||||
seqMotion = resolved != 0
|
|
||||||
? resolved
|
|
||||||
: (0x40000000u | (uint)commandOverride.Value);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
|
|
||||||
}
|
|
||||||
|
|
||||||
// R2-Q5: retail's enter-world sequence — initialize_
|
|
||||||
// state installs the table default (so the entity is
|
|
||||||
// NEVER without a cycle: the L.1c "torso on the
|
|
||||||
// ground" hazard is structurally gone), then the
|
|
||||||
// wire's initial motion dispatches through the
|
|
||||||
// verbatim GetObjectSequence (a missing cycle leaves
|
|
||||||
// the default playing — retail's own miss behavior;
|
|
||||||
// the Run→Walk→Ready fallback chain is deleted with
|
|
||||||
// RemoteMotionSink).
|
|
||||||
sequencer.InitializeState();
|
|
||||||
sequencer.SetCycle(seqStyle, seqMotion);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3730,44 +3703,20 @@ public sealed class GameWindow : IDisposable
|
||||||
// Sequencer.Advance(dt) returns no frames and the MeshRefs
|
// Sequencer.Advance(dt) returns no frames and the MeshRefs
|
||||||
// rebuild at line 7691 collapses the entity to origin).
|
// rebuild at line 7691 collapses the entity to origin).
|
||||||
//
|
//
|
||||||
// Initial cycle mirrors ACE's Door.cs:43
|
// #204: this path is not door-specific. Corpses also arrive here
|
||||||
// (CurrentMotionState = motionClosed): Off when closed at spawn,
|
// because Dead is a persistent static rest pose. The old rescue
|
||||||
// On when the spawn PhysicsState carries the ETHEREAL bit
|
// hard-coded Door On/Off from PhysicsState, discarded the wire's
|
||||||
// (already open in ACE's DB).
|
// authoritative Dead command, and left the corpse on Ready. Both
|
||||||
|
// spawn paths must seed the same wire MovementData through retail's
|
||||||
|
// description-then-enter-world lifecycle.
|
||||||
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
|
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
|
||||||
if (mtableId != 0)
|
if (mtableId != 0)
|
||||||
{
|
{
|
||||||
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||||
if (mtable is not null)
|
if (mtable is not null)
|
||||||
{
|
{
|
||||||
var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
var sequencer = SpawnMotionInitializer.Create(
|
||||||
|
setup, mtable, _animLoader, spawn.MotionState);
|
||||||
// Style key is `0x80000000 | stance`. ACE's MotionStance.NonCombat
|
|
||||||
// is 0x3D (61 decimal), NOT 0x01. Verified live: ACE broadcasts
|
|
||||||
// UpdateMotion with stance=0x003D and the sequencer keys cycles
|
|
||||||
// by style=0x8000003D. An earlier B.4c seed used the wrong
|
|
||||||
// 0x80000001 value, which made HasCycle always return false ->
|
|
||||||
// SetCycle never fired -> sequencer empty -> Advance returned
|
|
||||||
// no frames -> per-frame tick collapsed all door parts to the
|
|
||||||
// entity origin (visible as "door halfway in the ground").
|
|
||||||
const uint NonCombatStyle = 0x8000003Du;
|
|
||||||
const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
|
|
||||||
const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
|
|
||||||
const uint EtherealPs = 0x4u;
|
|
||||||
// Prefer the spawn's wire-level stance if provided; else default
|
|
||||||
// to NonCombat. (Doors normally don't carry an initial MotionState
|
|
||||||
// on spawn — falling back to NonCombat matches ACE Door.cs:43.)
|
|
||||||
ushort spawnStance = spawn.MotionState?.Stance ?? 0;
|
|
||||||
uint initialStyle = spawnStance != 0
|
|
||||||
? (0x80000000u | (uint)spawnStance)
|
|
||||||
: NonCombatStyle;
|
|
||||||
uint spawnState = spawn.PhysicsState ?? 0u;
|
|
||||||
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
|
|
||||||
// R2-Q5: initialize_state guarantees a playing default;
|
|
||||||
// the On/Off dispatch no-ops harmlessly if the door's
|
|
||||||
// table lacks the cycle (HasCycle guard deleted).
|
|
||||||
sequencer.InitializeState();
|
|
||||||
sequencer.SetCycle(initialStyle, initialCycle);
|
|
||||||
|
|
||||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||||||
for (int i = 0; i < meshRefs.Count; i++)
|
for (int i = 0; i < meshRefs.Count; i++)
|
||||||
|
|
@ -3788,8 +3737,11 @@ public sealed class GameWindow : IDisposable
|
||||||
};
|
};
|
||||||
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
{
|
||||||
|
var initial = SpawnMotionInitializer.ResolvePlan(mtable, spawn.MotionState);
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
$"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialStyle=0x{initialStyle:X8} initialCycle=0x{initialCycle:X8}"));
|
$"[reactive-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialStyle=0x{initial.Style:X8} initialCycle=0x{initial.Motion:X8}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/AcDream.App/Rendering/SpawnMotionInitializer.cs
Normal file
63
src/AcDream.App/Rendering/SpawnMotionInitializer.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the initial motion-table state for a server CreateObject using the
|
||||||
|
/// same description-then-enter-world lifecycle as retail.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail oracle: <c>ACCObjectMaint::CreateObject</c> (0x00558870) calls
|
||||||
|
/// <c>CPhysicsObj::set_description</c> (0x00514F40), which applies the wire
|
||||||
|
/// MovementData. <c>SmartBox::HandleCreateObject</c> (0x00454C80) then calls
|
||||||
|
/// <c>CPhysicsObj::enter_world</c> (0x00516170), reaching
|
||||||
|
/// <c>CPartArray::HandleEnterWorld</c> (0x00517D70) and
|
||||||
|
/// <c>MotionTableManager::HandleEnterWorld</c> (0x0051BDD0).
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SpawnMotionInitializer
|
||||||
|
{
|
||||||
|
internal readonly record struct Plan(uint Style, uint Motion);
|
||||||
|
|
||||||
|
public static AnimationSequencer Create(
|
||||||
|
Setup setup,
|
||||||
|
MotionTable motionTable,
|
||||||
|
IAnimationLoader loader,
|
||||||
|
CreateObject.ServerMotionState? wireState)
|
||||||
|
{
|
||||||
|
var sequencer = new AnimationSequencer(setup, motionTable, loader);
|
||||||
|
Plan plan = ResolvePlan(motionTable, wireState);
|
||||||
|
|
||||||
|
// set_description: install the table default, then apply MovementData.
|
||||||
|
sequencer.InitializeState();
|
||||||
|
sequencer.SetCycle(plan.Style, plan.Motion);
|
||||||
|
|
||||||
|
// enter_world: discard description-time transition links so the first
|
||||||
|
// rendered pose is the server-authored persistent cycle. A corpse's
|
||||||
|
// NonCombat+Dead therefore starts fallen; a door's NonCombat+On/Off
|
||||||
|
// comes from the same wire path with no object-type special case.
|
||||||
|
sequencer.Manager.HandleEnterWorld();
|
||||||
|
return sequencer;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Plan ResolvePlan(
|
||||||
|
MotionTable motionTable,
|
||||||
|
CreateObject.ServerMotionState? wireState)
|
||||||
|
{
|
||||||
|
uint style = wireState is { Stance: > 0 } state
|
||||||
|
? 0x80000000u | state.Stance
|
||||||
|
: (uint)motionTable.DefaultStyle;
|
||||||
|
|
||||||
|
uint motion = MotionCommand.Ready;
|
||||||
|
if (wireState?.ForwardCommand is ushort command && command > 0)
|
||||||
|
{
|
||||||
|
uint resolved = MotionCommandResolver.ReconstructFullCommand(command);
|
||||||
|
motion = resolved != 0
|
||||||
|
? resolved
|
||||||
|
: 0x40000000u | command;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Plan(style, motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class SpawnMotionInitializerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CorpseUsesAuthoritativeDeadMotionFromWire()
|
||||||
|
{
|
||||||
|
var table = new MotionTable
|
||||||
|
{
|
||||||
|
DefaultStyle = (DRWMotionCommand)0x8000003Du,
|
||||||
|
};
|
||||||
|
var wire = new CreateObject.ServerMotionState(
|
||||||
|
Stance: 0x003D,
|
||||||
|
ForwardCommand: 0x0011);
|
||||||
|
|
||||||
|
SpawnMotionInitializer.Plan plan = SpawnMotionInitializer.ResolvePlan(table, wire);
|
||||||
|
|
||||||
|
Assert.Equal(0x8000003Du, plan.Style);
|
||||||
|
Assert.Equal(0x40000011u, plan.Motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x000B, 0x4000000Bu)]
|
||||||
|
[InlineData(0x000C, 0x4000000Cu)]
|
||||||
|
public void DoorUsesAuthoritativeOnOrOffMotionFromWire(
|
||||||
|
ushort forwardCommand,
|
||||||
|
uint expectedMotion)
|
||||||
|
{
|
||||||
|
var table = new MotionTable();
|
||||||
|
var wire = new CreateObject.ServerMotionState(
|
||||||
|
Stance: 0x003D,
|
||||||
|
ForwardCommand: forwardCommand);
|
||||||
|
|
||||||
|
SpawnMotionInitializer.Plan plan = SpawnMotionInitializer.ResolvePlan(table, wire);
|
||||||
|
|
||||||
|
Assert.Equal(0x8000003Du, plan.Style);
|
||||||
|
Assert.Equal(expectedMotion, plan.Motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingWireStateUsesMotionTableDefaults()
|
||||||
|
{
|
||||||
|
var table = new MotionTable
|
||||||
|
{
|
||||||
|
DefaultStyle = (DRWMotionCommand)0x8000003Eu,
|
||||||
|
};
|
||||||
|
|
||||||
|
SpawnMotionInitializer.Plan plan = SpawnMotionInitializer.ResolvePlan(table, null);
|
||||||
|
|
||||||
|
Assert.Equal(0x8000003Eu, plan.Style);
|
||||||
|
Assert.Equal(0x41000003u, plan.Motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -382,6 +382,60 @@ public sealed class AnimationSequencerTests
|
||||||
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpawnEnterWorld_DeadState_StripsReadyToDeadTransition()
|
||||||
|
{
|
||||||
|
// Retail spawn order:
|
||||||
|
// ACCObjectMaint::CreateObject -> CPhysicsObj::set_description
|
||||||
|
// (0x00558870 -> 0x00514F40) installs Ready then applies the wire's
|
||||||
|
// Dead state while the object is detached. SmartBox::HandleCreateObject
|
||||||
|
// then enters the object into the world (0x00454C80 -> 0x00516170),
|
||||||
|
// reaching CPartArray::HandleEnterWorld (0x00517D70) ->
|
||||||
|
// MotionTableManager::HandleEnterWorld (0x0051BDD0). That last call
|
||||||
|
// strips the Ready->Dead transition before the first rendered tick.
|
||||||
|
const uint Style = 0x8000003Du;
|
||||||
|
const uint ReadyMotion = 0x41000003u;
|
||||||
|
const uint DeadMotion = 0x40000011u;
|
||||||
|
const uint ReadyAnim = 0x03000022u;
|
||||||
|
const uint DeadAnim = 0x03000020u;
|
||||||
|
const uint FallAnim = 0x03000021u;
|
||||||
|
|
||||||
|
var setup = Fixtures.MakeSetup(1);
|
||||||
|
var mt = Fixtures.MakeMtable(
|
||||||
|
style: Style,
|
||||||
|
motion: DeadMotion,
|
||||||
|
cycleAnimId: DeadAnim,
|
||||||
|
fromMotion: ReadyMotion,
|
||||||
|
toMotion: DeadMotion,
|
||||||
|
linkAnimId: FallAnim);
|
||||||
|
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)ReadyMotion;
|
||||||
|
mt.Cycles[(int)((Style << 16) | (ReadyMotion & 0xFFFFFFu))] =
|
||||||
|
Fixtures.MakeMotionData(ReadyAnim, framerate: 30f);
|
||||||
|
|
||||||
|
var loader = new FakeLoader();
|
||||||
|
loader.Register(ReadyAnim,
|
||||||
|
Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity));
|
||||||
|
loader.Register(FallAnim,
|
||||||
|
Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity));
|
||||||
|
loader.Register(DeadAnim,
|
||||||
|
Fixtures.MakeAnim(1, 1, new Vector3(1, 0, 0), Quaternion.Identity));
|
||||||
|
|
||||||
|
var seq = new AnimationSequencer(setup, mt, loader);
|
||||||
|
seq.InitializeState();
|
||||||
|
seq.SetCycle(Style, DeadMotion);
|
||||||
|
|
||||||
|
var detachedPose = seq.Advance(0.001f);
|
||||||
|
Assert.True(detachedPose[0].Origin.Y > detachedPose[0].Origin.X,
|
||||||
|
"Before enter-world, the description-time Ready->Dead link must exist");
|
||||||
|
|
||||||
|
seq.Manager.HandleEnterWorld();
|
||||||
|
|
||||||
|
var enteredPose = seq.Advance(0.001f);
|
||||||
|
Assert.True(enteredPose[0].Origin.X > enteredPose[0].Origin.Y,
|
||||||
|
"Enter-world must start the corpse on the fallen Dead cycle");
|
||||||
|
Assert.Equal(1, seq.CurrentNodeDiag.QueueCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Advance_LinkTailDoesNotBlendIntoLinkFrame0()
|
public void Advance_LinkTailDoesNotBlendIntoLinkFrame0()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue