docs(B.4c): design spec for door swing animation

Phase B.4c closes #58 (filed during B.4b ship). When a door's
state flips via ACE Door.ActOnUse, the L.2g chain handles the
SetState collision-bit flip but no UpdateMotion handler ever
animated the door visually. Investigation traced the gap to the
spawn-time registration gate at GameWindow.cs:2692 which requires
a multi-frame idle cycle — doors have no idle.

Design: door-specific spawn-time branch that bypasses the gate,
builds an AnimationSequencer, seeds it with Off (closed) or On
(open) cycle based on spawn PhysicsState. ACE Door.cs:43 sets the
same initial state. ~40 LOC in one file. Reuses the existing
AnimationSequencer + per-frame tick + WB renderer pipeline. No
changes downstream.

Discovered during self-review that the per-frame tick at
GameWindow.cs:7691-7697 unconditionally overwrites ae.Entity.MeshRefs
with sequencer-derived transforms; an empty sequencer would collapse
the door to origin. The state-seeded SetCycle at spawn keeps the
sequencer always producing valid frames. Also documented:
ae.Animation = null is safe because the tick's sequencer branch at
line 7497 never reads it (only the legacy slerp else branch does).

Diagnostic tags renamed from phase-named [B.4c] to durable
[door-anim] / [door-cycle] per Opus reviewer feedback on B.4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-14 06:26:57 +02:00
parent 3e08e109d6
commit b4f131e5c6

View file

@ -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<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.