diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b74926f9..45a12af2 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index b4f04e45..870fe6ee 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. - **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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 413a7320..3f876d09 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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:** diff --git a/docs/research/2026-07-12-death-and-auto-target-pseudocode.md b/docs/research/2026-07-12-death-and-auto-target-pseudocode.md index f1fcd8d9..b303175c 100644 --- a/docs/research/2026-07-12-death-and-auto-target-pseudocode.md +++ b/docs/research/2026-07-12-death-and-auto-target-pseudocode.md @@ -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 diff --git a/memory/project_animation_runtime.md b/memory/project_animation_runtime.md index 00bf091c..c20dee3b 100644 --- a/memory/project_animation_runtime.md +++ b/memory/project_animation_runtime.md @@ -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 animation and made animation-dependent door interaction appear frozen. Fixed as 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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dafd9546..1f70821e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3648,35 +3648,8 @@ public sealed class GameWindow : IDisposable var mtable = _dats.Get(mtableId); if (mtable is not null) { - sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); - uint seqStyle = stanceOverride is > 0 - ? (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); + sequencer = SpawnMotionInitializer.Create( + setup, mtable, _animLoader, spawn.MotionState); } } } @@ -3730,44 +3703,20 @@ public sealed class GameWindow : IDisposable // Sequencer.Advance(dt) returns no frames and the MeshRefs // rebuild at line 7691 collapses the entity to origin). // - // Initial cycle mirrors ACE's Door.cs:43 - // (CurrentMotionState = motionClosed): Off when closed at spawn, - // On when the spawn PhysicsState carries the ETHEREAL bit - // (already open in ACE's DB). + // #204: this path is not door-specific. Corpses also arrive here + // because Dead is a persistent static rest pose. The old rescue + // hard-coded Door On/Off from PhysicsState, discarded the wire's + // 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; if (mtableId != 0) { var mtable = _dats.Get(mtableId); if (mtable is not null) { - var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); - - // 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 sequencer = SpawnMotionInitializer.Create( + setup, mtable, _animLoader, spawn.MotionState); var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count]; for (int i = 0; i < meshRefs.Count; i++) @@ -3788,8 +3737,11 @@ public sealed class GameWindow : IDisposable }; if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + { + var initial = SpawnMotionInitializer.ResolvePlan(mtable, spawn.MotionState); 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}")); + } } } } diff --git a/src/AcDream.App/Rendering/SpawnMotionInitializer.cs b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs new file mode 100644 index 00000000..05df260b --- /dev/null +++ b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs @@ -0,0 +1,63 @@ +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Builds the initial motion-table state for a server CreateObject using the +/// same description-then-enter-world lifecycle as retail. +/// +/// +/// Retail oracle: ACCObjectMaint::CreateObject (0x00558870) calls +/// CPhysicsObj::set_description (0x00514F40), which applies the wire +/// MovementData. SmartBox::HandleCreateObject (0x00454C80) then calls +/// CPhysicsObj::enter_world (0x00516170), reaching +/// CPartArray::HandleEnterWorld (0x00517D70) and +/// MotionTableManager::HandleEnterWorld (0x0051BDD0). +/// +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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/SpawnMotionInitializerTests.cs b/tests/AcDream.App.Tests/Rendering/SpawnMotionInitializerTests.cs new file mode 100644 index 00000000..cb4720dd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/SpawnMotionInitializerTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index ed63ef8e..0d3af817 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -382,6 +382,60 @@ public sealed class AnimationSequencerTests $"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] public void Advance_LinkTailDoesNotBlendIntoLinkFrame0() {