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>
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:
- docs/research/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 — B.4b design spec.
- docs/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:
InputDispatcherhad no double-click detection (theSelectDblLeftbinding was dead code — the dispatcher never producedDoubleClickactivations).OnInputAction's early-return gate discardedDoubleClickactivations before the switch reached theSelectDblLeftcase.- L.2g
CollisionExemption.ShouldSkiprequired bothETHEREAL+IGNORE_COLLISIONSbits, but ACE'sDoor.Open()sends onlyETHEREAL(state=0x0001000C). OnLiveStateUpdatedpassed a server GUID toShadowObjectRegistrywhich 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 inAcDream.Core.Selection, notAcDream.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:
-
Double-click detection —
InputDispatcher.OnMouseDownchecks the elapsed time since the previousMouseLeftpress. If ≤500ms, the activation kind isDoubleClick; otherwisePress. This is new as of commit242ce70; prior to this theSelectDblLeftbinding was dead code (the dispatcher never producedDoubleClickactivations). -
Action dispatch —
InputDispatcherresolves the chord[MouseLeft, DoubleClick]→InputAction.SelectDblLeft+ activationDoubleClick. The multicastInputActionevent fires, logged as:[input] SelectDblLeft DoubleClick. -
OnInputAction gate —
GameWindow.OnInputActionreceives the event. Prior to commit58b95bc, an early-return guard (if (activation != Press) return;) discarded allDoubleClickevents. The fix widens the gate toif (activation != Press && activation != DoubleClick) return;. The switch now reaches theSelectDblLeftcase. -
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 throughinverse(proj)to get a view-space direction, then throughinverse(view)for world-space. -
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 (commit5821bdc) 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=Doorlogged on hit. -
Use message —
GameWindowstores_selectedGuid = picked.Guidand callsInteractRequests.BuildUse(seq, guid). The resulting0xF7B1 / 0x0036game message is sent to ACE via_liveSession.SendGameMessage(body).[B.4b] use guid=0x7A9B4015 seq=Nlogged. -
ACE processes the Use — ACE's
Door.Open()flips the door's physics flags toETHEREAL | ...and broadcastsSetState (0xF74B)with the new state value. -
SetState arrives —
WorldSession.OnSetStateparses the 12-byte payload (Guid + PhysicsState + InstanceSeq + StateSeq) and firesWorldSession.StateUpdated.GameWindow.OnLiveStateUpdatedhandles it. New as of commit08be296(slice 1c): the handler translatesparsed.Guid(server GUID0x7A9B4015) toentity.Id(local entity ID0x000F4245) via_entitiesByServerGuidbefore callingShadowObjectRegistry.UpdatePhysicsState. Without this translation the registry lookup always returned "not found" — a silent no-op. Log:[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C. -
Collision exemption — next physics tick,
FindObjCollisionscallsCollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState). New as of commita6e4b57(slice 1b): the check fires on(state & ETHEREAL_PS) != 0alone (widened from the originalETHEREAL && IGNORE_COLLISIONSconjunction). Because ACE broadcasts onlyETHEREALin the low bits (state=0x0001000C), the original conjunction never fired; the door stayed solid. -
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:
SetState (0xF74B)— the collision-bit flip. Handled by L.2g slice 1.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 inOnInputAction.
Recommended next steps (in M1 critical-path order):
-
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. -
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.
-
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.