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>
24 KiB
Phase B.4c — Door Swing Animation
Status: Design spec, created 2026-05-13 evening after B.4b ship.
Branch: claude/phase-b4c-door-anim (worktree phase-b4c-door-anim).
Predecessors:
- docs/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 #58 — door swing animation
UpdateMotionrouting for non-creature entities, filed during B.4b's Task 6. - docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md
— L.2g spec's "Wire flow" section §1 documents that ACE's
Door.ActOnUsebroadcasts BOTHEnqueueBroadcastMotion(motionOpen)(this spec's target) ANDEnqueueBroadcastPhysicsState()(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:
UpdateMotion (~0xF74D)with stanceNonCombatand commandMotionOpen— the swing-open animation cycle.SetState (0xF74B)withEtherealbit 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:
WorldSessionparses inbound0xF74D→ firesMotionUpdatedevent carryingEntityMotionUpdate { Guid, MotionState }.GameWindow.OnLiveMotionUpdated(line 3019) handles the event:if (_dats is null) return; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return; // ← door drops out HERE- The entity IS in
_entitiesByServerGuid(B.4b verified the picker hits it). It's NOT in_animatedEntitiesbecause the spawn-time registration gate atGameWindow.cs:2692requires: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'sDoor.cs:43setsCurrentMotionState = motionClosedat construction; we mirror this — at spawn, if the door's spawn-time state hasETHEREAL_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'sAdvance(dt)returns no frames, the per-frame MeshRefs rebuild atGameWindow.cs:7691-7697produces all-parts-at-origin transforms, and the door visually collapses. - The
AnimatedEntityis registered withAnimation = null— the per-frame tick at line 7497 branches into the sequencer path (if (ae.Sequencer is not null)), reads frames viaae.Sequencer.Advance(dt), and never touchesae.Animationin the sequencer branch (verified by code reading: only theelselegacy slerp branch at line 7644+ readsae.Animation.PartFrames).
The seed approach matches the existing creature-spawn pattern at
lines 2714-2771 which also calls sequencer.SetCycle(seqStyle, spawnCycle) at spawn to put the sequencer in a known state.
Components
IsDoorSpawn(spawn) — Door detection helper
private static bool IsDoorSpawn(LiveSpawnRecord spawn)
=> spawn.Name == "Door";
Detection by server-sent name string. Cheap, exact, no dependency on
Setup ID enumeration. The string comes through CreateObject parsing
already populated; verified live in B.4b log as name="Door" for the
Holtburg inn doorway entities.
If ACE ever localizes "Door" or sends a different name (e.g. "Iron Gate", "Portcullis"), those entities silently won't animate — that's the same fallback as today and is acceptable per the spec's "doors only" scope. Future generalization can replace the heuristic.
Spawn-time door registration branch (new, ~40 LOC)
Inserted after the existing if (idleCycle is not null && idleCycle.Framerate != 0f && ...) block. Body:
else if (IsDoorSpawn(spawn) && _animLoader is not null)
{
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
{
var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
// Seed initial cycle from spawn PhysicsState. ACE's Door.cs:43
// sets CurrentMotionState = motionClosed at construction; we
// mirror the same convention so the per-frame tick has frames
// to advance from frame 1, before any UpdateMotion arrives.
//
// ETHEREAL bit (0x4) set on the wire == door is open at spawn
// (rare — happens when the door was already open in ACE's DB).
const uint NonCombatStance = 0x80000001u;
const uint MotionOn = 0x4000000Bu; // door open
const uint MotionOff = 0x4000000Cu; // door closed
const uint EtherealPs = 0x4u;
uint spawnState = (uint)(spawn.PhysicsState ?? 0);
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
if (sequencer.HasCycle(NonCombatStance, initialCycle))
sequencer.SetCycle(NonCombatStance, initialCycle);
// Snapshot per-part identity (same as the creature branch).
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = null, // sequencer-driven; tick reads sequencer state, not ae.Animation
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = scale,
PartTemplate = template,
CurrFrame = 0,
Sequencer = sequencer,
};
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialCycle=0x{initialCycle:X8}"));
}
}
}
The four constants (NonCombatStance, MotionOn, MotionOff, EtherealPs)
are inline because they're touch-points for this phase only and acdream's
MotionInterpreter.cs doesn't yet declare On/Off. If a follow-up phase
broadens the registration to chests/levers/traps, lift them into a shared
constants class.
Same _animLoader and _dats already in scope. No new fields. No new
file. Skips the _liveAnimReject* counters because doors aren't
"rejected" — they're admitted via a sibling branch.
Diagnostic on UM dispatch (small additive, ~5 LOC)
Inside OnLiveMotionUpdated, gated on
PhysicsDiagnostics.ProbeBuildingEnabled AND the entity is a Door,
emit:
if (PhysicsDiagnostics.ProbeBuildingEnabled
&& _liveEntityInfoByGuid.TryGetValue(update.Guid, out var liveInfo)
&& liveInfo.Name == "Door")
{
Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{update.MotionState.Stance:X4} cmd=0x{(update.MotionState.ForwardCommand ?? 0u):X4}"));
}
Inserted alongside the existing [UM_RAW] and ACDREAM_DUMP_MOTION
diagnostics in the same handler. _liveEntityInfoByGuid already carries
the server-sent name (used elsewhere in DescribeLiveEntity per the B.4b
code).
Diagnostic tag choice. Use [door-anim] (registration) and
[door-cycle] (UM dispatch) rather than the phase-named [B.4c]. The
Opus reviewer flagged phase-tagged diagnostics as rotting from B.4b's
review — durable subsystem-named tags survive phase archival and grep
cleanly long after B.4c is closed.
Data flow
[Spawn]
ACE CreateObject for inn door
→ live-spawn handler resolves setup, meshRefs, scale, spawn.PhysicsState
→ idleCycle resolves to null (doors have no idle cycle)
→ existing gate at line 2692 fails → _liveAnimRejectNoCycle++
→ NEW gate: IsDoorSpawn(spawn) → true
→ mtableId = setup.DefaultMotionTable (door motion table id)
→ mtable loaded from dats
→ AnimationSequencer constructed
→ initialCycle = (spawnState & 0x4 /* ETHEREAL */) != 0 ? On (0x4000000B) : Off (0x4000000C)
→ sequencer.SetCycle(NonCombat 0x80000001, initialCycle)
→ _animatedEntities[entity.Id] = AnimatedEntity { Sequencer, Animation=null }
→ log [door-anim] registered guid=0x... initialCycle=0x...
→ per-frame tick advances the Off cycle, sequencer rests at last frame (closed pose)
→ renderer draws door at closed-pose transforms from sequencer
[Player Use]
B.4b chain: double-click → BuildUse → ACE Door.ActOnUse
→ ACE broadcasts UpdateMotion(NonCombat, On) where On = 0x4000000B
→ WorldSession parses → MotionUpdated event
→ OnLiveMotionUpdated:
_entitiesByServerGuid lookup → entity (id=0x000F4245)
_animatedEntities[entity.Id] → ae (with seeded sequencer)
log [door-cycle] guid=0x... stance=0x0001 cmd=0x000B
ae.Sequencer.SetCycle(0x80000001, 0x4000000B, 1f)
→ Sequencer transitions from Off cycle → On cycle (one-shot via motion-table link)
→ per-frame tick reads sequencer transforms → door's part transforms update
→ renderer rebuilds MeshRefs from updated transforms each frame
→ user sees door swinging open
→ cycle ends, sequencer rests at the open-pose final frame
→ renderer draws door at open pose
→ (parallel) ACE broadcasts SetState(0x0001000C) → L.2g chain → collision exempts
[Auto-close 30s later]
ACE broadcasts UpdateMotion(NonCombat, Off 0x4000000C) + SetState(0x00010008)
→ same UM path, sequencer transitions On → Off (close cycle)
→ cycle ends, sequencer rests at closed-pose final frame
→ renderer draws door at closed pose
→ (parallel) collision blocks again
Error handling
- Door has no MotionTable (
setup.DefaultMotionTable == 0ANDspawn.MotionTableId == null): the new branch's innerif (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
MotionOpencycle: the existingHasCyclefallback at lines 2742-2768 walks throughRunForward → WalkForward → Ready. For doors that's wrong (no Ready cycle). The NEW door branch doesn't run that fallback — it just doesn't callSetCycleat spawn. At runtime ifOnLiveMotionUpdatedcallsSetCycle(MotionOpen)and the table doesn't have it, the sequencer's internalHasCyclecheck 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. _animLoaderis null (test / headless mode): the NEW branch's outer_animLoader is not nullcheck 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-7697writes all-parts-at-origin transforms over the entity's spawn-time MeshRefs. Animation = nullin the AnimatedEntity record breaks per-frame tick: the sequencer branch atGameWindow.cs:7497reads frames viaae.Sequencer.Advance(dt)and never touchesae.Animation. The only Animation reads are in the legacy slerpelsebranch at line 7644+, reached only whenae.Sequencer is null. Safe by code reading.
Testing
No new unit tests. The change is GameWindow integration code, verified at runtime per the project's existing precedent (B.4b's switch cases, L.2g's MotionUpdated routing).
Runtime verification at Holtburg inn doorway (same recipe as L.2g slice 1 + B.4b ship handoff):
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_DUMP_MOTION = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
In-client:
- Wait ~8s for spawn at Holtburg.
- Walk to the inn doorway.
- Confirm visual: door at closed pose.
- Double-click the door.
- Confirm visual: door swings open over a fraction of a second.
- Walk through (already verified by L.2g + B.4b).
- Wait ~30s in the inn.
- Confirm visual: door swings closed.
- Bump the closed door — confirm it blocks again (collision restored).
Log grep:
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|UM guid=.*Door|setstate.*0x7A9B4015"
Expected:
[door-anim] registered guid=0x... initialCycle=0x4000000C(one per closed door at world load)UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000B(existing UM dump on Use)[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000B(NEW; cmd=On)[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C(L.2g chain)- ~30s gap
[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000C(NEW; cmd=Off)[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x00010008(close)
Slice plan
This is one slice. No further sub-slicing.
| Step | Files | LOC | Notes |
|---|---|---|---|
1. Add IsDoorSpawn helper |
GameWindow.cs |
~3 | Static private |
| 2. Add Door registration branch in spawn handler with state-seeded SetCycle | GameWindow.cs |
~40 | After existing creature gate; seeds Off/On from spawn.PhysicsState |
3. Add [door-cycle] diagnostic in OnLiveMotionUpdated |
GameWindow.cs |
~5 | Gated on probe + name check via _liveEntityInfoByGuid |
4. dotnet build + dotnet test green |
— | — | 1046 / 8 baseline expected |
| 5. Visual test at Holtburg inn doorway | — | — | Manual (user) |
| 6. Commit + ship handoff + close #58 + roadmap update | — | — | Same Task 6 pattern as B.4b |
| 7. Merge to main | — | — | After final review |
Total: ~38 LOC in one file. One implementation commit + one docs commit.
Acceptance criteria
dotnet buildgreendotnet testgreen (1046 / 8 pre-existing baseline unchanged)- At Holtburg, double-click on inn door:
- Log shows
[door-anim] registered guid=... initialCycle=0x4000000Cfor each closed door at world load - Log shows
[door-cycle] guid=... stance=0x0001 cmd=0x000Bafter 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=0x000Cfor the close motion - Closed door blocks collision again (already verified; should not regress)
- Log shows
- 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.4cif/when those entities show similar bugs. - One-shot vs cyclic playback contract in
AnimationSequencer. We trust the door's motion-table flags to markMotionOpen/MotionClosedas 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
SoundTablepath. 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.