acdream/docs/research/2026-05-13-b4b-shipped-handoff.md
Erik 48ce52c6ed docs(B.4b): final-review polish — file #59 #60 follow-ups + handoff correction
Final whole-branch code review (Opus) surfaced two Important post-merge
follow-ups + a one-word inaccuracy in the handoff doc:

- #59: tighten WorldPicker per-entity Setup.Radius (M1-deferred; the
  ServerGuid==0 invariant is load-bearing and worth documenting before
  L.2d's CBuildingObj port lands).
- #60: port retail's obstruction_ethereal downstream path so combat-HUD
  contact reporting works for ethereal creatures (M2-combat).
- handoff: corrected "Added a _entitiesByServerGuid reverse-lookup" to
  "Used the pre-existing _entitiesByServerGuid" — the dict has existed
  since Phase 6.6/6.7; slice 1c used it, didn't add it.

Review verdict: branch ready to merge to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:43:38 +02:00

20 KiB

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:


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.PickInteractRequests.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 detectionInputDispatcher.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 dispatchInputDispatcher resolves the chord [MouseLeft, DoubleClick]InputAction.SelectDblLeft + activation DoubleClick. The multicast InputAction event fires, logged as: [input] SelectDblLeft DoubleClick.

  3. OnInputAction gateGameWindow.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 constructionWorldPicker.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 pickWorldPicker.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 messageGameWindow 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 arrivesWorldSession.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:

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:

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:

// 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:

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:

$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:

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.