Original spec placed WorldPicker in src/AcDream.App/Rendering/ and the test in tests/AcDream.App.Tests/, but AcDream.App.Tests doesn't exist as a project. Moved to AcDream.Core.Selection where it conceptually belongs (no App-layer deps; only WorldEntity + System.Numerics) and where the existing AcDream.Core.Tests project can hold the tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Phase B.4b — Outbound Use Handler Wiring
Status: Design spec, created 2026-05-13 after L.2g slice 1 ship handoff.
Branch: claude/compassionate-wilson-23ff99 (worktree compassionate-wilson-23ff99).
Predecessors:
- docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
— L.2g slice 1 shipped the inbound
SetState (0xF74B)pipeline; visual test was deferred when investigation uncovered that the outbound Use handler had never been wired. - docs/ISSUES.md #57 — B.4 interaction-handler gap filed 2026-05-12, promoted to Phase B.4b.
- Phase B.4 (
InteractRequestswire builders +InputActionenum +KeyBindings, shipped 2026-04-28 per memory; commit history confirms the wire builders + bindings but not the handler).
Milestone: M1 — Walkable + clickable world. Demo scenario "open the inn door" depends on this slice landing. Once B.4b lands, L.2g slice 1's deferred visual test verifies in the same scenario.
Estimate: ~80 LOC, 1-2 subagent dispatches, ~30 minutes implementation.
TL;DR
Phase B.4 (2026-04-28) shipped half of itself: the wire-message
builders (InteractRequests.BuildUse / BuildUseWithTarget /
BuildTeleToLifestone), the InputAction enum entries
(SelectLeft / SelectDblLeft / UseSelected / etc.), and the
default keybindings. What was never landed: a handler that picks an
entity at the mouse position when the user clicks, stores the
selection, and sends a BuildUse packet.
Two further gaps surfaced during this session's exploration that the L.2g handoff and ISSUES.md #57 both miss-claim as "exists":
WorldPicker— does NOT exist insrc/. Doc-only.SelectionState— does NOT exist insrc/. Doc-only.InteractRequests.BuildPickUp— does NOT exist; onlyBuildUse,BuildUseWithTarget, andBuildTeleToLifestoneare present.
B.4b creates the minimum new structure to close the gap: one new
file (WorldPicker.cs as a stateless static helper), one rename
(_selectedTargetGuid → _selectedGuid on GameWindow, unifying
combat + interaction selection), and three switch cases in
GameWindow.OnInputAction (for SelectLeft, SelectDblLeft,
UseSelected). SelectionState as a class extraction is deferred
to whenever the M2 HUD wants a SelectionChanged event; per CLAUDE.md
"don't add abstractions beyond what the task requires."
BuildPickUp (F-key) is out of scope — not on the Holtburg inn-door
critical path. Filed as a follow-up.
Why B.4b (and not "fix B.4" or "Phase B.5")
| Option | Verdict |
|---|---|
| Reopen "Phase B.4" and amend | Rejected. B.4 is in memory + commit history as shipped 2026-04-28; reopening creates retroactive confusion. The gap is real; promote it to its own short-lived sub-phase per the L.2/L.2g precedent. |
| Roll into M2 interaction work | Rejected. M2 is creatures + combat + a real selection HUD — weeks of work. The doors-open scenario is M1. Need a small slice that unblocks the M1 visual test without dragging M2 forward. |
| New phase "B.4b — Outbound Use handler wiring" | Selected. Mirrors the L.2d → L.2g lettered-sub-phase pattern. Phase-sized (30-50 LOC was the initial estimate; final is closer to ~80 with the picker file), commit-trackable, closeable as soon as the visual test passes. |
Problem evidence
Discovered 2026-05-12 while running the L.2g slice 1 visual test. The
input dispatcher correctly fires SelectDblLeft on every double-left-
click — the diagnostic [input] SelectDblLeft Press line shows in the
log — but GameWindow.OnInputAction's switch has zero case InputAction.SelectLeft / SelectDblLeft / UseSelected branches.
Nothing downstream listens. The click silently dies.
From GameWindow.cs:8546-8646 (the full OnInputAction switch as of
this morning's L.2g merge):
switch (action)
{
case InputAction.AcdreamToggleDebugPanel: ...
case InputAction.AcdreamToggleCollisionWires: ...
case InputAction.AcdreamDumpNearby: ...
case InputAction.AcdreamCycleTimeOfDay: ...
// ... 12 other Acdream*/Combat*/Toggle* cases ...
case InputAction.SelectionClosestMonster:
SelectClosestCombatTarget(showToast: true);
break;
case InputAction.EscapeKey: ...
}
SelectionClosestMonster (Q-cycle combat target) is the only
selection-related case. SelectLeft / SelectDblLeft / SelectRight
/ UseSelected / SelectionPickUp / all the other Select-family
actions have no cases at all.
Inbound side (L.2g slice 1) is wired and ready to receive the server's reply. Outbound is the only block.
Current acdream state
| Component | State |
|---|---|
InteractRequests.BuildUse(seq, guid) wire builder |
shipped at src/AcDream.Core.Net/Messages/InteractRequests.cs:37 |
InteractRequests.BuildUseWithTarget |
shipped at same file:51 |
InteractRequests.BuildPickUp |
DOES NOT EXIST (handoff was wrong) |
InputAction.SelectLeft / SelectDblLeft / SelectRight / UseSelected / SelectionPickUp |
defined in InputAction enum |
KeyBindings: LMB → SelectLeft, LMB-dblclick → SelectDblLeft, RMB → SelectRight, R → UseSelected, F → SelectionPickUp |
wired in src/AcDream.UI.Abstractions/Input/KeyBindings.cs:303-320, 172, 210 |
WorldPicker class |
DOES NOT EXIST (handoff was wrong) |
SelectionState class |
DOES NOT EXIST (handoff was wrong) |
| Selection field on GameWindow | exists as _selectedTargetGuid but combat-only (used by SelectClosestCombatTarget and ToggleLiveCombatMode) |
WorldSession.NextGameActionSequence() |
shipped; outbound chat/move already uses it |
WorldSession.SendGameAction(byte[]) |
shipped; outbound chat/move already uses it |
OnInputAction switch case for Select* / UseSelected |
MISSING — the gap |
Design
Architecture
One new file + edits to GameWindow.cs.
New: src/AcDream.Core/Selection/WorldPicker.cs — static helper
class in AcDream.Core.Selection namespace. Two pure methods, no
state, no DI. Lives in Core (not App) because it has no App-layer
dependencies: it operates on WorldEntity (Core) plus
System.Numerics matrices/vectors. Putting it in Core also means it
can be unit-tested via the existing AcDream.Core.Tests project; no
new test project required (AcDream.App.Tests does not exist as of
2026-05-13 and creating it would add more LOC than the picker
itself).
Edited: src/AcDream.App/Rendering/GameWindow.cs:
- Rename field
_selectedTargetGuid→_selectedGuid(project-wide find/replace; ~5 call sites all insideGameWindow.cs). Unifies combat + interaction selection on one field. Retail-faithful: AC has one "current target" not two. - Add three switch cases to
OnInputAction:SelectLeft,SelectDblLeft,UseSelected.
SelectionState as a separate class is deferred. Reason: only two
consumers today (combat Q-cycle, click handler). The class earns its
keep when consumer #3 (HUD widget that subscribes to
SelectionChanged) lands in M2. Premature otherwise.
Components
WorldPicker.BuildRay
Standard mouse-to-world unprojection. Convert pixel (mouseX, mouseY)
to NDC (2*mouseX/vpW - 1, 1 - 2*mouseY/vpH), unproject the near
point (ndc.z = -1) and far point (ndc.z = +1) through
inverse(projection) → inverse(view), return (origin = near, direction = normalize(far - near)).
Signature:
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection);
~20 LOC. Pure math. No exception paths — the OpenGL view/proj matrices we hand it are always invertible.
WorldPicker.Pick
Ray-sphere intersection against each candidate entity's Position
with radius 5.0f (matches WorldEntity.DefaultAabbRadius). Skip the
self-guid (player). Track the closest hit with t < maxDistance (50m
default). Return the picked entity's ServerGuid, or null for miss.
Signature:
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f);
~30 LOC. Excludes entities with ServerGuid == 0 (atlas-tier scenery
- dat-hydrated statics) — those have no server-side identity, so a
BuildUseagainst them would carry guid=0 and be rejected.
Sphere intersection math (geometric form): for each candidate, compute
oc = origin - entity.Position, b = dot(oc, direction), c = dot(oc, oc) - r², discriminant d = b² - c. If d < 0 no hit.
Otherwise t = -b - sqrt(d) is the near intersection; track smallest
positive t < maxDistance.
OnInputAction switch cases
Three new cases right before the EscapeKey case (preserve the
existing case ordering by feature group):
case InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
case InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
break;
case InputAction.UseSelected:
UseCurrentSelection();
break;
Plus three private helper methods on GameWindow:
PickAndStoreSelection(bool useImmediately): pull_lastMouseX/Y,_cameraController.Active.View/Projection,_window.Size; callWorldPicker.BuildRay→WorldPicker.Pick; on hit, set_selectedGuid = picked, toast "Selected: {name}", emit diagnostic[B.4b] pick guid=0x{picked:X8} name={DescribeLiveEntity(picked)}. IfuseImmediately, also callSendUse(picked). On miss, toast "Nothing to select" (no diagnostic line, no state change).UseCurrentSelection(): if_selectedGuid is uint sel, callSendUse(sel). Otherwise toast "Nothing selected".SendUse(uint guid): gate on_liveSession?.CurrentState == InWorld;seq = _liveSession.NextGameActionSequence();body = InteractRequests.BuildUse(seq, guid);_liveSession.SendGameAction(body); diagnostic[B.4b] use guid=0x{guid:X8} seq={seq}.
All three switch branches honor the existing if (activation != ActivationType.Press) return; filter above the switch.
Data flow (happy path)
mouse double-click on door at pixel (540, 320)
-> Silk.NET window event
InputDispatcher.OnMouseDown -> DoubleClick chord match
->
InputDispatcher.Fired(SelectDblLeft, Press)
->
GameWindow.OnInputAction(SelectDblLeft, Press)
->
PickAndStoreSelection(useImmediately: true)
-> pulls _lastMouseX/Y + _cameraController.Active.View/Projection
WorldPicker.BuildRay(540, 320, vpW, vpH, view, proj) -> (origin, dir)
->
WorldPicker.Pick(origin, dir, _entitiesByServerGuid.Values,
_playerServerGuid, 50f) -> 0xDoorGuid
->
_selectedGuid = 0xDoorGuid
toast "Selected: Door"
log "[B.4b] pick guid=0x000F4244 name=Door"
->
SendUse(0xDoorGuid)
->
seq = _liveSession.NextGameActionSequence()
body = InteractRequests.BuildUse(seq, 0xDoorGuid)
_liveSession.SendGameAction(body)
log "[B.4b] use guid=0x000F4244 seq=N"
-> ACE processes Use, calls Door.Open()
ACE broadcasts UpdateMotion(NonCombat,On) -> swing animation
ACE broadcasts SetState(guid=0xDoor, state=0x14)
->
WorldSession.StateUpdated event fires (L.2g slice 1 path)
->
ShadowObjectRegistry.UpdatePhysicsState(doorGuid, 0x14)
-> next physics tick
CollisionExemption.ShouldSkip returns true -> door no longer blocks
->
player walks through doorway
Error handling / edge cases
- No entity hit (clicked on terrain, sky, empty space):
Pickreturnsnull. No_selectedGuidchange. Toast "Nothing to select". No network send. - ImGui consuming the click:
InputDispatcheralready filters viawantCaptureMouse.OnInputActiononly fires when the click was outside ImGui panels. No new guard needed. - No live session / not in world:
SendUseshort-circuits early. Toast "Not in world" for debug visibility. Picker still runs (cheap; fine to leave selection state updated even offline). UseSelectedwith no current selection: toast "Nothing selected". No network send.- Selected entity despawns between select and use:
BuildUsestill sends the cached guid. ACE replies withUseDonecarryingWeenieError.InvalidObject(a non-zero error code). That error already flows into the chat-log channel via the existingGameEventType.UseDonehandler; no new code needed. - Ray construction degenerate: if
direction.LengthSquared() < eps, treat as no-hit and return null fromPick. Defensive — should never trigger for sane view/proj matrices. - Entity at exactly
_selectedGuiddespawns silently while selected (e.g. NPC walks out of streaming range):_selectedGuidbecomes a stale reference. Acceptable for B.4b — the nextUseSelectedeither sends a guid the server now doesn't recognize (server replies with an error, harmless) or the player picks a new target before pressing R. Stale-selection cleanup is M2 HUD work.
Testing
Unit tests — tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
(new file in existing test project):
| Test | Scenario | Asserts |
|---|---|---|
BuildRay_CenterOfViewport_ReturnsForwardRay |
mouse at (vpW/2, vpH/2), identity view, simple perspective proj | direction approx -Z (camera-forward) within eps |
BuildRay_OffsetMouse_DeflectsRay |
mouse right-of-center, same camera | direction.X > 0 (deflects toward camera-right) |
Pick_RayThroughEntity_ReturnsServerGuid |
synthetic entity at (0,0,-10) with ServerGuid=0xABCD, ray from origin along -Z | returns 0xABCD |
Pick_RayMisses_ReturnsNull |
same entity, ray aimed at +X | returns null |
Pick_TwoEntitiesInLine_ReturnsCloser |
entities at -5 and -10, ray along -Z | returns the -5 one |
Pick_SkipsSkipGuid |
one entity at -10 with guid=0xABCD, skipServerGuid=0xABCD | returns null |
Pick_SkipsZeroServerGuid |
entity with ServerGuid=0 (dat-hydrated scenery) in path | returns null |
Pick_BeyondMaxDistance_ReturnsNull |
entity at -100, default maxDist=50 | returns null |
Switch-case behavior — not unit-tested. Would require mocking
GameWindow + WorldSession + InputDispatcher + CameraController,
high cost low value for a 3-case wiring change. Verified at runtime via
visual test.
Runtime verification — Holtburg inn doorway scenario (per the L.2g slice 1 handoff reproducibility recipe):
$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"
Then in-client: walk to the Holtburg inn doorway, double-left-click the closed door, wait for swing animation, walk through. After 30s, watch auto-close.
Expected log grep:
Select-String -Path launch-b4b.log -Pattern `
"B.4b|setstate-hex|setstate.*guid|input.*SelectDblLeft|entity-source.*Door"
Expected matches:
[input] SelectDblLeft Press(dispatcher fires — already worked pre-B.4b)- NEW:
[B.4b] use guid=0x000F4244 seq=N(B.4b send fires) [setstate-hex] body.len=16 ...(server replied — L.2g hex probe)[setstate] guid=0x000F4244 state=0x00000014(door opens — L.2g per-tick probe) — NB: if state is0x4only (not0x14), follow the L.2g slice-1 review's "Important note" → file a tiny L.2g slice 1b to widenCollisionExemption.ShouldSkip.[setstate] guid=0x000F4244 state=0x00000000~30s later (auto-close).- Player visibly walks through doorway during the open window.
Slice plan
This is one slice. No further sub-slicing.
| Step | Files | LOC | Subagent? |
|---|---|---|---|
1. Write WorldPickerTests.cs (TDD: tests first) |
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs (new) |
~80 | Yes (Sonnet) — bounded TDD task |
2. Create WorldPicker.cs static helper |
src/AcDream.Core/Selection/WorldPicker.cs (new) |
~50 | Same agent as step 1 |
3. Rename _selectedTargetGuid → _selectedGuid in GameWindow.cs |
1 file edit | ~5 sites | Manual or Sonnet |
4. Add 3 switch cases + 3 helper methods in GameWindow.OnInputAction |
1 file edit | ~40 | Manual or Sonnet |
5. dotnet build + dotnet test green |
— | — | Manual |
| 6. Visual test at Holtburg inn doorway + log grep | — | — | Manual (user) |
| 7. Commit + close #57 + update roadmap + update memory | — | — | Manual |
Total: ~80 LOC new code + ~80 LOC tests + ~50 LOC edits. One commit (or two: picker + test as one, handler wiring + rename as another).
Acceptance criteria
dotnet buildgreendotnet testgreen; 8 newWorldPickerTestspass- Double-left-click on closed door in Holtburg inn doorway:
- Log shows
[B.4b] pick guid=0x... name=Door - Log shows
[B.4b] use guid=0x... seq=N - Log shows
[setstate] guid=0x... state=0x14(or0x4) shortly after - Door swings open visually (animation plays)
- Player can walk through threshold (no
RESOLVE-line wall hits)
- Log shows
- R hotkey with no selection: toast "Nothing selected", no send.
- R hotkey after selecting a door (single click) but not using it
(no double-click): sends
BuildUsefor the same guid. - Single left-click on terrain (or sky): toast "Nothing to select", no send.
- Q-cycle (combat closest-target) still works after the
_selectedTargetGuid→_selectedGuidrename. - ISSUES.md #57 moved to "Recently closed" with this commit's SHA.
- Roadmap "shipped" table updated.
- CLAUDE.md "Currently in Phase L.2" paragraph updated to reflect L.2g slice 1 + B.4b verified, next phase candidate is the next preference-order item from the candidate list.
Non-goals / explicitly deferred
BuildPickUp(F-key pickup) —InteractRequestsdoesn't have this builder yet. Out of M1 critical path; file as a follow-up note.UseWithTarget— wire builder exists but no client-side UX yet (cursor-on-item then click-on-target). M2 work.SelectionStateas a class withSelectionChangedevent — wait for HUD consumer in M2.- Hover-highlight / cursor change on hover — UX polish, M2/M3.
- Right-click
SelectRightradial menu — M3. - Selected-entity HUD widget (name, vitals) — M2.
- Stale-selection auto-clear when target despawns — M2 HUD work.
- Mesh-accurate picking (vs. 5m sphere) — optimization for later; the 5m sphere is the retail "fast bbox" first pass, which retail followed with a per-triangle test on the candidate. Add only if a visual-test session reports a wrong-entity pick.
Risks / open questions
| Risk | Mitigation |
|---|---|
| 5m sphere too generous at doorways — picks the wall or NPC inside the inn instead of the door | First visual test pass settles it. If it picks the wrong entity, tighten the radius to 3m or add a closer-than-furthest tiebreak by entity type. |
| Camera-mode mismatch — in fly/orbit mode the ray origin should be the camera position, not the player. | Resolved by using _cameraController.Active.View which is the camera's view matrix regardless of mode. The picker doesn't care about player position. |
State value 0x4 vs 0x14 — L.2g slice-1 review flagged that CollisionExemption.ShouldSkip requires both ETHEREAL (0x4) AND IGNORE_COLLISIONS (0x10). If ACE sends only 0x4, the exemption won't fire. |
Settled by the same visual test's [setstate] log line. If 0x4 only, file a tiny L.2g slice 1b to widen the check; that's a one-line edit and out of B.4b scope. |
_lastMouseX/Y at click time vs. dispatcher-fire time — if there's a frame of latency between Silk's mouse-down event and the dispatcher fire, the mouse may have moved. |
Silk fires mouse-down synchronously; _lastMouseX/Y are updated on every move, so they hold the click position at the moment the dispatcher fires. Verified by reading the existing OnMouseDown path. Low risk. |
Entity not in _entitiesByServerGuid despite being visible — e.g. dat-hydrated EnvCell statics have ServerGuid=0 and won't be pickable |
Acceptable for B.4b. Doors and NPCs in Holtburg are server-spawned with non-zero ServerGuid. Dat-hydrated statics (fireplaces, decorations) aren't meant to be Use-able. |
Open question after slice ships (L.2g slice 1b)
The L.2g slice-1 final-review "Important note" — does ACE's
PhysicsObj.cs:787-791 set both ETHEREAL_PS (0x4) AND
IGNORE_COLLISIONS_PS (0x10) simultaneously when doors open, or only
ETHEREAL? B.4b's visual test settles this. If the hex shows 0x4
alone, file L.2g slice 1b to either widen CollisionExemption.ShouldSkip
to ((state & ETHEREAL_PS) != 0) alone, or set both bits in
UpdatePhysicsState. Decision deferred until evidence lands.
Reproducibility
Same launch recipe as L.2g slice 1 (above). Visual verification is the same scenario — both L.2g slice 1 and B.4b verified together. No separate L.2g visual test session needed.
Worktree
Branch: claude/compassionate-wilson-23ff99, worktree
compassionate-wilson-23ff99. Clean off main (commit eea9b4d =
the L.2g slice 1 merge from the previous session).
After ship: merge to main, close #57, update CLAUDE.md + roadmap + memory, archive this spec + the impl plan.