Merge branch 'claude/phase-b4c-door-anim' — Phase B.4c door swing animation

Phase B.4c shipped end-to-end with 4 implementation commits + 4 docs commits.
M1 demo target 'open the inn door' now has full visual feedback at Holtburg.

Code:
- IsDoorSpawn / IsDoorName helpers + Door spawn-time AnimationSequencer
  registration with state-seeded initial cycle (Off/On from spawn PhysicsState
  ETHEREAL bit)
- [door-anim] registration diagnostic + [door-cycle] UM dispatch diagnostic
  (both gated on ACDREAM_PROBE_BUILDING)
- Stance-value fix: NonCombat is 0x3D not 0x01; cycle key is 0x8000003D
  not 0x80000001. Without the fix, HasCycle always returned false and
  doors collapsed to entity origin (halfway underground).
- Refactor: shared IsDoorName(string?) predicate eliminates the open-coded
  duplicate name check; durable subsystem-named comment.

Closes #58. Files #61 (link->cycle flash, polish) + #62 (PARTSDIAG null-guard,
latent). Final whole-branch code review (Opus) approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-14 14:15:54 +02:00
commit e7842e0f1e
7 changed files with 1506 additions and 40 deletions

View file

@ -619,17 +619,22 @@ acdream's plan lives in two files committed to the repo:
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
**Phase B.4b** all shipped and visual-verified 2026-05-13. The M1 demo
target *"open the inn door"* is met: double-click a door in the Holtburg
inn doorway → `WorldPicker.Pick` finds the door entity → `BuildUse` sends
`0xF7B1/0x0036` to ACE → ACE broadcasts `SetState (0xF74B)` with `ETHEREAL`
bit → `ShadowObjectRegistry.UpdatePhysicsState` (L.2g slice 1) mutates the
cached state (via fixed ServerGuid→entity.Id translation, L.2g slice 1c) →
`CollisionExemption.ShouldSkip` exempts on ETHEREAL-alone (L.2g slice 1b) →
player walks through. Issue #57 (B.4 handler gap) is closed. Issue #58
(door swing animation — `UpdateMotion 0xF74D` routing for non-creature
entities) is filed as M1-deferred polish.
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13.
The M1 demo target *"open the inn door"* is met **with full visual feedback**:
double-click a door in the Holtburg inn doorway → `WorldPicker.Pick` finds
the door entity → `BuildUse` sends `0xF7B1/0x0036` to ACE → ACE broadcasts
`SetState (0xF74B)` with `ETHEREAL` bit → `ShadowObjectRegistry.UpdatePhysicsState`
(L.2g slice 1) mutates the cached state (via fixed ServerGuid→entity.Id
translation, L.2g slice 1c) → `CollisionExemption.ShouldSkip` exempts on
ETHEREAL-alone (L.2g slice 1b) → player walks through → door swing animation
plays (B.4c: spawn-time `AnimationSequencer` registration + `OnLiveMotionUpdated`
routing for door entities). Issue #57 (B.4 handler gap) is closed. Issue #58
(door swing animation) is closed by B.4c. Issues #61 (link→cycle boundary
flash) and #62 (PARTSDIAG null-guard) are filed as M1-deferred polish.
**B.4c ship handoff:** [`docs/research/2026-05-13-b4c-shipped-handoff.md`](docs/research/2026-05-13-b4c-shipped-handoff.md)
— full evidence for the 4 commits + 2 bonus discoveries (stance-value wrong
`0x01` vs `0x3D` causing underground doors; link→cycle boundary flash).
**B.4b ship handoff:** [`docs/research/2026-05-13-b4b-shipped-handoff.md`](docs/research/2026-05-13-b4b-shipped-handoff.md)
— full evidence for the 9 commits + 4 bonus discoveries (double-click dead
code, DoubleClick gate, CollisionExemption, ServerGuid→Id translation).
@ -712,12 +717,19 @@ together comprise the streaming + rendering perf foundation for the
project.
**Next phase candidates (in rough preference order):**
- **Issue #58 — Door swing animation.** Route `UpdateMotion (0xF74D)` to
non-creature entities so the door visually swings when opened. M1 polish
but not blocking. Scope unknown until a spike: could be 30 min (simple
routing) or 2 hrs (AnimationSequencer audit for creature-specific
assumptions). Start with a spike in `OnLiveMotionUpdated` to see how
far the AnimationSequencer cooperates with non-creature entities.
- **"Click an NPC" verification spike (M1 critical path).** B.4b's
`WorldPicker` + `BuildUse` is already wired. The question is whether ACE
NPCs respond to a Use message from our testaccount and what they broadcast
back (TalkDirect? MoveToObject?). Spike: stand near a Holtburg NPC,
double-click, read what ACE sends back. If ACE responds with recognizable
packets, wire the handlers; if it is silent, investigate ACE's NPC handler
configuration. ~30 min spike, outcome determines whether NPC interaction
needs a full phase or is a one-commit fix.
- **Phase B.5 — Ground item pickup (F key) (M1 critical path).** The
`SelectionPickUp` input action + F-key binding exist in `KeyBindings` but
`OnInputAction` has no case for it. `BuildUse` IS `BuildPickUp` (same wire
format). One-commit addition: add `SelectionPickUp` case to `GameWindow.
OnInputAction` → call `InteractRequests.BuildPickUp(seq, _selectedGuid)`. ~30 min.
- **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #41 (remote-motion blips) have been open since April/early-May and
@ -732,6 +744,12 @@ project.
only if user wants sustained 500+ FPS. With Tier 1 dispatcher at ~1.2 ms
the project comfortably hits 200-400 FPS at radius=12 standstill;
escalation is optional from here.
- **Issue #61 — AnimationSequencer link→cycle boundary flash** (M1-deferred
polish). Brief flap at end of door-swing animations. Low severity; does
not block M1 demo. Address before milestone demo record if distracting.
- **Issue #62 — PARTSDIAG null-guard** (trivial latent fix). One-line
null-coalescing guard in `GameWindow.TickAnimations`. Address any time a
diagnostic-related PR is open nearby.
**Earlier rendering + streaming arc (2026-05-08 → 2026-05-10).**
Phases **N.4 → N.5 → N.5b → A.5** shipped the modern rendering

View file

@ -46,6 +46,77 @@ Copy this block when adding a new issue:
# Active issues
## #62 — PARTSDIAG null-guard for sequencer-driven entities
**Status:** OPEN
**Severity:** LOW (latent crash; not reachable for doors today — see notes)
**Filed:** 2026-05-13 (code-quality review of B.4c Task 1)
**Component:** diagnostic / `GameWindow.TickAnimations` PARTSDIAG block
**Description:** The PARTSDIAG block at `GameWindow.cs:7657` reads
`ae.Animation.PartFrames.Count` without a null-guard. B.4c introduced
`Animation = null!` for sequencer-driven door entities (per the same
pattern at line 7857). Today this is safe: doors never enter
`_remoteDeadReckon` (ACE never sends UpdatePosition for them), and
`_remoteDeadReckon` membership is one of the outer guards on the
PARTSDIAG block. The diagnostic never fires for doors.
**Risk:** Future code that admits more non-creature entities via the
B.4c branch — or extends ACE to send UpdatePosition for doors — would
make `_remoteDeadReckon` membership reachable for null-Animation
entities. The next time someone enables `ACDREAM_REMOTE_VEL_DIAG=1`
and that scenario occurs, the diagnostic crashes the tick.
**Acceptance:** PARTSDIAG block tolerates null `ae.Animation`. One-line
fix:
```csharp
int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
? ae.Animation.PartFrames[0].Frames.Count
: -1;
```
**Files:** `src/AcDream.App/Rendering/GameWindow.cs:7657` (one-line null-coalescing change).
**Estimated scope:** Trivial. One-line edit + a build verification.
---
## #61 — AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
**Status:** OPEN
**Severity:** LOW (visual polish — animation works, brief one-frame flash through prior pose at end of swing)
**Filed:** 2026-05-13 (visual test of B.4c)
**Component:** animation / `AcDream.Core.Physics.AnimationSequencer` link+cycle transition
**Description:** When a door receives `UpdateMotion(NonCombat, On)` via the
B.4c spawn-time-registered sequencer, the swing-open animation plays
correctly but exhibits a brief one-frame flash through the closed pose
at the END of the swing before settling at the open pose. Same flash on
close (settles at closed pose after one-frame flash through open).
**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a
transition link (the swing motion) followed by the target cycle (likely
a single-frame static rest pose). If the link's last frame and the
cycle's frame 0 don't match exactly, the renderer reads one frame of
the cycle's start pose before the cycle's natural rest. Cumulative
effect: link plays Closed→Open over N frames → cycle's frame 0 is
Closed → cycle resets to frame 0 for one render → cycle advances to
its single rest frame which IS the open pose. Visible as a flap.
**Acceptance:** Door open / close cycles play cleanly with no closed/open
pose flash at the link→cycle transition. Test: in Holtburg, double-click
inn door, watch swing animation rest at open pose with no intermediate flash.
**Files (likely):**
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — link+cycle queue boundary handling
- (read the link node's last-frame extraction + the cycle's frame-0 evaluation)
**Estimated scope:** Moderate. Requires understanding the sequencer's link-vs-cycle queue semantics and possibly the underlying MotionTable's cycle data shape for doors. Could be a one-line fix (e.g. "preserve last link frame as cycle rest pose") or a deeper sequencer behavior change.
**Workaround:** None needed for M1 — the flash is brief enough that doors are usable.
---
## #60`obstruction_ethereal` retail downstream path not ported (M2 combat-HUD impact)
**Status:** OPEN
@ -98,38 +169,41 @@ the 6 existing picker tests with realistic radii.
---
## #58 — Door swing animation: UpdateMotion not wired for non-creature entities
## #58 [DONE 2026-05-13] Door swing animation: UpdateMotion not wired for non-creature entities
**Status:** OPEN
**Severity:** MEDIUM (M1 demo cosmetic — doors function but don't visually animate)
**Status:** DONE
**Closed:** 2026-05-13
**Severity:** MEDIUM (was M1 demo cosmetic — doors functioned but didn't visually animate)
**Filed:** 2026-05-13
**Component:** animation / `UpdateMotion (0xF74D)` routing for non-creature entities
**Description:** B.4b shipped end-to-end interaction (click → BuildUse →
SetState → collision exempt → walk through). When ACE opens a door it
broadcasts TWO packets: `SetState (0xF74B)` (the collision-bit flip,
handled by L.2g) AND `UpdateMotion (0xF74D)` with `(NonCombat, On)` (the
swing animation cycle, NOT handled). acdream's `UpdateMotion` pipeline is
currently scoped to player + creature animation (Phase L.3); door entities
do not receive cycle commands.
**Closure:** Closed by Phase B.4c on branch `claude/phase-b4c-door-anim`
(4 implementation commits). The complete animation round-trip for door entities
is now wired and visual-verified at the Holtburg inn doorway: double-click a
closed door → swing-open animation plays → player walks through → ~30s later
ACE broadcasts `UpdateMotion (NonCombat, Off)` → swing-close animation plays.
**Root cause / status:** The `UpdateMotion` packet handler in
`GameWindow.OnLiveMotionUpdated` filters to player + creature entity types.
Non-creature WorldEntity instances (doors, chests, etc.) silently drop
the `(NonCombat, On)` cycle command that ACE sends when the door opens.
Implementation: spawn-time `AnimationSequencer` registration for door entities
in `GameWindow.OnLiveEntitySpawnedLocked` (Task 1, commit `9053860`), with
initial state seeded from `spawn.PhysicsState` so closed doors initialize to
the `Off` cycle and open doors initialize to the `On` cycle. A `[door-cycle]`
diagnostic line in `OnLiveMotionUpdated` (Task 2, commit `b89f004`) confirms
each `UpdateMotion` is processed. A shared `IsDoorName` predicate (Task 2
review, commit `8a9b15e`) eliminates duplication. A stance-value fix (bonus,
commit `454d88e`) corrected `NonCombat = 0x3D` (not `0x01`), which was causing
doors to render halfway underground due to empty sequencer frames.
**Files (likely):**
- `src/AcDream.App/Rendering/GameWindow.cs``OnLiveMotionUpdated` handler
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — may have creature-specific assumptions
- The entity-spawn adapter (unknown if non-creature entities are wired to an AnimationSequencer at all)
Two follow-up items were filed: issue #61 (link→cycle boundary flash — brief
visual flap at end of swing animation; low severity) and issue #62 (PARTSDIAG
null-guard for sequencer-driven entities; latent, not currently reachable).
**Acceptance:** Double-click a door → swing animation plays → ~30s later the
door auto-close animation plays. Log shows `UpdateMotion (NonCombat, On)` processed
for the door entity.
See [`docs/research/2026-05-13-b4c-shipped-handoff.md`](research/2026-05-13-b4c-shipped-handoff.md)
for the full evidence trail, log output, and bonus-discovery narrative. M1
demo target "open the inn door" now has full visual feedback.
**Estimated scope:** Unknown. Could be quick (route UpdateMotion to non-creature
WorldEntity with cycle dispatch, ~30 min) or moderate (AnimationSequencer audit
for creature-specific assumptions, ~2 hrs). Spike before committing to estimate.
**Files (what shipped):**
- `src/AcDream.App/Rendering/GameWindow.cs``IsDoorSpawn` / `IsDoorName` helpers, spawn-time `AnimationSequencer` registration branch in `OnLiveEntitySpawnedLocked`, `_doorSequencers` dict, `[door-cycle]` diagnostic in `OnLiveMotionUpdated`, `TickAnimations` loop extended to advance door sequencers.
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — no changes required; existing link+cycle API was sufficient.
---

View file

@ -66,6 +66,7 @@
| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 3050× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ |
| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity``OnCreate`, `RemoveEntityByServerGuid``OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) |
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number:

View file

@ -0,0 +1,346 @@
# Phase B.4c shipped — handoff (visual-verified 2026-05-13)
**Date:** 2026-05-13.
**Branch:** `claude/phase-b4c-door-anim` (ready to merge to main; do NOT merge here — controller handles that after code review).
**Predecessors:**
- [docs/research/2026-05-13-b4b-shipped-handoff.md](2026-05-13-b4b-shipped-handoff.md) — B.4b shipped handoff; interaction was the upstream dependency (Use message, SetState handling, collision exemption, double-click detection — all shipped there).
- [docs/superpowers/specs/2026-05-13-phase-b4c-design.md](../superpowers/specs/2026-05-13-phase-b4c-design.md) — B.4c design spec.
- [docs/superpowers/plans/2026-05-13-phase-b4c-plan.md](../superpowers/plans/2026-05-13-phase-b4c-plan.md) — B.4c implementation plan (4 tasks).
---
## TL;DR
Phase B.4c **shipped end-to-end and is visual-verified 2026-05-13.** The M1
demo target *"open the inn door"* now has **full visual feedback** — the door
swings open when double-clicked and swings closed again when ACE toggles it
back. 4 implementation commits implement and fix door-specific spawn-time
`AnimationSequencer` registration + `UpdateMotion` routing + stance-value
correctness.
The plan estimated "2 tasks, door spawn-time registration + UM diagnostic."
Visual testing surfaced **two bonus discoveries** beyond the plan:
1. The plan's `NonCombatStance` constant was wrong: `0x80000001` (from
creature motion table conventions) should be `0x8000003D` (from AC's
`MotionStance.NonCombat = 0x0000003D`). Wrong constant → wrong
`HasCycle` lookup → `SetCycle` never fires → sequencer empty →
per-frame part rebuild collapses to entity origin → doors render halfway
underground.
2. The `AnimationSequencer`'s link→cycle boundary transition produces a
brief one-frame flash through the prior pose at the end of the door-swing
animation. Not B.4c-specific — it is the sequencer's general link+cycle
queue mechanics. Deferred as issue #61.
Issue #58 (door swing animation) is closed. Issues #61 + #62 (cycle-boundary
flash; PARTSDIAG null-guard) are filed as M1-deferred polish.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `9053860` | `feat(B.4c): door spawn-time AnimationSequencer with state-seeded initial cycle` | Task 1 |
| 2 | `b89f004` | `feat(B.4c): [door-cycle] diagnostic in OnLiveMotionUpdated` | Task 2 |
| 3 | `8a9b15e` | `refactor(B.4c): share IsDoorName predicate + durable comment + use UM locals` | Task 2 review |
| 4 | `454d88e` | `fix(B.4c): correct NonCombat stance value (0x3D, not 0x01) + read spawn.MotionState` | Bonus: stance fix |
Plus plan/spec commits earlier in the branch session:
- `b4f131e` — B.4c design spec.
- `6ae38f7` — B.4c implementation plan (4 tasks).
**Build:** clean. **Tests:** existing test suite passes; no new unit tests added
(the door-cycle registration path runs in-process with a live GameWindow; pure
unit tests would require a MotionTable + AnimationSequencer integration harness).
---
## What the code does end-to-end
When the world loads, any entity whose name contains "Door" (checked via the
shared `GameWindow.IsDoorName(string)` helper, committed as part of Task 2
review) is registered in the **door-animation side-track** at spawn time. This
happens inside `GameWindow.OnLiveEntitySpawnedLocked`, which branches on
`IsDoorSpawn(spawn)` before reaching the standard creature/player paths.
### At world load (spawn time)
1. `IsDoorSpawn(spawn)` — delegates to `IsDoorName(spawn.Name)`, which
returns `name == "Door"`. Detection by server-sent name string only.
Cheap, exact, no WeenieType lookup. If a future ACE localizes "Door"
or sends a different name, those entities silently won't animate —
acceptable per B.4c's "doors only at English Holtburg" scope.
2. **Initial state seed** — the door's `PhysicsState` from `spawn` carries the
open/closed bit. The code reads `spawn.PhysicsState` (or
`spawn.MotionState?.Stance` as a fallback for unusual doors with explicit
stance data) to determine whether to seed the sequencer with the `Off`
(closed) or `On` (open) cycle.
3. **AnimationSequencer registration** — a fresh `AnimationSequencer` is
created for the door entity's `MotionTableId` (from `spawn`). Then:
```csharp
var style = 0x80000000u | (uint)MotionStance.NonCombat; // = 0x8000003D
var cycleCmd = isOpen ? MotionCommand.On : MotionCommand.Off;
sequencer.SetCycle(style, (uint)cycleCmd, speed: 0f);
```
The fully-initialized `AnimatedEntity` (with the seeded `Sequencer`) is
registered into the existing `_animatedEntities` dict keyed by `entity.Id`
— same dict that holds creatures and the player. `Animation = null!`
(the null-forgiving suppression matches an existing pattern at
`GameWindow.cs:7885` for sequencer-driven entities where the legacy
`Animation` field is unused). At the first per-frame `Advance(dt)`
call from `TickAnimations`, the sequencer produces the correct
rest-pose frames for the door's current state.
4. **Log evidence at spawn:**
```
[door-anim] registered guid=0x7A9B403A entityId=0x000F4291 mtable=0x09000202 initialStyle=0x8000003D initialCycle=0x4000000C
```
`0x4000000C` = `MotionCommand.Off` with the upper flag bits — the door is
closed at spawn, matching the initial world state.
### When the door opens (UpdateMotion arrives)
ACE broadcasts `UpdateMotion (0xF74D)` with `stance=0x003D` (NonCombat) and
wire `cmd=0x000C` (which `MotionCommandResolver.ReconstructFullCommand`
maps to full motion `0x4000000B` = `MotionCommand.On` = door open).
B.4c does NOT add a new dispatch path here — the existing
`OnLiveMotionUpdated` handler already routes via the `_animatedEntities`
dict + per-entity `Sequencer`, the same code path creatures use. The
only B.4c contribution at UM dispatch is the new `[door-cycle]`
diagnostic gated on `IsDoorName(doorInfo.Name)`. Before B.4c, doors
silently dropped at the `_animatedEntities.TryGetValue` check at
`GameWindow.cs:3036` because doors weren't registered; B.4c's Task 1
spawn-time branch fixed that.
The sequencer transitions from the `Off` cycle (static closed pose) through
the door-swing link animation to the `On` cycle (static open pose).
**Log evidence:**
```
UM guid=0x7A9B403A mt=0x00 stance=0x003D cmd=0x000C spd=0.00 | seq now style=0x8000003D motion=0x4000000B
[door-cycle] guid=0x7A9B403A stance=0x003D cmd=0x000C
```
The `[door-cycle]` line is the new B.4c diagnostic (gated on
`ACDREAM_PROBE_BUILDING=1`). The `seq now motion=0x4000000B` shows the
sequencer's current motion state after the `SetCycle` call.
### SetState chain (from B.4b + L.2g, unchanged)
Simultaneously with `UpdateMotion`, ACE also sends `SetState (0xF74B)`:
```
[setstate] guid=0x7A9B... state=0x0001000C
```
This is the B.4b / L.2g chain: `ShadowObjectRegistry.UpdatePhysicsState` flips
the door's cached state, `CollisionExemption.ShouldSkip` exempts on ETHEREAL-alone,
and the player can walk through. B.4c is additive — it only adds the animation
layer; it does not touch the collision path.
### When the door closes
ACE toggles on the next Use: `UpdateMotion` with `cmd=0x000B` (Off = close).
The sequencer transitions from the `On` cycle (open pose) through the door-swing
link animation (reversed) to the `Off` cycle (closed pose).
**Log evidence:**
```
UM guid=0x7A9B403A mt=... cmd=0x000B ... motion=0x4000000C
[door-cycle] guid=0x7A9B... cmd=0x000B
[setstate] guid=0x7A9B... state=0x00010008
```
### Per-frame mesh rebuild
The door sequencer integrates into `GameWindow.TickAnimations` via the same
`_animatedEntities` dict that holds creatures. Each frame, `ae.Sequencer.Advance(dt)`
is called and the resulting per-part transforms drive the same `MeshRefs` rebuild
that creature entities use (sequencer branch at `GameWindow.cs:7497`; doors
never enter the legacy slerp `else` branch). This is the reason the stance-value
bug produced underground doors: with the wrong style key (`0x80000001`)
`HasCycle` returned false, the sequencer was empty at spawn, `Advance` returned
identity frames, and the per-frame part-matrix rebuild received `Vector3.Zero /
Quaternion.Identity` for every part — collapsing them all to the entity origin.
---
## The two bonus discoveries
### 1. NonCombatStance constant was wrong: 0x01 vs 0x3D (`454d88e`) — THE render blocker
**Root cause:** The B.4c design spec specified the initial-cycle style key as:
```csharp
uint style = 0x80000000u | (uint)MotionStance.NonCombat; // spec said 0x80000001
```
The spec's comment was wrong. `MotionStance.NonCombat` in acdream (and retail)
is `0x0000003D`, not `0x00000001`. The value `0x01` is a creature-specific
variant. The style key for the door's cycle lookup must be `0x8000003D`.
With the wrong style key:
- `sequencer.HasCycle(0x80000001, MotionCommand.Off)` → false.
- `SetCycle(0x80000001, ...)` enqueued a cycle that was never reachable.
- On first `Advance(dt)`, the sequencer returned 0 part-frames.
- The per-frame mesh rebuild at `GameWindow.cs:7691` iterated 0 frames, leaving
every door part at the entity root origin (which is the door's structural
pivot, typically near the hinge). For inn doors this pivot is at roughly
floor level, so all the door's mesh parts collapsed to that single point,
rendering as a thin sliver partway underground.
**Fix:** Corrected the constant. Additionally, added a defensive read of
`spawn.MotionState?.Stance` as the source of the stance value where available,
so unusual doors with explicit motion state (possible in custom ACE content) use
their actual stance rather than the hardcoded NonCombat assumption:
```csharp
var stance = spawn.MotionState?.Stance ?? MotionStance.NonCombat;
uint style = 0x80000000u | (uint)stance;
```
**Verification:** After this fix, the `[door-anim]` log line showed
`initialStyle=0x8000003D` (correct), and doors appeared at the correct floor
level and height at world load.
### 2. AnimationSequencer link→cycle boundary flash (deferred as #61)
**Observed:** User reports "weird flapping at end of animation when it opens.
It is like it flaps back to closed quickly then open. Like really quickly."
Both open and close animations exhibit this flash.
**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a transition
link (the actual swing animation) followed by the target cycle (the door's
rest pose — likely a single-frame static "open" or "closed" pose). At the link→
cycle boundary, the sequencer evaluates the cycle's frame 0 before the cycle
settles into its natural rest position. If the link's last frame and the
cycle's frame 0 don't match exactly (which is common for one-shot door motions
versus the continuous idle cycles the sequencer was designed for), the renderer
sees one frame of the "wrong" pose at the link boundary.
**Why not B.4c-specific:** This is the sequencer's general link+cycle queue
boundary semantics. Any entity that uses a one-shot `SetCycle` transition
(rather than a continuous idle cycle) will exhibit this if the link/cycle
boundary frames diverge. The door case just makes it visible because the
swing duration is short (1-2 seconds) and the user is watching closely.
**Deferred:** Filed as issue #61. Workaround: the flash is brief (~1 frame,
~16ms at 60 FPS) and does not affect the door's usability. M1 is met without
this fix.
---
## Open notes / follow-ups
### #61 — AnimationSequencer link→cycle frame-0 flash (filed this session)
See Bonus discovery #2 above. Deferred as M1-deferred polish. Low severity.
Acceptance: door swing animations play cleanly with no intermediate closed/open
pose flash at the link→cycle transition.
### #62 — PARTSDIAG null-guard for sequencer-driven entities (filed this session)
The PARTSDIAG block at `GameWindow.cs:7657` reads `ae.Animation.PartFrames`
without a null-guard. B.4c introduced `Animation = null!` for sequencer-driven
door entities. Today this is safe (doors never enter `_remoteDeadReckon` because
ACE never sends UpdatePosition for them). Deferred as low-severity latent crash.
One-line fix when addressed.
### Chests, levers, traps
The `IsDoorName` / `IsDoorSpawn` predicate correctly gates on door entities only.
Other interactable non-creature entities (chests, levers, traps) will still
silently drop their `UpdateMotion` commands — they are not covered by B.4c and
no issue has been filed for them yet. When those animations become relevant
(M2/M3 inventory + dungeon content), the same spawn-time registration pattern
can be extended: broaden the detection predicate beyond `name == "Door"` and
register additional entity types in the existing `_animatedEntities` dict via
the same sibling branch.
### Door toggle behavior
Unchanged from B.4b. ACE doors toggle on each Use: first double-click opens,
subsequent double-click closes. Both transitions now play the correct swing
animation (open swing on open, close swing on close).
---
## Next session
**M1 demo progress as of this branch:**
- "Walk through Holtburg without getting stuck" — Phase L.2 in progress (outdoor collision works; `CBuildingObj` interior still deferred to L.2d).
- "Open the inn door" — **DONE with full visual feedback** (B.4b interaction + B.4c animation, this branch). Door swings open AND closed.
- "Click an NPC" — pick + Use wiring exists (from B.4b); depends on ACE NPC handler responding to Use correctly.
- "Pick up an item" — `BuildPickUp` + F-key wiring not yet in `OnInputAction`. Post-B.4b/B.4c deferred.
**Recommended next steps (in M1 critical-path order):**
1. **"Click an NPC" verification spike** — B.4b's WorldPicker + Use messaging
is already wired. The question is whether ACE NPCs respond to Use and what
they broadcast back. A quick spike: stand near an NPC in Holtburg,
double-click, check what ACE sends back. If ACE sends recognizable response
messages, wire them; if it is silent, investigate ACE's NPC handler
configuration for testaccount.
2. **Phase B.5 — Ground item pickup (F key)**`SelectionPickUp` input action
+ F-key binding exist but `OnInputAction` has no case. `BuildUse` is the
same wire format as `BuildPickUp`. Adding the `SelectionPickUp` case to
the switch and routing to `InteractRequests.BuildPickUp` is a one-commit
addition.
3. **Triage chronic open-issue list**#2 (lightning), #4 (sky horizon-glow),
#28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #41
(remote-motion blips) have been open since April/early-May. Link each to
a future phase or downgrade. ~1 hour.
4. **#61 fix (cycle-boundary flash)** — low-severity M1 polish. If the user
finds the flash distracting during the M1 demo record, address before
milestone wrap; otherwise defer to M2 animation quality pass.
---
## Reproducibility
Same launch recipe as B.4b. For reproducing the visual test:
```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_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
```
Walk to the Holtburg inn doorway. Watch the `[door-anim]` lines appear in the
log as each door entity spawns (verifies correct style=0x8000003D and initial
cycle). Double-left-click a closed door. Watch the swing animation. Walk
through. Wait ~30s (ACE auto-close). Watch the close animation.
After closing the client, grep for:
```powershell
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|setstate"
```
Expected:
- `[door-anim] registered guid=... initialStyle=0x8000003D initialCycle=0x4000000C` — correct style + Off initial cycle for each closed door.
- `[door-cycle] guid=... stance=0x003D cmd=0x000C` — open UpdateMotion processed.
- `[setstate] guid=... state=0x0001000C` — ACE collision-flip processed (from B.4b / L.2g).
- `[door-cycle] guid=... cmd=0x000B` — close UpdateMotion processed.
- `[setstate] guid=... state=0x00010008` — ACE close collision-flip processed.
---
## Worktree state at handoff
- Branch `claude/phase-b4c-door-anim`.
- 6 commits ahead of `3e08e10` (the B.4b+L.2g merge from this morning):
2 docs/spec/plan commits + 4 implementation commits.
- Controller should run a code review, then merge to main.
- Do NOT rebase or squash — each commit tells a diagnostic story that the
next phase's debugging may need.

View file

@ -0,0 +1,444 @@
# Phase B.4c — Door Swing Animation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make Holtburg's inn doors visibly swing open / closed when the player Uses them. Closes #58 and completes the M1 demo target *"open the inn door"* with full visual feedback.
**Architecture:** One block edit in `GameWindow.cs` adds a Door-specific spawn-time branch alongside the existing creature gate at line 2692. Detect Door entities by `spawn.Name == "Door"`. For each, build the same `AnimationSequencer` as creatures (load `MotionTable` from dats, construct sequencer) and immediately seed it with the `Off` cycle (closed) or `On` cycle (already open) based on the spawn's `PhysicsState` ETHEREAL bit. The existing `OnLiveMotionUpdated` handler then routes naturally — no downstream changes. Adds one diagnostic line in the UM handler for greppable verification. Spec: [`docs/superpowers/specs/2026-05-13-phase-b4c-design.md`](../specs/2026-05-13-phase-b4c-design.md).
**Tech Stack:** C# .NET 10 · existing `AnimationSequencer` + per-frame tick + WB renderer · no new dependencies.
---
## File map
| File | Op | Why |
|---|---|---|
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Add `IsDoorSpawn` static helper + Door registration branch (after the existing `idleCycle` gate at line 2692) + `[door-cycle]` diagnostic in `OnLiveMotionUpdated`. |
No new files. No unit tests added — GameWindow integration code is runtime-verified per the project's existing precedent (B.4b's switch cases, L.2g's MotionUpdated routing).
---
## Task 1 — Add `IsDoorSpawn` helper + Door registration branch
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
This task adds two things in one commit: the static helper that detects Door entities, and the new `else if` branch in the live-spawn handler that registers them with a seeded `AnimationSequencer`.
- [ ] **Step 1: Add the `IsDoorSpawn` helper**
Insert this static helper immediately above the live-spawn handler. Use the `Edit` tool with this exact anchor:
`old_string`:
```
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
```
`new_string`:
```
/// <summary>
/// Phase B.4c — door detection by server-sent name. Doors fail the
/// generic multi-frame-idle gate at line 2692 (no idle cycle), so we
/// register them via a sibling branch with a state-seeded sequencer.
/// </summary>
private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
=> spawn.Name == "Door";
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
```
- [ ] **Step 2: Add the Door registration branch**
Insert the new `else if` branch immediately after the existing `idleCycle` gate's closing brace (around line 2800). The anchor is the comment line that follows the gate. Use the `Edit` tool:
`old_string`:
```
}
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
```
`new_string`:
```
}
else if (IsDoorSpawn(spawn) && _animLoader is not null)
{
// Phase B.4c — Door swing animation. Doors fail the
// multi-frame-idle gate above (no idle cycle) but DO have a
// MotionTable with On/Off cycles that ACE drives via
// UpdateMotion. Register with a seeded sequencer so the
// per-frame tick has frames to advance from frame 1 (without
// the seed, Sequencer.Advance(dt) returns no frames and the
// MeshRefs rebuild at line 7691 collapses the door to origin).
//
// Initial cycle mirrors ACE's Door.cs:43
// (CurrentMotionState = motionClosed): Off when the door is
// closed at spawn, On when the spawn PhysicsState carries the
// ETHEREAL bit (door was already open in ACE's DB).
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);
const uint NonCombatStance = 0x80000001u;
const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
const uint EtherealPs = 0x4u;
uint spawnState = spawn.PhysicsState ?? 0u;
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
if (sequencer.HasCycle(NonCombatStance, initialCycle))
sequencer.SetCycle(NonCombatStance, initialCycle);
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
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}"));
}
}
}
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
```
- [ ] **Step 3: Build green**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: build succeeds, 0 errors. Any new warnings should be tied to the additions only.
If a name like `meshRefs`, `entity`, `setup`, or `scale` doesn't resolve in scope at the insertion point, STOP and report — these are variables that exist in the surrounding scope at line 2800 of `OnLiveEntitySpawnedLocked` (verified during spec authoring at line 2700-2784 reads). They should be in scope; if Edit landed in the wrong place, fix the anchor first.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: **1046 pass / 8 pre-existing-baseline fail** (same as main HEAD `3e08e10`). Any regression here means the new branch is touching unrelated code paths — investigate.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(B.4c): door spawn-time AnimationSequencer with state-seeded initial cycle
Adds IsDoorSpawn helper and a sibling branch to the live-spawn
handler's animation registration gate. Detects entities where
spawn.Name == "Door" and registers them in _animatedEntities with an
AnimationSequencer seeded from the spawn PhysicsState's ETHEREAL bit
(Off cycle if closed, On if already open).
Mirrors ACE Door.cs:43 (CurrentMotionState = motionClosed) so the
sequencer always has frames for the per-frame tick to advance from
the first render. Without the seed, Advance(dt) returns no frames and
the MeshRefs rebuild at line 7691 collapses the door to origin.
No changes to OnLiveMotionUpdated, AnimationSequencer, EntitySpawnAdapter,
or the per-frame tick. The tick's sequencer branch at line 7497 reads
ae.Sequencer.Advance(dt) and never touches ae.Animation in this path
(only the legacy slerp else branch at line 7644+ does).
[door-anim] registered diagnostic gated on ACDREAM_PROBE_BUILDING.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2 — Add `[door-cycle]` UM dispatch diagnostic
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Adds one diagnostic line in `OnLiveMotionUpdated` that fires whenever an `UpdateMotion` arrives for an entity named "Door". Greppable trail for verification of the open/close cycle dispatch.
- [ ] **Step 1: Locate the diagnostic insertion point**
Run (Grep tool):
```
pattern: ACDREAM_DUMP_MOTION.*== "1"
path: src/AcDream.App/Rendering/GameWindow.cs
output: content with -n
```
Expected: one match around line 3075 in the `OnLiveMotionUpdated` body. The `[door-cycle]` diagnostic goes immediately after this `ACDREAM_DUMP_MOTION` block so both diagnostics are grouped.
- [ ] **Step 2: Add the `[door-cycle]` diagnostic**
Use the `Edit` tool. The anchor is the closing brace + blank line + the next code section ("Wire server-echoed RunRate") which follows the `ACDREAM_DUMP_MOTION` block at line 3075-3087:
`old_string`:
```
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Wire server-echoed RunRate first — used for the player's own
```
`new_string`:
```
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Phase B.4c — durable per-Door UM dispatch trail for visual-test grep.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& _liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo)
&& doorInfo.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}"));
}
// Wire server-echoed RunRate first — used for the player's own
```
- [ ] **Step 3: Build green**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: build succeeds, 0 errors.
If the name `_liveEntityInfoByGuid` doesn't resolve, STOP and report. It exists in `GameWindow.cs` (verified during spec authoring; used elsewhere in `DescribeLiveEntity` around line 8758 of the B.4b-shipped tree).
If `doorInfo.Name` doesn't resolve, the field on the live-entity info struct may be named differently (e.g. `EntityName`). Use Grep to find the existing usage pattern and adjust.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: same **1046 pass / 8 pre-existing-baseline fail** from Task 1.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(B.4c): [door-cycle] diagnostic in OnLiveMotionUpdated
Logs one line per UpdateMotion arriving for an entity named "Door"
when ACDREAM_PROBE_BUILDING=1. Greppable trail for the B.4c visual
test: confirms the dispatcher hit the sequencer for door open / close.
Durable subsystem-named tag per the Opus reviewer's B.4b feedback
([B.4c] would rot after phase archival; [door-cycle] survives).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3 — Visual verification at Holtburg inn doorway
**This task is performed by the user.** The implementing agent kicks off the launch in background; the user observes the running client and reports the result.
- [ ] **Step 1: Kill any stale client + wait for ACE session cleanup**
Run via PowerShell:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
```
Per CLAUDE.md "Logout-before-reconnect": ACE keeps the last session alive briefly after disconnect. 20s is the empirical minimum from B.4b's debug session.
- [ ] **Step 2: Launch the client with probes enabled**
Run via Bash tool with `run_in_background: true`:
```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 --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
```
- [ ] **Step 3: User performs the scenario**
In the running client:
1. Wait ~8s for the player to spawn at Holtburg.
2. Walk to the inn doorway (south building, north-facing door).
3. Observe: door visually closed.
4. Double-left-click the door.
5. **Observe: door visibly swings open over a fraction of a second.**
6. Walk forward through the open doorway.
7. Wait ~30s in the inn.
8. **Observe: door visibly swings closed.**
9. Bump the closed door — confirm it blocks again.
10. Close the client window.
- [ ] **Step 4: Grep the log**
Run via PowerShell:
```powershell
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|UM guid=.*Door|setstate.*0x7A9B4015"
```
Expected matches (in approximate order):
- `[door-anim] registered guid=0x... mtable=0x... initialCycle=0x4000000C` (one per closed door at world load)
- (user double-clicks)
- `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000B ...` (existing UM dump for the open motion)
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000B` (NEW; cmd=On)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` (L.2g chain)
- ~30s gap
- `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000C ...`
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000C` (NEW; cmd=Off)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x00010008`
- [ ] **Step 5: Decide on follow-up based on observed behavior**
- **Animation plays + door rests at open pose for ~30s + animation plays again on close + rests at closed pose**: success. Proceed to Task 4.
- **Animation plays as a loop instead of one-shot** (door spins continuously): pivot to Approach C from the spec (bespoke `DoorAnimationState` outside the sequencer). Out of B.4c scope; revise the spec and file a slice 2.
- **No animation, but log shows the dispatch fired**: motion-table cycle resolution issue. Inspect `mtable.Cycles[(0x80000001 << 16) | 0x4000000B]` to see if the cycle is present. May need a different cycle key form.
- **`[door-anim] registered` never logs**: spawn-time branch isn't firing. Check `spawn.Name` actual value (might be localized or padded). Add a one-line `Console.WriteLine` of `spawn.Name` in the live-spawn handler to surface it, then revise `IsDoorSpawn` accordingly.
---
## Task 4 — Ship handoff + close #58 + roadmap/CLAUDE/memory updates
**Files (in-repo):**
- Create: `docs/research/2026-05-13-b4c-shipped-handoff.md`
- Modify: `docs/ISSUES.md` (close #58)
- Modify: `docs/plans/2026-04-11-roadmap.md` (add B.4c row to shipped table)
- Modify: `CLAUDE.md` ("Currently in Phase L.2" paragraph + Next phase candidates)
**File (outside repo):**
- Modify: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`
- [ ] **Step 1: Write the ship-handoff doc**
Create `docs/research/2026-05-13-b4c-shipped-handoff.md`. Model after `docs/research/2026-05-13-b4b-shipped-handoff.md` for structure: TL;DR / What shipped (commit table) / End-to-end flow with actual observed evidence / Open notes / Reproducibility / Worktree state.
Required content:
- TL;DR: B.4c shipped, M1 demo target "open the inn door" now has full visual feedback. ~50 LOC, 2 implementation commits + 1 docs commit.
- What shipped table (2 implementation commits from Tasks 1+2)
- Actual observed `[door-anim]` and `[door-cycle]` log lines from Task 3 step 4
- Worktree branch: `claude/phase-b4c-door-anim`, 4 commits ahead of `3e08e10` (the B.4b merge)
- [ ] **Step 2: Move #58 from Active to Recently Closed in `docs/ISSUES.md`**
Edit `docs/ISSUES.md`:
- Cut the `## #58 — Door swing animation` block from "Active issues".
- Paste under "Recently closed" with header changed to `## #58 — [DONE 2026-05-13] Door swing animation: ...`.
- Add `**Status:** DONE` and `**Closed:** 2026-05-13` lines.
- Add a one-paragraph closure summary describing the fix: Door-specific spawn-time branch + state-seeded SetCycle + UM diagnostic. Cite this PR's merge commit + the handoff doc.
- [ ] **Step 3: Update the roadmap's shipped table**
Edit `docs/plans/2026-04-11-roadmap.md`. Add a new row to the "shipped" table:
```
| 2026-05-13 | Phase B.4c — Door swing animation | <commit> | Closes #58. Door-specific spawn-time AnimationSequencer registration with state-seeded initial cycle. M1 demo target "open the inn door" now has full visual feedback. |
```
(Read the table first to match its column structure exactly — the B.4b row uses `Phase | What landed | Verification`; match that.)
- [ ] **Step 4: Update `CLAUDE.md` "Currently in Phase L.2" paragraph + Next phase candidates**
Edit `CLAUDE.md`:
- Update "Currently in Phase L.2" paragraph to reflect B.4c shipped + visual-verified 2026-05-13.
- Remove `#58 — Door swing animation` from the "Next phase candidates" list.
- Elevate the next candidate (currently #2 in the list: "Triage the chronic open-issue list") to #1, OR pick a different next-phase based on M1 critical-path-ness. The natural next step per CLAUDE.md's "work-order autonomy" rule is whichever progresses M1's remaining demo targets ("click an NPC", "pick up an item") — file a one-line note that these are the M1-critical-path follow-ups even though they aren't pre-specced phases.
- [ ] **Step 5: Update the memory file** (outside the repo, NOT git-tracked)
Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`:
- Append a "B.4c shipped 2026-05-13" entry to the components table:
- `Door swing animation` — exists now (`GameWindow.cs IsDoorSpawn + sibling spawn-time branch`)
- `[door-anim]` / `[door-cycle]` diagnostics — gated on `ACDREAM_PROBE_BUILDING`
- Note: animation routing is door-specific, not general non-creature support yet (chests/levers/traps still drop through the gate).
- [ ] **Step 6: Commit the in-repo docs**
```bash
git add docs/research/2026-05-13-b4c-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(B.4c): ship handoff + close #58 + roadmap/CLAUDE update
Phase B.4c shipped end-to-end 2026-05-13. Holtburg inn doorway
double-click verified: door visually swings open, player walks
through, door visually swings closed ~30s later.
2 implementation commits:
- B.4c Task 1: door spawn-time AnimationSequencer with state-seeded cycle
- B.4c Task 2: [door-cycle] diagnostic in OnLiveMotionUpdated
Closes #58. Memory file project_interaction_pipeline.md updated
outside the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(The memory file lives outside the repo — update it but don't include it in this commit.)
- [ ] **Step 7: Hand off to merge (controller does final review + merge)**
After this commit, hand off to the controller. The controller will:
1. Run the final whole-branch code review (Opus per CLAUDE.md "load-bearing quality review of a phase boundary").
2. Merge `claude/phase-b4c-door-anim``main` with `--no-ff`.
3. Verify tests on merged main.
4. Remove the worktree (best-effort; submodules may block per the B.4b finishing experience).
---
## Self-review against the spec
| Spec section | Plan task(s) | Coverage |
|---|---|---|
| §Architecture: sibling branch after creature gate at line 2692 | Task 1 step 2 | covered |
| §Architecture: state-seeded initial cycle from spawn.PhysicsState | Task 1 step 2 | covered |
| §Components: `IsDoorSpawn(spawn) => spawn.Name == "Door"` | Task 1 step 1 | covered |
| §Components: Door registration body (sequencer build + SetCycle + AnimatedEntity register) | Task 1 step 2 | covered |
| §Components: `[door-anim] registered` diagnostic on spawn | Task 1 step 2 | covered (inline in registration body) |
| §Components: `[door-cycle]` diagnostic in OnLiveMotionUpdated | Task 2 step 2 | covered |
| §Data flow: spawn → seeded cycle → UM dispatch → state flip → animation | Tasks 1+2 + L.2g (existing) | covered (L.2g pipeline is the upstream dependency) |
| §Error handling: door has no MotionTable | Task 1 step 2 (`if (mtableId != 0)` + inner `if (mtable is not null)`) | covered |
| §Error handling: MotionTable lacks On/Off cycle | Task 1 step 2 (`if (sequencer.HasCycle(...))` gate around SetCycle) | covered |
| §Error handling: `_animLoader` null | Task 1 step 2 (outer `&& _animLoader is not null`) | covered |
| §Error handling: spawn.Name != "Door" | (no code change — silent fallback, acceptable per spec) | covered by omission |
| §Testing: runtime visual verification at Holtburg | Task 3 | covered |
| §Testing: log grep | Task 3 step 4 | covered |
| §Acceptance: build + tests green | Tasks 1+2 steps 3-4 | covered |
| §Acceptance: ISSUES.md #58 → Recently closed | Task 4 step 2 | covered |
| §Acceptance: roadmap + CLAUDE.md update | Task 4 steps 3-4 | covered |
| §Non-goals: sound effects, dust, lighting, collision rotation, generalized non-creature support | (none — explicitly deferred) | covered by omission |
No placeholders. No "TBD." Every code step shows the actual code; every command step shows the exact command and expected output. Type names (`AnimationSequencer`, `AnimatedEntity`, `MotionTable`, `EntitySpawn`) match across tasks. Diagnostic tags (`[door-anim]`, `[door-cycle]`) consistent throughout.

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.

View file

@ -2127,6 +2127,18 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Door detection by server-sent name. Doors fail the generic
/// multi-frame-idle gate at line 2692 (no idle cycle), so we register
/// them via a sibling branch with a state-seeded sequencer. Shared
/// with the [door-cycle] UM dispatch diagnostic — both sites must
/// agree on the name predicate.
/// </summary>
private static bool IsDoorName(string? name) => name == "Door";
private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
=> IsDoorName(spawn.Name);
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
{
_liveSpawnReceived++;
@ -2798,6 +2810,76 @@ public sealed class GameWindow : IDisposable
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
else if (IsDoorSpawn(spawn) && _animLoader is not null)
{
// Phase B.4c — Door swing animation. Doors fail the
// multi-frame-idle gate above (no idle cycle) but DO have a
// MotionTable with On/Off cycles that ACE drives via
// UpdateMotion. Register with a seeded sequencer so the
// per-frame tick has frames to advance from frame 1 (without
// the seed, Sequencer.Advance(dt) returns no frames and the
// MeshRefs rebuild at line 7691 collapses the door to origin).
//
// Initial cycle mirrors ACE's Door.cs:43
// (CurrentMotionState = motionClosed): Off when the door is
// closed at spawn, On when the spawn PhysicsState carries the
// ETHEREAL bit (door was already open in ACE's DB).
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);
// Style key is `0x80000000 | stance`. ACE's MotionStance.NonCombat
// is 0x3D (61 decimal), NOT 0x01. Verified live: ACE broadcasts
// UpdateMotion with stance=0x003D and the sequencer keys cycles
// by style=0x8000003D. An earlier B.4c seed used the wrong
// 0x80000001 value, which made HasCycle always return false ->
// SetCycle never fired -> sequencer empty -> Advance returned
// no frames -> per-frame tick collapsed all door parts to the
// entity origin (visible as "door halfway in the ground").
const uint NonCombatStyle = 0x8000003Du;
const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
const uint EtherealPs = 0x4u;
// Prefer the spawn's wire-level stance if provided; else default
// to NonCombat. (Doors normally don't carry an initial MotionState
// on spawn — falling back to NonCombat matches ACE Door.cs:43.)
ushort spawnStance = spawn.MotionState?.Stance ?? 0;
uint initialStyle = spawnStance != 0
? (0x80000000u | (uint)spawnStance)
: NonCombatStyle;
uint spawnState = spawn.PhysicsState ?? 0u;
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
if (sequencer.HasCycle(initialStyle, initialCycle))
sequencer.SetCycle(initialStyle, initialCycle);
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
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} initialStyle=0x{initialStyle:X8} initialCycle=0x{initialCycle:X8}"));
}
}
}
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
@ -3086,6 +3168,15 @@ public sealed class GameWindow : IDisposable
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& _liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo)
&& IsDoorName(doorInfo.Name))
{
Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
}
// Wire server-echoed RunRate first — used for the player's own
// locomotion tuning regardless of whether a cycle resolves.
if (_playerController is not null