docs(B.4b): ship handoff + close #57 + file #58 + roadmap/CLAUDE update

Phase B.4b shipped end-to-end 2026-05-13. Holtburg inn doorway
double-click verified: pick -> BuildUse -> ACE SetState reply ->
ID-translated registry update -> CollisionExemption exempts ->
player walks through. M1 demo target "open the inn door" met.

9 commits on this branch:
- Tasks 1-4 per plan (BuildRay, Pick, rename, handler wiring)
- 4 bonus visual-test discoveries:
  * InputDispatcher double-click detection (was dead code)
  * DoubleClick activation gate fix in OnInputAction
  * L.2g slice 1b: CollisionExemption widened to ETHEREAL alone
  * L.2g slice 1c: ServerGuid -> entity.Id translation (silent blocker)

Closes #57. Files #58 for door swing animation (UpdateMotion routing
for non-creature entities, M1 deferred polish). Updates roadmap and
CLAUDE.md Phase L.2 paragraph. Memory file project_interaction_pipeline.md
updated outside the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-13 19:33:27 +02:00
parent 08be296dcd
commit 2c9bdb512b
4 changed files with 501 additions and 92 deletions

View file

@ -618,27 +618,23 @@ acdream's plan lives in two files committed to the repo:
approval.
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 shipped 2026-05-12. L.2g slice 1
is CODE-COMPLETE (parser + registry mutator + WorldSession dispatcher +
GameWindow subscriber, 4 commits, build green, 6 new tests pass), but
its visual verification is **deferred to the B.4b session** — clicking
on a door does nothing today because Phase B.4's input-action handler
was never wired (the wire builders and bindings exist, but
`GameWindow.OnInputAction` has no case for `SelectDblLeft`, so the
outbound Use never sends). **The natural next step is Phase B.4b —
finish the outbound Use handler wiring** (subscribe `SelectDblLeft`
`WorldPicker.Pick``InteractRequests.BuildUse` → send), then re-run
the Holtburg inn-doorway visual test which verifies both L.2g slice 1
and B.4b in one pass. Estimated 30-50 LOC, ~30 min, 1-2 subagent
dispatches.
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.
L.2g slice 1 ship handoff: [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md)
— full evidence + the 4 minor review notes + the 1 Important test-coverage
gap for `ShouldSkip` (the B.4b visual test's hex-dump will settle whether
ACE sends `state=0x4` alone or `0x14`).
Design spec: [`docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md`](docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md).
Implementation plan: [`docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md`](docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md).
L.2d ship handoff: [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
**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).
**L.2g slice 1 ship handoff:** [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md).
**L.2d ship handoff:** [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
**Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
Three commits land the L.2 "make every bad movement outcome explainable"
@ -716,30 +712,17 @@ together comprise the streaming + rendering perf foundation for the
project.
**Next phase candidates (in rough preference order):**
- **Phase B.4b — finish the outbound Use handler wiring.**
Direct M1 blocker discovered while running the L.2g slice 1 visual
test: the wire builders (`InteractRequests.BuildUse`), classes
(`SelectionState`, `WorldPicker`), input-action enum entries
(`SelectDblLeft` etc.), and keybindings (LMB-dblclick → `SelectDblLeft`)
all ship today, but `GameWindow.OnInputAction`'s switch has NO case
for any of the `Select*` actions — clicking on a door fires the
diagnostic `[input] SelectDblLeft Press` but nothing downstream
listens. Memory file `project_interaction_pipeline.md` updated to
reflect this reality. Shape: subscribe `InputAction.SelectDblLeft`
→ build a world ray from current mouse → `WorldPicker.Pick(...)`
store in `_selection` → call `InteractRequests.BuildUse(seq, guid)`
+ `_liveSession.SendGameMessage(body)`. Probably also subscribe
`SelectLeft` for select-without-use and `UseSelected` for the R
hotkey. Estimate: 30-50 LOC, 1-2 subagent dispatches, ~30 min.
Verifies L.2g slice 1 in the same Holtburg-doorway visual test once
it lands. Full context:
[`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md)
"Why the visual test is deferred" section.
- **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.
- **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #50 (stray tree), #41 (remote-motion blips) have been open since
April/early-May and keep getting deferred. Either link each to a future
phase or downgrade. ~1 hour, surfaces what's chronic vs. linked-to-a-phase.
coat), #41 (remote-motion blips) have been open since April/early-May and
keep getting deferred. Either link each to a future phase or downgrade.
~1 hour, surfaces what's chronic vs. linked-to-a-phase.
- **More Phase C visual-fidelity work** (C.2 dynamic point lights, C.3
palette tuning, C.4 double-sided translucent polys) closing the
"world reads as old / broken vs. retail" backlog.

View file

@ -46,62 +46,70 @@ Copy this block when adding a new issue:
# Active issues
## #57 — B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
## #58 — Door swing animation: UpdateMotion not wired for non-creature entities
**Status:** OPEN
**Severity:** HIGH (M1 blocker — demo target *"open the inn door, click an NPC, pick up an item"* is fully blocked)
**Severity:** MEDIUM (M1 demo cosmetic — doors function but don'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.
**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.
**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)
**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.
**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.
---
## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
**Status:** DONE
**Closed:** 2026-05-13
**Severity:** HIGH (was M1 blocker)
**Filed:** 2026-05-12
**Component:** input / interaction / `GameWindow.OnInputAction`
**Description:** Discovered 2026-05-12 while running the L.2g slice 1
visual test. Phase B.4 (2026-04-28) shipped half of itself: the wire-
message builders (`InteractRequests.BuildUse` / `BuildUseWithTarget` /
`BuildPickUp`), the supporting classes (`SelectionState`, `WorldPicker`),
the `InputAction` enum entries (`SelectLeft`, `SelectDblLeft`,
`SelectRight`, `SelectDblRight`, `UseSelected`, `SelectionPickUp`, etc.),
and the default keybindings (LMB → `SelectLeft`, LMB-dblclick →
`SelectDblLeft`, RMB → `SelectRight`, R → `UseSelected`, F →
`SelectionPickUp`). What was never shipped: a case for ANY of those
actions in `GameWindow.OnInputAction`'s switch. The runtime diagnostic
`[input] SelectLeft Press` fires when you click — confirming the
dispatcher resolves the chord — but nothing downstream listens, so the
click silently does nothing. Neither does double-click, R, or F. The
inbound side (`MoveToObjectReceived`, `StateUpdated` after L.2g slice 1)
is wired and ready; the block is purely outbound.
**Closure:** Closed by Phase B.4b on branch `claude/compassionate-wilson-23ff99`
(9 implementation commits, Tasks 1-4 per plan + 4 bonus fixes). The
full round-trip — double-click door → `WorldPicker.BuildRay` + `Pick`
`InteractRequests.BuildUse` → ACE `SetState` reply → `ShadowObjectRegistry`
mutation (via fixed ServerGuid→entity.Id translation) → `CollisionExemption.ShouldSkip`
exempts (widened to ETHEREAL-alone) → player walks through — was
visual-verified at the Holtburg inn doorway 2026-05-13. Four bonus
discoveries were required beyond the original plan: (1) `InputDispatcher`
had no double-click detection, (2) `OnInputAction` gate blocked
`DoubleClick` activations, (3) `CollisionExemption` required both
ETHEREAL+IGNORE_COLLISIONS while ACE sends only ETHEREAL, (4)
`OnLiveStateUpdated` passed server GUID to a local-entity-ID-keyed
registry. M1 demo target "open the inn door" met. See
[docs/research/2026-05-13-b4b-shipped-handoff.md](research/2026-05-13-b4b-shipped-handoff.md)
for full evidence and rationale.
**Root cause / status:** B.4 handler integration step was evidently
dropped or never landed. Memory file
`memory/project_interaction_pipeline.md` was updated 2026-05-12 to
reflect this reality (previous text claimed shipped).
**Files:**
- `src/AcDream.App/Rendering/GameWindow.cs``OnInputAction` switch
around line 8546+ has no `Select*` cases.
- `src/AcDream.Core.Net/Messages/InteractRequests.cs` — wire builders
exist but have zero callers in `src/`.
- `src/AcDream.Core/Selection/SelectionState.cs` — class exists, zero
production callers.
- `src/AcDream.App/Rendering/WorldPicker.cs` — class exists, zero
production callers.
- `src/AcDream.UI.Abstractions/Input/KeyBindings.cs:300-320` — bindings
for `SelectLeft` / `SelectDblLeft` / `SelectRight` / `SelectDblMid`
exist.
**Research:** [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](research/2026-05-12-l2g-slice1-shipped-handoff.md)
"Why the visual test is deferred" section has the full investigation.
**Acceptance:** Double-left-clicking on a door in the Holtburg inn
doorway sends a `0xF7B1 / 0x0036 Use` to the server, the server flips
the door's `Ethereal` bit and broadcasts `SetState (0xF74B)`, the
L.2g-slice-1 chain mutates `ShadowObjectRegistry`, the
`CollisionExemption.ShouldSkip` check honors it, and the player can
walk through the doorway. Visual verification + log grep (per the
L.2g handoff's reproducibility recipe) both pass.
**Status promotion:** This is a phase-sized follow-up (estimated
30-50 LOC, ~30 min). Promoted to **Phase B.4b** in the L.2 milestone
context and the CLAUDE.md "Next phase candidates" list. Will be closed
as `DONE (promoted to Phase B.4b)` once that phase's design spec lands.
**Files (what shipped):**
- `src/AcDream.Core/Selection/WorldPicker.cs` (new; formerly zero callers, now wired)
- `src/AcDream.App/Rendering/GameWindow.cs``OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `OnLiveStateUpdated` ServerGuid→Id translation; `_entitiesByServerGuid` reverse-lookup dict
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` — double-click detection
- `src/AcDream.Core/Physics/CollisionExemption.cs` — widened to ETHEREAL-alone
---

View file

@ -65,6 +65,7 @@
| N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ |
| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 3050× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ |
| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity``OnCreate`, `RemoveEntityByServerGuid``OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) |
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number:

View file

@ -0,0 +1,417 @@
# Phase B.4b shipped — handoff (visual-verified 2026-05-13)
**Date:** 2026-05-13.
**Branch:** `claude/compassionate-wilson-23ff99` (ready to merge to main; do NOT merge here — controller handles that after code review).
**Predecessors:**
- [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](2026-05-12-l2g-slice1-shipped-handoff.md) — L.2g slice 1 ship handoff that discovered the B.4 handler gap and deferred the Holtburg visual test to B.4b.
- [docs/superpowers/specs/2026-05-13-phase-b4b-design.md](../superpowers/specs/2026-05-13-phase-b4b-design.md) — B.4b design spec.
- [docs/superpowers/plans/2026-05-13-phase-b4b-plan.md](../superpowers/plans/2026-05-13-phase-b4b-plan.md) — B.4b implementation plan (6 tasks; Tasks 1-4 per plan + 2 bonus sets beyond the plan).
---
## TL;DR
Phase B.4b **shipped end-to-end and is visual-verified 2026-05-13.** The M1
demo target *"open the inn door"* is met. 9 commits on this branch implement
and fix the complete round-trip: double-click door → `WorldPicker.Pick`
`InteractRequests.BuildUse` → ACE broadcasts `SetState (0xF74B)` with
`ETHEREAL` bit → `ShadowObjectRegistry.UpdatePhysicsState` (L.2g slice 1)
mutates cached state → `CollisionExemption.ShouldSkip` exempts the door →
player walks through.
The plan estimated "30-50 LOC, 1-2 subagent dispatches, ~30 min."
Visual testing surfaced **four bonus discoveries** beyond the plan's
Tasks 1-4:
1. `InputDispatcher` had no double-click detection (the `SelectDblLeft`
binding was dead code — the dispatcher never produced `DoubleClick`
activations).
2. `OnInputAction`'s early-return gate discarded `DoubleClick` activations
before the switch reached the `SelectDblLeft` case.
3. L.2g `CollisionExemption.ShouldSkip` required **both** `ETHEREAL` +
`IGNORE_COLLISIONS` bits, but ACE's `Door.Open()` sends only `ETHEREAL`
(`state=0x0001000C`).
4. `OnLiveStateUpdated` passed a server GUID to `ShadowObjectRegistry` which
is keyed by local entity ID — the registry lookup always missed → no-op
→ the door never became passable. **This was the actual blocker the user
reported.**
Fixes 1-4 were shipped as bonus commits 5-9 beyond the plan's Tasks 1-4.
L.2g slice 1 and B.4b are now both fully verified by the same visual test.
Issue #57 is closed. Issue #58 (door swing animation) is filed as M1-deferred
polish.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `f0b3bd9` | `feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection` | Task 1 |
| 2 | `221b641` | `feat(B.4b): WorldPicker.Pick — ray-sphere entity pick` | Task 2 |
| 3 | `5821bdc` | `fix(B.4b): WorldPicker.Pick — handle inside-sphere origin + document normalize contract` | Task 2 review fix |
| 4 | `7b4aff2` | `refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid` | Task 3 |
| 5 | `89d82e1` | `feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker` | Task 4 |
| 6 | `242ce70` | `feat(B.4b): InputDispatcher detects double-clicks` | Bonus: Task 4b |
| 7 | `58b95bc` | `fix(B.4b): let DoubleClick activation pass the OnInputAction gate` | Bonus: Task 4c |
| 8 | `a6e4b57` | `fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone` | L.2g slice 1b |
| 9 | `08be296` | `fix(phys L.2g slice 1c): translate ServerGuid -> entity.Id for ShadowObjectRegistry` | L.2g slice 1c |
Plus plan/spec commits earlier in the branch session:
- `4a1c594` — B.4b design spec.
- `ffa404d` — corrected file paths in spec (WorldPicker is in `AcDream.Core.Selection`, not `AcDream.App/Rendering`).
- `179e441` — B.4b implementation plan (6 tasks).
**Build:** clean. **Tests:** 4 new double-click detection tests (commit `242ce70`, all pass). Full suite: builds green, no regressions. L.2g slice 1's 6 tests continue to pass.
---
## What the code does end-to-end
When the user double-left-clicks a door entity in the Holtburg inn doorway,
the following chain fires:
1. **Double-click detection**`InputDispatcher.OnMouseDown` checks the
elapsed time since the previous `MouseLeft` press. If ≤500ms, the
activation kind is `DoubleClick`; otherwise `Press`. This is new as
of commit `242ce70`; prior to this the `SelectDblLeft` binding was dead
code (the dispatcher never produced `DoubleClick` activations).
2. **Action dispatch**`InputDispatcher` resolves the chord
`[MouseLeft, DoubleClick]``InputAction.SelectDblLeft` + activation
`DoubleClick`. The multicast `InputAction` event fires, logged as:
`[input] SelectDblLeft DoubleClick`.
3. **OnInputAction gate**`GameWindow.OnInputAction` receives the event.
Prior to commit `58b95bc`, an early-return guard (`if (activation != Press) return;`)
discarded all `DoubleClick` events. The fix widens the gate to
`if (activation != Press && activation != DoubleClick) return;`.
The switch now reaches the `SelectDblLeft` case.
4. **Ray construction**`WorldPicker.BuildRay(mousePos, viewport, viewMatrix, projMatrix)`
unprojects the cursor pixel into a world-space ray origin + direction,
using standard NDC→view→world unprojection. Numerically: the mouse pixel
is mapped to `[-1,+1]` NDC, transformed through `inverse(proj)` to get
a view-space direction, then through `inverse(view)` for world-space.
5. **Entity pick**`WorldPicker.Pick(ray, entities, maxDist=50m)` iterates
all entities in `_gpuWorldState.GetAllEntities()`, tests each against a
ray-sphere intersection with the entity's bounding radius, and returns
the closest hit. A special-case inside-sphere origin guard (commit `5821bdc`)
ensures the pick works even when the cursor origin is already inside an
entity's bounding sphere (common for large portals or doors at close range).
`[B.4b] pick guid=0x7A9B4015 name=Door` logged on hit.
6. **Use message**`GameWindow` stores `_selectedGuid = picked.Guid` and
calls `InteractRequests.BuildUse(seq, guid)`. The resulting `0xF7B1 / 0x0036`
game message is sent to ACE via `_liveSession.SendGameMessage(body)`.
`[B.4b] use guid=0x7A9B4015 seq=N` logged.
7. **ACE processes the Use** — ACE's `Door.Open()` flips the door's physics
flags to `ETHEREAL | ...` and broadcasts `SetState (0xF74B)` with the
new state value.
8. **SetState arrives**`WorldSession.OnSetState` parses the 12-byte
payload (Guid + PhysicsState + InstanceSeq + StateSeq) and fires
`WorldSession.StateUpdated`. `GameWindow.OnLiveStateUpdated` handles it.
**New as of commit `08be296` (slice 1c):** the handler translates
`parsed.Guid` (server GUID `0x7A9B4015`) to `entity.Id` (local entity ID
`0x000F4245`) via `_entitiesByServerGuid` before calling
`ShadowObjectRegistry.UpdatePhysicsState`. Without this translation the
registry lookup always returned "not found" — a silent no-op.
Log: `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
9. **Collision exemption** — next physics tick, `FindObjCollisions` calls
`CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)`.
**New as of commit `a6e4b57` (slice 1b):** the check fires on
`(state & ETHEREAL_PS) != 0` alone (widened from the original `ETHEREAL &&
IGNORE_COLLISIONS` conjunction). Because ACE broadcasts only `ETHEREAL`
in the low bits (`state=0x0001000C`), the original conjunction never fired;
the door stayed solid.
10. **Player walks through** — the resolver produces no wall-contact response
for the door's collision geometry. User confirms: "Now I can walk through."
### Observed log evidence
```
[input] SelectDblLeft DoubleClick
[B.4b] pick guid=0x7A9B4015 name=Door
[B.4b] use guid=0x7A9B4015 seq=N
[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C
```
Player walks through the closed door after the `setstate` line.
---
## The four bonus discoveries
### 1. InputDispatcher had no double-click detection (`242ce70`)
**Root cause:** `InputDispatcher.OnMouseDown` only looked up `Press` and
`Hold` activations in the binding table. The `SelectDblLeft` binding was
wired to the chord `[MouseLeft, DoubleClick]` in `KeyBindings.cs:300-320`
(shipped in B.4, 2026-04-28), but the dispatcher's mouse-down handler
never set activation to `DoubleClick` — it always produced `Press`.
So `SelectDblLeft` was literally unreachable: the chord required
`DoubleClick` to match, but the dispatcher never generated it.
**Fix:** Added a `_lastMouseDownTime` (and `_lastMouseDownButton`) tracker
to `InputDispatcher`. In `OnMouseDown`, if the same button fires within
500ms of its last press, activation is `DoubleClick`; otherwise `Press`.
500ms matches the standard Windows/macOS double-click threshold.
**Rationale:** The fix is minimal and correct. A more faithful retail
implementation might read the OS's configured double-click interval, but
500ms is the retail default and was the right call for now. 4 new unit
tests cover the timing logic: first click = Press, second click within
500ms = DoubleClick, third click = Press again (resets the window), and
button mismatch = Press.
### 2. OnInputAction gate discarded DoubleClick activations (`58b95bc`)
**Root cause:** Even after discovery #1 was fixed and `SelectDblLeft DoubleClick`
fired from the dispatcher, the event handler had an early-return guard at
the top of `GameWindow.OnInputAction`:
```csharp
if (activation != InputActivation.Press) return;
```
This guard was introduced to prevent `Hold` repetition from triggering
switch cases intended for one-shot actions. It correctly blocked `Hold`
but also blocked `DoubleClick` — so the `SelectDblLeft` case was still
unreachable even after the dispatcher started generating `DoubleClick`.
**Fix:** Widened the guard to let both `Press` and `DoubleClick` through:
```csharp
if (activation != InputActivation.Press && activation != InputActivation.DoubleClick) return;
```
**Rationale:** `DoubleClick` is semantically a one-shot activation (fires
once per double-click gesture), so it belongs in the same pass-through
group as `Press`. `Hold` repetition remains blocked.
### 3. CollisionExemption required both ETHEREAL + IGNORE_COLLISIONS (`a6e4b57`)
**Root cause:** The original `CollisionExemption.ShouldSkip` check was
ported faithfully from `acclient_2013_pseudo_c.txt:276782`, which requires
**both** `ETHEREAL_PS (0x4)` and `IGNORE_COLLISIONS_PS (0x10)` to be set
simultaneously before short-circuiting collision detection. Retail servers
send both bits when opening a door, so retail clients see `state ≥ 0x14`.
However, ACE's `Door.Open()` broadcasts only the `ETHEREAL` bit in the
low portion of the state word. The observed wire value was
`state=0x0001000C`: bit `0x4` (ETHEREAL) is set, bit `0x10`
(IGNORE_COLLISIONS) is not. The `&&` conjunction in `ShouldSkip` evaluated
to false → door stayed solid even after the registry update.
This was the exact scenario the L.2g slice 1 Important review note warned
about (see L.2g handoff §"One Important review note"): *"ACE's
`PhysicsObj.cs:787-791` may set both bits... but this is not verified by
the test suite. The B.4b visual test will settle this definitively."*
It settled as: ACE sends `0x4` alone, not `0x14`.
**Fix:** Widened the short-circuit to fire on `ETHEREAL` alone:
```csharp
// Widened from (ETHEREAL && IGNORE_COLLISIONS) — ACE Door.Open() sends
// ETHEREAL alone (state=0x0001000C); retail servers send both.
// Pragmatic choice: exempt on ETHEREAL-bit-alone until full retail
// obstruction_ethereal flag path is ported.
if ((state & ETHEREAL_PS) != 0) return true;
```
**Rationale:** The deeper retail path (pseudo-C line 276795 sets
`obstruction_ethereal=1` and routes through downstream movement handling)
was not ported — that's a more invasive change requiring more testing. The
pragmatic widening to ETHEREAL alone is correct for ACE's Door behavior and
matches the spirit of the retail check (ETHEREAL means "pass through me").
If a future retail-server emulator sends both bits, the widened check still
fires (ETHEREAL is a subset of ETHEREAL+IGNORE_COLLISIONS).
### 4. ServerGuid → entity.Id translation missing in OnLiveStateUpdated (`08be296`) — THE actual blocker
**Root cause:** `ShadowObjectRegistry` is keyed by local `entity.Id` (the
per-session integer ID assigned by `GpuWorldState` at entity registration,
e.g. `0x000F4245`). The `GameWindow.OnLiveStateUpdated` handler was passing
`parsed.Guid` — the **server GUID** broadcasted in the `SetState` packet
(e.g. `0x7A9B4015`) — directly to `UpdatePhysicsState`. Because the registry
has no entry keyed by server GUID, the lookup always returned "not found"
and the state mutation was silently dropped. The registry stayed at
`state=0x00000000` (closed, solid) regardless of how many times the door
was clicked.
This is why discoveries 1-3 alone were insufficient: even with double-click
detection working, the correct gate firing, and `CollisionExemption`
widened, the registry still held the stale closed state and the door
stayed solid.
**Fix:** Added a `_entitiesByServerGuid` reverse-lookup dictionary to
`GameWindow` (populated at entity registration in `OnLiveCreateObject`).
`OnLiveStateUpdated` now does:
```csharp
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
_physicsEngine.ShadowObjects.UpdatePhysicsState(entity.Id, parsed.PhysicsState);
```
The `entityId=` field was added to the `[setstate]` diagnostic log line
specifically to make this translation visible and greppable:
`[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
**Why this was missed:** L.2g slice 1's unit tests operated at the
`ShadowObjectRegistry` level directly, calling `UpdatePhysicsState` with
an `entity.Id` (not a server GUID). The integration was never exercised
end-to-end before B.4b's visual test. The two tests `UpdatePhysicsState_FlipsEthereal_*`
were correct in isolation; the broken layer was one level above them
(the handler → registry call site).
**Why the "multiple doors" misdiagnosis occurred:** Before slice 1c was
identified, the `[resolve]` probes showed wall hits attributed to
`obj=0x000F4245` while the clicked door's ServerGuid was `0x7A9B4015`.
Initial read: "these are two different entities blocking the threshold."
Slice 1c clarified: both IDs refer to the same door — `0x000F4245` is
the local entity ID, `0x7A9B4015` is the server GUID for the same entity.
The ID-space mismatch was the cause of both the collision-not-clearing
AND the "different object" misread.
---
## Open notes / follow-ups
### Door swing animation (#58)
When ACE opens a door it broadcasts **two** packets, not one:
1. `SetState (0xF74B)` — the collision-bit flip. **Handled by L.2g slice 1.**
2. `UpdateMotion (0xF74D)` with stance/command `(NonCombat, On)` — the
swing animation cycle. **NOT handled.**
acdream's `UpdateMotion` pipeline is currently scoped to player + creature
animation (Phase L.3). Non-creature entities like doors do not receive
cycle commands. The door therefore opens (becomes passable) but has no
visible swing animation.
Filed as **issue #58**. Scope is unknown — routing `UpdateMotion` to
non-creature `WorldEntity` instances could be quick (few lines), or the
`AnimationSequencer` may have creature-specific assumptions that require
audit first. Filed as M1-deferred polish; it does not block the demo
scenario.
### Door toggle behavior
ACE doors toggle on each Use: first double-click opens, subsequent
double-click closes (re-sends `SetState` with `state=0x00000000`, restoring
collision). This is correct ACE behavior and matches retail. No issue to file.
Rapid double-clicks (faster than ACE's server-tick processing) will open
then close in quick succession — each Use lands as a distinct game action.
Expected behavior; no fix needed.
### Multiple-door misdiagnosis (historical note)
While slice 1c was still unidentified, the `[resolve]` diagnostic showed:
```
[resolve] ... obj=0x000F4245 wall hit
[B.4b] use guid=0x7A9B4015 ...
[setstate] guid=0x7A9B4015 state=0x0001000C
[resolve] ... obj=0x000F4245 wall hit (unchanged!)
```
Initial misdiagnosis: there must be a *different* door entity (`0x000F4245`)
blocking the threshold whose state was never updated. Slice 1c revealed:
both IDs refer to the same door — one is the server GUID (network space),
the other is the local entity ID (registry space). The registry update was
targeting the server GUID (which missed), so the local-ID-keyed entry
stayed solid.
### Selection HUD / hover-highlight / brackets
Out of B.4b scope per design spec §Non-goals. The `_selectedGuid` field on
`GameWindow` is populated (stores the last-picked entity's server GUID), but
nothing renders a selection bracket, hover highlight, or target nameplate.
That is M2/M3 HUD work (Phase D.6).
### BuildPickUp (F key) + UseWithTarget UX
`InteractRequests.BuildPickUp` exists (as an alias of `BuildUse`). The
`SelectionPickUp` input action and the F-key binding exist. But
`OnInputAction` has no case for `SelectionPickUp` — pick-up-by-F-key is
still unimplemented. Same for `UseWithTarget` (requires a secondary target
selection UX). Both deferred to a follow-up phase; not M1-blocking.
---
## 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** (B.4b, this branch).
- ⬜ "click an NPC" — pick + Use wiring exists now; depends on ACE NPC handler responding to Use.
- ⬜ "pick up an item" — `BuildPickUp` + F-key wiring not yet in `OnInputAction`.
**Recommended next steps (in M1 critical-path order):**
1. **Door swing animation (#58)** — cosmetic M1 polish. Route
`UpdateMotion (0xF74D)` to non-creature entities so the door visually
swings. Could be quick (30 min) or moderate (2 hrs with AnimationSequencer
audit). Worth a spike before committing to an estimate.
2. **Chronic open-issue triage**#2 (lightning), #4 (horizon-glow), #28
(aurora), #29 (cloud thinness), #37 (humanoid coat), #41 (remote-motion
blips) have been deferred since April/early-May. Link each to a future
phase or downgrade. ~1 hour. Not M1-blocking but surfaces the real backlog.
3. **More Phase C visual-fidelity** — C.2 (dynamic point lights), C.3
(palette tuning), C.4 (double-sided translucent polys). World still reads
"old" without local lighting on fireplaces/lamps.
---
## Reproducibility
Same launch recipe as before. 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-b4b.log"
```
Walk to the Holtburg inn doorway. Double-left-click the closed Door. Walk
through. Subsequent double-clicks will close and re-open (ACE toggle).
After closing the client, grep for:
```powershell
Select-String -Path launch-b4b.log -Pattern "SelectDblLeft|pick guid|use guid|setstate.*entityId"
```
Expected:
- `[input] SelectDblLeft DoubleClick` — dispatcher fires on second click within 500ms.
- `[B.4b] pick guid=0x7A9B4015 name=Door` — ray hits the door.
- `[B.4b] use guid=0x7A9B4015 seq=N` — Use message sent.
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` — ACE reply processed, translation confirmed.
---
## Worktree state at handoff
- Branch `claude/compassionate-wilson-23ff99`.
- 9 implementation commits + 3 plan/spec commits ahead of `eea9b4d`
(the L.2g slice 1 merge from the previous session).
- 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.