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 ## #203 — Unequipping armor freezes character and world animations — FIXED
**Status:** DONE — 2026-07-11 (this change) **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. - **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.

View file

@ -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:**

View file

@ -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

View file

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

View file

@ -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}"));
}
} }
} }
} }

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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()
{ {