diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 8e0fa7b..2b673bc 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2127,6 +2127,14 @@ public sealed class GameWindow : IDisposable
}
}
+ ///
+ /// Phase B.4c — door detection by server-sent name. Doors fail the
+ /// generic multi-frame-idle gate at line 2692 (no idle cycle), so we
+ /// register them via a sibling branch with a state-seeded sequencer.
+ ///
+ private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
+ => spawn.Name == "Door";
+
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
{
_liveSpawnReceived++;
@@ -2798,6 +2806,61 @@ public sealed class GameWindow : IDisposable
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
+ else if (IsDoorSpawn(spawn) && _animLoader is not null)
+ {
+ // Phase B.4c — Door swing animation. Doors fail the
+ // multi-frame-idle gate above (no idle cycle) but DO have a
+ // MotionTable with On/Off cycles that ACE drives via
+ // UpdateMotion. Register with a seeded sequencer so the
+ // per-frame tick has frames to advance from frame 1 (without
+ // the seed, Sequencer.Advance(dt) returns no frames and the
+ // MeshRefs rebuild at line 7691 collapses the door to origin).
+ //
+ // Initial cycle mirrors ACE's Door.cs:43
+ // (CurrentMotionState = motionClosed): Off when the door is
+ // closed at spawn, On when the spawn PhysicsState carries the
+ // ETHEREAL bit (door was already open in ACE's DB).
+ 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);
+
+ const uint NonCombatStance = 0x80000001u;
+ const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
+ const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
+ const uint EtherealPs = 0x4u;
+ uint spawnState = spawn.PhysicsState ?? 0u;
+ uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
+ if (sequencer.HasCycle(NonCombatStance, initialCycle))
+ sequencer.SetCycle(NonCombatStance, initialCycle);
+
+ var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count];
+ for (int i = 0; i < meshRefs.Count; i++)
+ template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
+
+ _animatedEntities[entity.Id] = new AnimatedEntity
+ {
+ Entity = entity,
+ Setup = setup,
+ Animation = null!, // sequencer-driven; tick reads sequencer state
+ LowFrame = 0,
+ HighFrame = 0,
+ Framerate = 0f,
+ Scale = scale,
+ PartTemplate = template,
+ CurrFrame = 0,
+ Sequencer = sequencer,
+ };
+
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialCycle=0x{initialCycle:X8}"));
+ }
+ }
+ }
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.