diff --git a/docs/superpowers/specs/2026-05-13-phase-b4c-design.md b/docs/superpowers/specs/2026-05-13-phase-b4c-design.md new file mode 100644 index 0000000..0e7d07b --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-phase-b4c-design.md @@ -0,0 +1,492 @@ +# 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.