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:
commit
e7842e0f1e
7 changed files with 1506 additions and 40 deletions
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
122
docs/ISSUES.md
122
docs/ISSUES.md
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 30–50× 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:
|
||||
|
|
|
|||
346
docs/research/2026-05-13-b4c-shipped-handoff.md
Normal file
346
docs/research/2026-05-13-b4c-shipped-handoff.md
Normal 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.
|
||||
444
docs/superpowers/plans/2026-05-13-phase-b4c-plan.md
Normal file
444
docs/superpowers/plans/2026-05-13-phase-b4c-plan.md
Normal 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.
|
||||
492
docs/superpowers/specs/2026-05-13-phase-b4c-design.md
Normal file
492
docs/superpowers/specs/2026-05-13-phase-b4c-design.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue