# 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:** Used the pre-existing `_entitiesByServerGuid` reverse-lookup
dictionary on `GameWindow` (populated at entity registration in
`OnLiveCreateObject` since Phase 6.6/6.7). `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.
