docs(B.4c): design spec for door swing animation
Phase B.4c closes #58 (filed during B.4b ship). When a door's state flips via ACE Door.ActOnUse, the L.2g chain handles the SetState collision-bit flip but no UpdateMotion handler ever animated the door visually. Investigation traced the gap to the spawn-time registration gate at GameWindow.cs:2692 which requires a multi-frame idle cycle — doors have no idle. Design: door-specific spawn-time branch that bypasses the gate, builds an AnimationSequencer, seeds it with Off (closed) or On (open) cycle based on spawn PhysicsState. ACE Door.cs:43 sets the same initial state. ~40 LOC in one file. Reuses the existing AnimationSequencer + per-frame tick + WB renderer pipeline. No changes downstream. Discovered during self-review that the per-frame tick at GameWindow.cs:7691-7697 unconditionally overwrites ae.Entity.MeshRefs with sequencer-derived transforms; an empty sequencer would collapse the door to origin. The state-seeded SetCycle at spawn keeps the sequencer always producing valid frames. Also documented: ae.Animation = null is safe because the tick's sequencer branch at line 7497 never reads it (only the legacy slerp else branch does). Diagnostic tags renamed from phase-named [B.4c] to durable [door-anim] / [door-cycle] per Opus reviewer feedback on B.4b. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e08e109d6
commit
b4f131e5c6
1 changed files with 492 additions and 0 deletions
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue