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

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