acdream/docs/superpowers/specs/2026-05-13-phase-b4b-design.md
Erik ffa404d236 docs(B.4b): correct file paths — WorldPicker lives in AcDream.Core.Selection
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>
2026-05-13 17:31:49 +02:00

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 (InteractRequests wire builders + InputAction enum + 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 in src/. Doc-only.
  • SelectionState — does NOT exist in src/. Doc-only.
  • InteractRequests.BuildPickUp — does NOT exist; only BuildUse, BuildUseWithTarget, and BuildTeleToLifestone are 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:

  1. Rename field _selectedTargetGuid_selectedGuid (project-wide find/replace; ~5 call sites all inside GameWindow.cs). Unifies combat + interaction selection on one field. Retail-faithful: AC has one "current target" not two.
  2. 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 BuildUse against 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; call WorldPicker.BuildRayWorldPicker.Pick; on hit, set _selectedGuid = picked, toast "Selected: {name}", emit diagnostic [B.4b] pick guid=0x{picked:X8} name={DescribeLiveEntity(picked)}. If useImmediately, also call SendUse(picked). On miss, toast "Nothing to select" (no diagnostic line, no state change).
  • UseCurrentSelection(): if _selectedGuid is uint sel, call SendUse(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): Pick returns null. No _selectedGuid change. Toast "Nothing to select". No network send.
  • ImGui consuming the click: InputDispatcher already filters via wantCaptureMouse. OnInputAction only fires when the click was outside ImGui panels. No new guard needed.
  • No live session / not in world: SendUse short-circuits early. Toast "Not in world" for debug visibility. Picker still runs (cheap; fine to leave selection state updated even offline).
  • UseSelected with no current selection: toast "Nothing selected". No network send.
  • Selected entity despawns between select and use: BuildUse still sends the cached guid. ACE replies with UseDone carrying WeenieError.InvalidObject (a non-zero error code). That error already flows into the chat-log channel via the existing GameEventType.UseDone handler; no new code needed.
  • Ray construction degenerate: if direction.LengthSquared() < eps, treat as no-hit and return null from Pick. Defensive — should never trigger for sane view/proj matrices.
  • Entity at exactly _selectedGuid despawns silently while selected (e.g. NPC walks out of streaming range): _selectedGuid becomes a stale reference. Acceptable for B.4b — the next UseSelected either 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 teststests/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 is 0x4 only (not 0x14), follow the L.2g slice-1 review's "Important note" → file a tiny L.2g slice 1b to widen CollisionExemption.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 build green
  • dotnet test green; 8 new WorldPickerTests pass
  • 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 (or 0x4) shortly after
    • Door swings open visually (animation plays)
    • Player can walk through threshold (no RESOLVE-line wall hits)
  • 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 BuildUse for 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_selectedGuid rename.
  • 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)InteractRequests doesn'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.
  • SelectionState as a class with SelectionChanged event — wait for HUD consumer in M2.
  • Hover-highlight / cursor change on hover — UX polish, M2/M3.
  • Right-click SelectRight radial 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.