acdream/docs/superpowers/specs/2026-05-13-phase-b4c-design.md
Erik b4f131e5c6 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>
2026-05-14 06:26:57 +02:00

24 KiB

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:

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:
    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:
    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

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:

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:

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):

$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:

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.