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

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