# 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<DatReaderWriter.DBObjs.MotionTable>(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<uint, uint>?)[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.
