# Phase B.4c — Door Swing Animation **Status:** Design spec, created 2026-05-13 evening after B.4b ship. **Branch:** `claude/phase-b4c-door-anim` (worktree `phase-b4c-door-anim`). **Predecessors:** - [docs/research/2026-05-13-b4b-shipped-handoff.md](../../research/2026-05-13-b4b-shipped-handoff.md) — B.4b shipped end-to-end interaction; door becomes ethereal + passable on Use, but doesn't visually swing. - [docs/ISSUES.md](../../ISSUES.md) #58 — door swing animation `UpdateMotion` routing for non-creature entities, filed during B.4b's Task 6. - [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](2026-05-12-l2g-dynamic-physicsstate-design.md) — L.2g spec's "Wire flow" section §1 documents that ACE's `Door.ActOnUse` broadcasts BOTH `EnqueueBroadcastMotion(motionOpen)` (this spec's target) AND `EnqueueBroadcastPhysicsState()` (handled by L.2g slice 1+1c). **Milestone:** M1 — Walkable + clickable world. Polish on the *"open the inn door"* demo target. The door is already passable post-B.4b; B.4c adds the visible swing animation that confirms the open/close state to the player. **Estimate:** ~30-50 LOC, 1 commit, ~2 hours implementation including visual verification. **Scope chosen 2026-05-13 (brainstorm):** doors only. Generalizing the spawn-time registration gate to admit all non-creature interactives (chests, levers, traps, statues) is filed separately as future work; the B.4c fix is door-specific, narrowly scoped to the M1 demo target. --- ## TL;DR ACE's `Door.ActOnUse` broadcasts two packets when the player Uses a door: 1. `UpdateMotion (~0xF74D)` with stance `NonCombat` and command `MotionOpen` — the swing-open animation cycle. 2. `SetState (0xF74B)` with `Ethereal` bit set — the collision-bit flip handled by L.2g slice 1 + 1c. acdream's `OnLiveMotionUpdated` handler at `GameWindow.cs:3019` early-outs at line 3023 when the entity isn't in `_animatedEntities`. Doors are **not registered** in `_animatedEntities` because the spawn-time gate at `GameWindow.cs:2692` requires `idleCycle != null && idleCycle.Framerate != 0f && idleCycle.HighFrame > idleCycle.LowFrame && idleCycle.Animation.PartFrames.Count > 1`. Doors don't have a multi-frame idle cycle (their natural state is the static closed pose), so they fail all four sub-checks and the registration silently drops. B.4c adds a Door-specific spawn-time branch that bypasses the multi-frame-idle gate. Door entities get a sequencer + `AnimatedEntity` registration so the existing UM handler routes naturally to them. No changes to `OnLiveMotionUpdated`, `AnimationSequencer`, `EntitySpawnAdapter`, or the per-frame animation tick — the rest of the chain already works generically over `(stance, command)` pairs. --- ## Why B.4c (and not "fix the registration gate generally") | Option | Verdict | |---|---| | **Generalize: relax the multi-frame-idle gate for all non-creature entities with a MotionTable** | Rejected (for B.4c). Closes the bug class for chests, levers, traps, statues in one shot — but every non-creature with a sequencer would tick every frame, even when nothing is animating. Bigger risk surface, slower visual-verification cycle. The retail-fidelity cost is also higher: we'd be admitting many entities into a path designed for creatures. | | **Door-specific lazy registration on first UpdateMotion** | Rejected. Avoids the spawn-time gate question but adds complexity to the hot UM handler; double-allocations possible if multiple UMs race. Net more code than the spawn-time fix, with worse locality. | | **Door-specific bespoke `DoorAnimationState` outside `AnimationSequencer`** | Rejected unless A fails. Cleaner conceptual separation but duplicates MotionTable cycle-key resolution + per-frame frame-tick logic. Worth pivoting to if approach A reveals that the sequencer drives doors poorly (loops a one-shot cycle, etc.). | | **Door-specific spawn-time gate bypass** | **Selected.** Smallest change, reuses everything. One block edit at `GameWindow.cs:2692`. If the sequencer doesn't drive doors well at runtime, falls back to the bespoke approach without losing existing work. | --- ## Problem evidence From the B.4b visual test 2026-05-13 (per the user-confirmed shipped handoff): double-click on the Holtburg inn door at server guid `0x7A9B4015` (entity Id `0x000F4245`) sends a `BuildUse`, ACE replies with both `UpdateMotion (NonCombat, On)` AND `SetState (state=0x0001000C = HasPhysicsBSP | Ethereal | ReportCollisions)`, the L.2g chain mutates the cached state, the door becomes passable. **No visible animation plays** — the door's mesh sits at its closed pose throughout the open window, then sits at the same closed pose throughout the closed window. Code path trace: - `WorldSession` parses inbound `0xF74D` → fires `MotionUpdated` event carrying `EntityMotionUpdate { Guid, MotionState }`. - `GameWindow.OnLiveMotionUpdated` (line 3019) handles the event: ```csharp if (_dats is null) return; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return; // ← door drops out HERE ``` - The entity IS in `_entitiesByServerGuid` (B.4b verified the picker hits it). It's NOT in `_animatedEntities` because the spawn-time registration gate at `GameWindow.cs:2692` requires: ```csharp if (idleCycle is not null && idleCycle.Framerate != 0f && idleCycle.HighFrame > idleCycle.LowFrame && idleCycle.Animation.PartFrames.Count > 1) ``` - Doors fail at least one of those sub-checks (likely `idleCycle is null` — doors don't have an idle in the conventional sense). The renderer continues to draw the door at its spawn-time MeshRefs (the closed pose) every frame because nothing in the chain rebuilds those MeshRefs without an `_animatedEntities` entry. --- ## Current acdream state | Component | State | |---|---| | `WorldSession` parses `0xF74D` UpdateMotion + fires `MotionUpdated` | shipped | | `GameWindow.OnLiveMotionUpdated` handles the event | shipped, generic over creatures | | `AnimationSequencer.SetCycle(style, motion, speedMod)` | shipped, generic over `(style, motion)` pairs | | Per-frame animation tick rebuilds `MeshRefs` from sequencer state | shipped | | Door entities registered in `_animatedEntities` at spawn | MISSING — fails gate at line 2692 | | Door's `Setup.DefaultMotionTable` resolved + sequencer built | conditional — only happens via the creature branch which doors fall through | --- ## Design ### Architecture One block-level edit to `GameWindow.cs`'s live-spawn animation registration. Around line 2692 (the existing creature gate), add a sibling branch that detects Door entities and registers them with a sequencer regardless of idle-cycle quality. ``` existing line 2681-2688: increment _liveAnimReject* counters existing line 2692-2788: if (idleCycle qualifies) { build sequencer + register } NEW after line 2788: else if (IsDoorSpawn(spawn) && setup has motion table) { build sequencer + register } ``` The new branch reuses the same sequencer construction pattern from lines 2704-2768 (load motion table, build sequencer). What's different: - No idle-cycle gating: doors don't have an idle cycle. - **Sequencer is seeded with an initial cycle derived from spawn `PhysicsState`.** ACE's `Door.cs:43` sets `CurrentMotionState = motionClosed` at construction; we mirror this — at spawn, if the door's spawn-time state has `ETHEREAL_PS (0x4)` set the door is "open" (initial cycle = `MotionCommand.On = 0x4000000B`), otherwise it's "closed" (initial cycle = `MotionCommand.Off = 0x4000000C`). Without this seed, the sequencer's `Advance(dt)` returns no frames, the per-frame MeshRefs rebuild at `GameWindow.cs:7691-7697` produces all-parts-at-origin transforms, and the door visually collapses. - The `AnimatedEntity` is registered with `Animation = null` — the per-frame tick at line 7497 branches into the sequencer path (`if (ae.Sequencer is not null)`), reads frames via `ae.Sequencer.Advance(dt)`, and never touches `ae.Animation` in the sequencer branch (verified by code reading: only the `else` legacy slerp branch at line 7644+ reads `ae.Animation.PartFrames`). The seed approach matches the existing creature-spawn pattern at lines 2714-2771 which also calls `sequencer.SetCycle(seqStyle, spawnCycle)` at spawn to put the sequencer in a known state. ### Components #### `IsDoorSpawn(spawn)` — Door detection helper ```csharp private static bool IsDoorSpawn(LiveSpawnRecord spawn) => spawn.Name == "Door"; ``` Detection by server-sent name string. Cheap, exact, no dependency on Setup ID enumeration. The string comes through `CreateObject` parsing already populated; verified live in B.4b log as `name="Door"` for the Holtburg inn doorway entities. If ACE ever localizes "Door" or sends a different name (e.g. "Iron Gate", "Portcullis"), those entities silently won't animate — that's the same fallback as today and is acceptable per the spec's "doors only" scope. Future generalization can replace the heuristic. #### Spawn-time door registration branch (new, ~40 LOC) Inserted after the existing `if (idleCycle is not null && idleCycle.Framerate != 0f && ...)` block. Body: ```csharp else if (IsDoorSpawn(spawn) && _animLoader is not null) { 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); // Seed initial cycle from spawn PhysicsState. ACE's Door.cs:43 // sets CurrentMotionState = motionClosed at construction; we // mirror the same convention so the per-frame tick has frames // to advance from frame 1, before any UpdateMotion arrives. // // ETHEREAL bit (0x4) set on the wire == door is open at spawn // (rare — happens when the door was already open in ACE's DB). const uint NonCombatStance = 0x80000001u; const uint MotionOn = 0x4000000Bu; // door open const uint MotionOff = 0x4000000Cu; // door closed const uint EtherealPs = 0x4u; uint spawnState = (uint)(spawn.PhysicsState ?? 0); uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff; if (sequencer.HasCycle(NonCombatStance, initialCycle)) sequencer.SetCycle(NonCombatStance, initialCycle); // Snapshot per-part identity (same as the creature branch). 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, not ae.Animation 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}")); } } } ``` The four constants (`NonCombatStance`, `MotionOn`, `MotionOff`, `EtherealPs`) are inline because they're touch-points for this phase only and acdream's `MotionInterpreter.cs` doesn't yet declare `On`/`Off`. If a follow-up phase broadens the registration to chests/levers/traps, lift them into a shared constants class. Same `_animLoader` and `_dats` already in scope. No new fields. No new file. Skips the `_liveAnimReject*` counters because doors aren't "rejected" — they're admitted via a sibling branch. #### Diagnostic on UM dispatch (small additive, ~5 LOC) Inside `OnLiveMotionUpdated`, gated on `PhysicsDiagnostics.ProbeBuildingEnabled` AND the entity is a Door, emit: ```csharp if (PhysicsDiagnostics.ProbeBuildingEnabled && _liveEntityInfoByGuid.TryGetValue(update.Guid, out var liveInfo) && liveInfo.Name == "Door") { Console.WriteLine(System.FormattableString.Invariant( $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{update.MotionState.Stance:X4} cmd=0x{(update.MotionState.ForwardCommand ?? 0u):X4}")); } ``` Inserted alongside the existing `[UM_RAW]` and `ACDREAM_DUMP_MOTION` diagnostics in the same handler. `_liveEntityInfoByGuid` already carries the server-sent name (used elsewhere in `DescribeLiveEntity` per the B.4b code). **Diagnostic tag choice.** Use `[door-anim]` (registration) and `[door-cycle]` (UM dispatch) rather than the phase-named `[B.4c]`. The Opus reviewer flagged phase-tagged diagnostics as rotting from B.4b's review — durable subsystem-named tags survive phase archival and grep cleanly long after B.4c is closed. ### Data flow ``` [Spawn] ACE CreateObject for inn door → live-spawn handler resolves setup, meshRefs, scale, spawn.PhysicsState → idleCycle resolves to null (doors have no idle cycle) → existing gate at line 2692 fails → _liveAnimRejectNoCycle++ → NEW gate: IsDoorSpawn(spawn) → true → mtableId = setup.DefaultMotionTable (door motion table id) → mtable loaded from dats → AnimationSequencer constructed → initialCycle = (spawnState & 0x4 /* ETHEREAL */) != 0 ? On (0x4000000B) : Off (0x4000000C) → sequencer.SetCycle(NonCombat 0x80000001, initialCycle) → _animatedEntities[entity.Id] = AnimatedEntity { Sequencer, Animation=null } → log [door-anim] registered guid=0x... initialCycle=0x... → per-frame tick advances the Off cycle, sequencer rests at last frame (closed pose) → renderer draws door at closed-pose transforms from sequencer [Player Use] B.4b chain: double-click → BuildUse → ACE Door.ActOnUse → ACE broadcasts UpdateMotion(NonCombat, On) where On = 0x4000000B → WorldSession parses → MotionUpdated event → OnLiveMotionUpdated: _entitiesByServerGuid lookup → entity (id=0x000F4245) _animatedEntities[entity.Id] → ae (with seeded sequencer) log [door-cycle] guid=0x... stance=0x0001 cmd=0x000B ae.Sequencer.SetCycle(0x80000001, 0x4000000B, 1f) → Sequencer transitions from Off cycle → On cycle (one-shot via motion-table link) → per-frame tick reads sequencer transforms → door's part transforms update → renderer rebuilds MeshRefs from updated transforms each frame → user sees door swinging open → cycle ends, sequencer rests at the open-pose final frame → renderer draws door at open pose → (parallel) ACE broadcasts SetState(0x0001000C) → L.2g chain → collision exempts [Auto-close 30s later] ACE broadcasts UpdateMotion(NonCombat, Off 0x4000000C) + SetState(0x00010008) → same UM path, sequencer transitions On → Off (close cycle) → cycle ends, sequencer rests at closed-pose final frame → renderer draws door at closed pose → (parallel) collision blocks again ``` ### Error handling - **Door has no MotionTable** (`setup.DefaultMotionTable == 0` AND `spawn.MotionTableId == null`): the new branch's inner `if (mtableId != 0)` fails. Door not registered. Same as today; no animation, no regression. Should not happen in practice — retail doors all have motion tables. - **MotionTable doesn't contain the requested `MotionOpen` cycle**: the existing `HasCycle` fallback at lines 2742-2768 walks through `RunForward → WalkForward → Ready`. For doors that's wrong (no Ready cycle). The NEW door branch doesn't run that fallback — it just doesn't call `SetCycle` at spawn. At runtime if `OnLiveMotionUpdated` calls `SetCycle(MotionOpen)` and the table doesn't have it, the sequencer's internal `HasCycle` check fails and the cycle is silently not played. The door stays at its current pose. Acceptable for B.4c — if Holtburg's doors are missing cycles in the dat, that's a dat-content issue not a client bug. - **`_animLoader` is null** (test / headless mode): the NEW branch's outer `_animLoader is not null` check skips registration. Door stays static. Tests don't exercise the live-spawn path anyway. - **`spawn.Name != "Door"` for an actual door** (ACE override, localization): door silently doesn't animate. M1 demo is at Holtburg English server; safe enough. Future generalization (e.g. detect by Setup ID 0x020019FF) is trivial if needed. - **UM arrives before spawn**: existing handler returns at line 3023 (`!_animatedEntities.TryGetValue → return`). No change needed. - **Sequencer plays the cycle as cyclic instead of one-shot**: if observed in visual test, file as a follow-up to investigate the motion-table cycle's flags. Pivot to bespoke `DoorAnimationState` (Approach C) only if the sequencer can't be coaxed into one-shot behavior. - **Sequencer with no current motion produces no frames → door collapses visually**: avoided by seeding the sequencer at spawn with the state-derived initial cycle (Off if closed, On if already open). Without this seed, the per-frame tick's MeshRefs rebuild at `GameWindow.cs:7691-7697` writes all-parts-at-origin transforms over the entity's spawn-time MeshRefs. - **`Animation = null` in the AnimatedEntity record breaks per-frame tick**: the sequencer branch at `GameWindow.cs:7497` reads frames via `ae.Sequencer.Advance(dt)` and never touches `ae.Animation`. The only Animation reads are in the legacy slerp `else` branch at line 7644+, reached only when `ae.Sequencer is null`. Safe by code reading. ### Testing **No new unit tests.** The change is GameWindow integration code, verified at runtime per the project's existing precedent (B.4b's switch cases, L.2g's MotionUpdated routing). **Runtime verification** at Holtburg inn doorway (same recipe as L.2g slice 1 + B.4b ship handoff): ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_DEVTOOLS = "1" $env:ACDREAM_PROBE_BUILDING = "1" $env:ACDREAM_DUMP_MOTION = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 | Tee-Object -FilePath "launch-b4c.log" ``` In-client: 1. Wait ~8s for spawn at Holtburg. 2. Walk to the inn doorway. 3. Confirm visual: door at closed pose. 4. Double-click the door. 5. Confirm visual: door swings open over a fraction of a second. 6. Walk through (already verified by L.2g + B.4b). 7. Wait ~30s in the inn. 8. Confirm visual: door swings closed. 9. Bump the closed door — confirm it blocks again (collision restored). Log grep: ```powershell Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|UM guid=.*Door|setstate.*0x7A9B4015" ``` Expected: - `[door-anim] registered guid=0x... initialCycle=0x4000000C` (one per closed door at world load) - `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000B` (existing UM dump on Use) - `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000B` (NEW; cmd=On) - `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` (L.2g chain) - ~30s gap - `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000C` (NEW; cmd=Off) - `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x00010008` (close) ### Slice plan This is one slice. No further sub-slicing. | Step | Files | LOC | Notes | |---|---|---|---| | 1. Add `IsDoorSpawn` helper | `GameWindow.cs` | ~3 | Static private | | 2. Add Door registration branch in spawn handler with state-seeded SetCycle | `GameWindow.cs` | ~40 | After existing creature gate; seeds Off/On from spawn.PhysicsState | | 3. Add `[door-cycle]` diagnostic in `OnLiveMotionUpdated` | `GameWindow.cs` | ~5 | Gated on probe + name check via `_liveEntityInfoByGuid` | | 4. `dotnet build` + `dotnet test` green | — | — | 1046 / 8 baseline expected | | 5. Visual test at Holtburg inn doorway | — | — | Manual (user) | | 6. Commit + ship handoff + close #58 + roadmap update | — | — | Same Task 6 pattern as B.4b | | 7. Merge to main | — | — | After final review | Total: ~38 LOC in one file. One implementation commit + one docs commit. ### Acceptance criteria - [ ] `dotnet build` green - [ ] `dotnet test` green (1046 / 8 pre-existing baseline unchanged) - [ ] At Holtburg, double-click on inn door: - [ ] Log shows `[door-anim] registered guid=... initialCycle=0x4000000C` for each closed door at world load - [ ] Log shows `[door-cycle] guid=... stance=0x0001 cmd=0x000B` after the user's double-click - [ ] Door visibly swings open - [ ] Player can walk through (already verified; should not regress) - [ ] Door visibly swings closed ~30s later - [ ] Log shows a second `[door-cycle] ... cmd=0x000C` for the close motion - [ ] Closed door blocks collision again (already verified; should not regress) - [ ] No visible regression in creature animations (NPCs in Holtburg still walk and emote correctly). - [ ] ISSUES.md #58 moved to Recently closed. - [ ] Roadmap "shipped" table updated. - [ ] CLAUDE.md "Currently in Phase L.2" paragraph updated to reflect B.4c shipped. ### Non-goals / explicitly deferred - **Generalize the registration gate** for chests, levers, traps, statues. File as `post-B.4c` if/when those entities show similar bugs. - **One-shot vs cyclic playback contract** in `AnimationSequencer`. We trust the door's motion-table flags to mark `MotionOpen` / `MotionClosed` as one-shot. If the sequencer loops them, we'll surface that and decide whether to fix the sequencer or pivot to Approach C. - **Sound effect on door open** — that's wired through a separate `SoundTable` path. ACE may or may not broadcast the sound. M1 polish beyond B.4c. - **Rotating the door's collision shape** to match the visual. The door becomes ETHEREAL (collision skipped) while open, so the cylinder's rotation doesn't matter. If a future phase ports retail's obstruction-ethereal path (issue #60), we may revisit. - **Door open/close sounds, dust particles, lighting changes** — all M1 polish or post-M1. ### Risks / open questions | Risk | Mitigation | |---|---| | **Per-frame tick requires non-null `Animation`** — the new branch sets `Animation = null` because the sequencer drives transforms, not the legacy animation pointer. If the tick crashes on null, the door registration crashes the renderer at spawn. | Verify during implementation. If the tick reads `Animation`, gate the tick on `ae.Sequencer != null && ae.Sequencer.CurrentMotion != 0` first. Inline fix during the same task. | | **Sequencer plays one-shot cycles as cyclic** — door swings open, then loops the swing animation forever instead of resting at the open pose. | Visual test catches this immediately. If observed, investigate motion-table flags or pivot to bespoke `DoorAnimationState`. | | **Multiple doors at same threshold (Holtburg has paired leaves per L.2d trace)** — opening one door's animation while the other is closed leaves an asymmetric visual. | Acceptable for B.4c. The player can double-click the second door to open both. If both doors are wired to the same Use target by ACE, both will animate from a single Use. Visual test reveals which. | | **Door's `setup.DefaultMotionTable` is 0** — relies on `spawn.MotionTableId` from CreateObject. If both are 0, no animation. | Defensive code path (the inner `if (mtableId != 0)` skips registration). Door stays static; collision still works. | | **Diagnostic log volume** — `[B.4c] door cycle` fires per UM, which is once per Use. Low volume. Not a concern. | — | --- ## Reproducibility Same as B.4b's launch recipe. The visual verification scenario reuses B.4b's "open the inn door" target. No new test character or server config needed. --- ## Worktree Branch: `claude/phase-b4c-door-anim`, worktree `.claude/worktrees/phase-b4c-door-anim`. Clean off main (commit `3e08e10` = the B.4b merge from this morning). After ship: merge to main, close #58, update CLAUDE.md + roadmap + memory, archive this spec + the implementation plan.