Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slices 1b/1c

Phase B.4b shipped end-to-end with 9 code commits + 4 bonus discoveries
during visual testing. M1 demo target 'open the inn door' verified at
Holtburg.

Code:
- WorldPicker (BuildRay + Pick, AcDream.Core.Selection)
- GameWindow.OnInputAction wires SelectLeft/SelectDblLeft/UseSelected
- _selectedTargetGuid -> _selectedGuid (unified combat + interaction)
- InputDispatcher double-click detection (was dead code)
- DoubleClick activation gate in OnInputAction
- L.2g slice 1b: CollisionExemption widened to ETHEREAL alone
- L.2g slice 1c: ServerGuid -> entity.Id translation (silent blocker)

Closes #57. Files #58 (door swing animation), #59 (picker per-entity
radius), #60 (obstruction_ethereal port). Final whole-branch code
review (Opus) approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-13 21:13:12 +02:00
commit 3e08e109d6
13 changed files with 2352 additions and 113 deletions

View file

@ -618,27 +618,23 @@ acdream's plan lives in two files committed to the repo:
approval.
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 shipped 2026-05-12. L.2g slice 1
is CODE-COMPLETE (parser + registry mutator + WorldSession dispatcher +
GameWindow subscriber, 4 commits, build green, 6 new tests pass), but
its visual verification is **deferred to the B.4b session** — clicking
on a door does nothing today because Phase B.4's input-action handler
was never wired (the wire builders and bindings exist, but
`GameWindow.OnInputAction` has no case for `SelectDblLeft`, so the
outbound Use never sends). **The natural next step is Phase B.4b —
finish the outbound Use handler wiring** (subscribe `SelectDblLeft`
`WorldPicker.Pick``InteractRequests.BuildUse` → send), then re-run
the Holtburg inn-doorway visual test which verifies both L.2g slice 1
and B.4b in one pass. Estimated 30-50 LOC, ~30 min, 1-2 subagent
dispatches.
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
**Phase B.4b** all shipped and visual-verified 2026-05-13. The M1 demo
target *"open the inn door"* is met: double-click a door in the Holtburg
inn doorway → `WorldPicker.Pick` finds the door entity → `BuildUse` sends
`0xF7B1/0x0036` to ACE → ACE broadcasts `SetState (0xF74B)` with `ETHEREAL`
bit → `ShadowObjectRegistry.UpdatePhysicsState` (L.2g slice 1) mutates the
cached state (via fixed ServerGuid→entity.Id translation, L.2g slice 1c) →
`CollisionExemption.ShouldSkip` exempts on ETHEREAL-alone (L.2g slice 1b) →
player walks through. Issue #57 (B.4 handler gap) is closed. Issue #58
(door swing animation — `UpdateMotion 0xF74D` routing for non-creature
entities) is filed as M1-deferred polish.
L.2g slice 1 ship handoff: [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md)
— full evidence + the 4 minor review notes + the 1 Important test-coverage
gap for `ShouldSkip` (the B.4b visual test's hex-dump will settle whether
ACE sends `state=0x4` alone or `0x14`).
Design spec: [`docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md`](docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md).
Implementation plan: [`docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md`](docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md).
L.2d ship handoff: [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
**B.4b ship handoff:** [`docs/research/2026-05-13-b4b-shipped-handoff.md`](docs/research/2026-05-13-b4b-shipped-handoff.md)
— full evidence for the 9 commits + 4 bonus discoveries (double-click dead
code, DoubleClick gate, CollisionExemption, ServerGuid→Id translation).
**L.2g slice 1 ship handoff:** [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md).
**L.2d ship handoff:** [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
**Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
Three commits land the L.2 "make every bad movement outcome explainable"
@ -716,30 +712,17 @@ together comprise the streaming + rendering perf foundation for the
project.
**Next phase candidates (in rough preference order):**
- **Phase B.4b — finish the outbound Use handler wiring.**
Direct M1 blocker discovered while running the L.2g slice 1 visual
test: the wire builders (`InteractRequests.BuildUse`), classes
(`SelectionState`, `WorldPicker`), input-action enum entries
(`SelectDblLeft` etc.), and keybindings (LMB-dblclick → `SelectDblLeft`)
all ship today, but `GameWindow.OnInputAction`'s switch has NO case
for any of the `Select*` actions — clicking on a door fires the
diagnostic `[input] SelectDblLeft Press` but nothing downstream
listens. Memory file `project_interaction_pipeline.md` updated to
reflect this reality. Shape: subscribe `InputAction.SelectDblLeft`
→ build a world ray from current mouse → `WorldPicker.Pick(...)`
store in `_selection` → call `InteractRequests.BuildUse(seq, guid)`
+ `_liveSession.SendGameMessage(body)`. Probably also subscribe
`SelectLeft` for select-without-use and `UseSelected` for the R
hotkey. Estimate: 30-50 LOC, 1-2 subagent dispatches, ~30 min.
Verifies L.2g slice 1 in the same Holtburg-doorway visual test once
it lands. Full context:
[`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md)
"Why the visual test is deferred" section.
- **Issue #58 — Door swing animation.** Route `UpdateMotion (0xF74D)` to
non-creature entities so the door visually swings when opened. M1 polish
but not blocking. Scope unknown until a spike: could be 30 min (simple
routing) or 2 hrs (AnimationSequencer audit for creature-specific
assumptions). Start with a spike in `OnLiveMotionUpdated` to see how
far the AnimationSequencer cooperates with non-creature entities.
- **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #50 (stray tree), #41 (remote-motion blips) have been open since
April/early-May and keep getting deferred. Either link each to a future
phase or downgrade. ~1 hour, surfaces what's chronic vs. linked-to-a-phase.
coat), #41 (remote-motion blips) have been open since April/early-May and
keep getting deferred. Either link each to a future phase or downgrade.
~1 hour, surfaces what's chronic vs. linked-to-a-phase.
- **More Phase C visual-fidelity work** (C.2 dynamic point lights, C.3
palette tuning, C.4 double-sided translucent polys) closing the
"world reads as old / broken vs. retail" backlog.

View file

@ -46,62 +46,122 @@ Copy this block when adding a new issue:
# Active issues
## #57 — B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
## #60 — `obstruction_ethereal` retail downstream path not ported (M2 combat-HUD impact)
**Status:** OPEN
**Severity:** HIGH (M1 blocker — demo target *"open the inn door, click an NPC, pick up an item"* is fully blocked)
**Severity:** LOW for M1 (no observable defect); MEDIUM for M2 (combat contact reporting on ethereal creatures will be wrong)
**Filed:** 2026-05-13 (final-review surfaced from B.4b)
**Component:** physics / `CollisionExemption.ShouldSkip` + downstream movement contact handling
**Description:** B.4b's L.2g slice 1b widened `CollisionExemption.ShouldSkip` to exempt
on `ETHEREAL_PS` alone (cite `src/AcDream.Core/Physics/CollisionExemption.cs:62-79`). Retail's
`acclient_2013_pseudo_c.txt:276782` requires both `ETHEREAL_PS && IGNORE_COLLISIONS_PS` to wrap
the entire `FindObjCollisions` body — ETHEREAL alone takes the deeper path at line 276795 which
sets `sphere_path.obstruction_ethereal = 1` and lets downstream movement allow passage WHILE
STILL REPORTING THE CONTACT. We do not port that downstream path; we just exempt entirely.
**M2 impact:** Combat HUD work that relies on physics-contact reporting for ethereal creatures
(ghosts, partially-phased monsters, spell projectiles with ETHEREAL set) will see no contact at
all instead of "soft contact with obstruction_ethereal=1". The user will not be able to target
or interact with such entities via the contact path.
**Acceptance:** Port the retail deeper path so `obstruction_ethereal=1` flows through movement +
collision-reporting layers. Tests should cover: ETHEREAL creature target → contact reported but
passage allowed; ETHEREAL+IGNORE_COLLISIONS target (door, retail-style) → full exempt.
**Estimated scope:** Moderate. Touches `CollisionExemption.cs`, transition/movement layer, and
sphere-path state propagation. Visible test through a spawned ethereal creature in ACE.
---
## #59`WorldPicker` 5m fixed-radius could over-pick at tight thresholds (M1-deferred polish)
**Status:** OPEN
**Severity:** LOW (cosmetic — picker grabs the right entity in Holtburg-tested scenarios)
**Filed:** 2026-05-13 (final-review surfaced from B.4b)
**Component:** selection / `AcDream.Core.Selection.WorldPicker.Pick`
**Description:** `WorldPicker.Pick` uses a hardcoded 5m sphere around every candidate's
`Position` regardless of the entity's actual size (`src/AcDream.Core/Selection/WorldPicker.cs:82`).
This matches `WorldEntity.DefaultAabbRadius` and is sufficient for M1 acceptance: in tight
doorways, every server-keyed candidate has correct sphere coverage and the closest-wins logic
plus `ServerGuid==0` skip filter the wrong picks. But the invariant "non-clickable geometry has
`ServerGuid==0`" is load-bearing — if L.2d ever ports `CBuildingObj` as a server-keyed entity,
the picker may mis-target buildings. Per-entity `Setup.Radius` would be tighter.
**Acceptance:** Either (a) tighten picker to read per-entity Setup.Radius / CylSphere bounds,
or (b) document the invariant in `WorldPicker.cs` and add a regression test asserting
`ServerGuid==0` entities never reach the per-candidate hit test.
**Estimated scope:** Quick (~1 hour) — wire `Setup.Radius` lookup into the picker and update
the 6 existing picker tests with realistic radii.
---
## #58 — Door swing animation: UpdateMotion not wired for non-creature entities
**Status:** OPEN
**Severity:** MEDIUM (M1 demo cosmetic — doors function but don't visually animate)
**Filed:** 2026-05-13
**Component:** animation / `UpdateMotion (0xF74D)` routing for non-creature entities
**Description:** B.4b shipped end-to-end interaction (click → BuildUse →
SetState → collision exempt → walk through). When ACE opens a door it
broadcasts TWO packets: `SetState (0xF74B)` (the collision-bit flip,
handled by L.2g) AND `UpdateMotion (0xF74D)` with `(NonCombat, On)` (the
swing animation cycle, NOT handled). acdream's `UpdateMotion` pipeline is
currently scoped to player + creature animation (Phase L.3); door entities
do not receive cycle commands.
**Root cause / status:** The `UpdateMotion` packet handler in
`GameWindow.OnLiveMotionUpdated` filters to player + creature entity types.
Non-creature WorldEntity instances (doors, chests, etc.) silently drop
the `(NonCombat, On)` cycle command that ACE sends when the door opens.
**Files (likely):**
- `src/AcDream.App/Rendering/GameWindow.cs``OnLiveMotionUpdated` handler
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — may have creature-specific assumptions
- The entity-spawn adapter (unknown if non-creature entities are wired to an AnimationSequencer at all)
**Acceptance:** Double-click a door → swing animation plays → ~30s later the
door auto-close animation plays. Log shows `UpdateMotion (NonCombat, On)` processed
for the door entity.
**Estimated scope:** Unknown. Could be quick (route UpdateMotion to non-creature
WorldEntity with cycle dispatch, ~30 min) or moderate (AnimationSequencer audit
for creature-specific assumptions, ~2 hrs). Spike before committing to estimate.
---
## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
**Status:** DONE
**Closed:** 2026-05-13
**Severity:** HIGH (was M1 blocker)
**Filed:** 2026-05-12
**Component:** input / interaction / `GameWindow.OnInputAction`
**Description:** Discovered 2026-05-12 while running the L.2g slice 1
visual test. Phase B.4 (2026-04-28) shipped half of itself: the wire-
message builders (`InteractRequests.BuildUse` / `BuildUseWithTarget` /
`BuildPickUp`), the supporting classes (`SelectionState`, `WorldPicker`),
the `InputAction` enum entries (`SelectLeft`, `SelectDblLeft`,
`SelectRight`, `SelectDblRight`, `UseSelected`, `SelectionPickUp`, etc.),
and the default keybindings (LMB → `SelectLeft`, LMB-dblclick →
`SelectDblLeft`, RMB → `SelectRight`, R → `UseSelected`, F →
`SelectionPickUp`). What was never shipped: a case for ANY of those
actions in `GameWindow.OnInputAction`'s switch. The runtime diagnostic
`[input] SelectLeft Press` fires when you click — confirming the
dispatcher resolves the chord — but nothing downstream listens, so the
click silently does nothing. Neither does double-click, R, or F. The
inbound side (`MoveToObjectReceived`, `StateUpdated` after L.2g slice 1)
is wired and ready; the block is purely outbound.
**Closure:** Closed by Phase B.4b on branch `claude/compassionate-wilson-23ff99`
(9 implementation commits, Tasks 1-4 per plan + 4 bonus fixes). The
full round-trip — double-click door → `WorldPicker.BuildRay` + `Pick`
`InteractRequests.BuildUse` → ACE `SetState` reply → `ShadowObjectRegistry`
mutation (via fixed ServerGuid→entity.Id translation) → `CollisionExemption.ShouldSkip`
exempts (widened to ETHEREAL-alone) → player walks through — was
visual-verified at the Holtburg inn doorway 2026-05-13. Four bonus
discoveries were required beyond the original plan: (1) `InputDispatcher`
had no double-click detection, (2) `OnInputAction` gate blocked
`DoubleClick` activations, (3) `CollisionExemption` required both
ETHEREAL+IGNORE_COLLISIONS while ACE sends only ETHEREAL, (4)
`OnLiveStateUpdated` passed server GUID to a local-entity-ID-keyed
registry. M1 demo target "open the inn door" met. See
[docs/research/2026-05-13-b4b-shipped-handoff.md](research/2026-05-13-b4b-shipped-handoff.md)
for full evidence and rationale.
**Root cause / status:** B.4 handler integration step was evidently
dropped or never landed. Memory file
`memory/project_interaction_pipeline.md` was updated 2026-05-12 to
reflect this reality (previous text claimed shipped).
**Files:**
- `src/AcDream.App/Rendering/GameWindow.cs``OnInputAction` switch
around line 8546+ has no `Select*` cases.
- `src/AcDream.Core.Net/Messages/InteractRequests.cs` — wire builders
exist but have zero callers in `src/`.
- `src/AcDream.Core/Selection/SelectionState.cs` — class exists, zero
production callers.
- `src/AcDream.App/Rendering/WorldPicker.cs` — class exists, zero
production callers.
- `src/AcDream.UI.Abstractions/Input/KeyBindings.cs:300-320` — bindings
for `SelectLeft` / `SelectDblLeft` / `SelectRight` / `SelectDblMid`
exist.
**Research:** [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](research/2026-05-12-l2g-slice1-shipped-handoff.md)
"Why the visual test is deferred" section has the full investigation.
**Acceptance:** Double-left-clicking on a door in the Holtburg inn
doorway sends a `0xF7B1 / 0x0036 Use` to the server, the server flips
the door's `Ethereal` bit and broadcasts `SetState (0xF74B)`, the
L.2g-slice-1 chain mutates `ShadowObjectRegistry`, the
`CollisionExemption.ShouldSkip` check honors it, and the player can
walk through the doorway. Visual verification + log grep (per the
L.2g handoff's reproducibility recipe) both pass.
**Status promotion:** This is a phase-sized follow-up (estimated
30-50 LOC, ~30 min). Promoted to **Phase B.4b** in the L.2 milestone
context and the CLAUDE.md "Next phase candidates" list. Will be closed
as `DONE (promoted to Phase B.4b)` once that phase's design spec lands.
**Files (what shipped):**
- `src/AcDream.Core/Selection/WorldPicker.cs` (new; formerly zero callers, now wired)
- `src/AcDream.App/Rendering/GameWindow.cs``OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `OnLiveStateUpdated` ServerGuid→Id translation; `_entitiesByServerGuid` reverse-lookup dict
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` — double-click detection
- `src/AcDream.Core/Physics/CollisionExemption.cs` — widened to ETHEREAL-alone
---

View file

@ -65,6 +65,7 @@
| N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ |
| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 3050× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ |
| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity``OnCreate`, `RemoveEntityByServerGuid``OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) |
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number:

View file

@ -0,0 +1,417 @@
# 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.

View file

@ -0,0 +1,788 @@
# Phase B.4b — Outbound Use Handler Wiring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire double-left-click and the R hotkey to a server `BuildUse` packet via a new `WorldPicker` so the M1 demo target *"open the inn door"* works and L.2g slice 1's deferred visual test verifies in the same scenario.
**Architecture:** New static `AcDream.Core.Selection.WorldPicker` (pure `BuildRay` + `Pick` functions, no state); rename `_selectedTargetGuid``_selectedGuid` on `GameWindow` (unify combat + interaction selection on one field); add three switch cases (`SelectLeft`, `SelectDblLeft`, `UseSelected`) to `GameWindow.OnInputAction` calling three private helpers (`PickAndStoreSelection`, `UseCurrentSelection`, `SendUse`). Spec: [`docs/superpowers/specs/2026-05-13-phase-b4b-design.md`](../specs/2026-05-13-phase-b4b-design.md).
**Tech Stack:** C# .NET 10 · xUnit · Silk.NET · System.Numerics
---
## File map
| File | Op | Why |
|---|---|---|
| `src/AcDream.Core/Selection/WorldPicker.cs` | Create | Static helper with `BuildRay(mouse→world ray)` + `Pick(ray→entity guid)`. No state, no deps beyond `WorldEntity` + `System.Numerics`. |
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` | Create | 8 xUnit `[Fact]`s covering BuildRay (center + offset) and Pick (hit/miss/closer/skip-guid/skip-zero/max-distance). |
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Rename `_selectedTargetGuid``_selectedGuid` (~5 sites). Add 3 switch cases + 3 helper methods. |
No solution-file edits. New files land in existing projects (`AcDream.Core` for the picker, `AcDream.Core.Tests` for its tests; `AcDream.App` for the handler).
---
## Task 1 — `WorldPicker.BuildRay` (TDD)
**Files:**
- Create: `src/AcDream.Core/Selection/WorldPicker.cs`
- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
- [ ] **Step 1: Write the failing tests for `BuildRay`**
Create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` with:
```csharp
using System;
using System.Numerics;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerTests
{
private const float Epsilon = 0.01f;
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
fieldOfView: MathF.PI / 3f,
aspectRatio: 16f / 9f,
nearPlaneDistance: 0.1f,
farPlaneDistance: 100f);
return (view, proj);
}
[Fact]
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW / 2f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
// Right-handed perspective + identity view -> camera looks down -Z.
// Center pixel ray = (0, 0, -1) within float epsilon.
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
}
[Fact]
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
}
}
```
- [ ] **Step 2: Run the tests, expect fail (class doesn't exist)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: build error `CS0246: The type or namespace name 'WorldPicker' could not be found` (or equivalent — `AcDream.Core.Selection` namespace doesn't exist yet).
- [ ] **Step 3: Create `WorldPicker.cs` with `BuildRay`**
Create `src/AcDream.Core/Selection/WorldPicker.cs`:
```csharp
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.Core.Selection;
/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// </summary>
public static class WorldPicker
{
/// <summary>
/// Unprojects a pixel coordinate to a world-space ray using the supplied
/// view + projection matrices (System.Numerics row-vector convention,
/// composed as view * projection — same as the rest of acdream's camera
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
/// </summary>
/// <returns>
/// (origin = world point on the near plane, direction = normalized
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
/// if the view-projection composition is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
{
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
float ndcX = (2f * mouseX) / viewportW - 1f;
float ndcY = 1f - (2f * mouseY) / viewportH;
var vp = view * projection;
if (!Matrix4x4.Invert(vp, out var invVp))
return (Vector3.Zero, Vector3.Zero);
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
var n4 = Vector4.Transform(nearClip, invVp);
var f4 = Vector4.Transform(farClip, invVp);
if (n4.W == 0f || f4.W == 0f)
return (Vector3.Zero, Vector3.Zero);
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
var dir = farWorld - nearWorld;
if (dir.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
}
```
- [ ] **Step 4: Run the tests, expect pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: `Passed: 2, Failed: 0`. Both `BuildRay_*` tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection
New AcDream.Core.Selection.WorldPicker static helper. BuildRay
unprojects pixel (mouseX, mouseY) through a view+projection matrix
pair into a world-space (origin, direction) ray. Used by
GameWindow.OnInputAction to drive entity picking on click.
Pure math, no state, no DI. Composes view*projection (System.Numerics
row-vector convention, matching the rest of acdream's camera path —
see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit
tests cover center-of-viewport (forward ray) and right-of-center
(positive-X deflection).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2 — `WorldPicker.Pick` (TDD)
**Files:**
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
- Modify: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
- [ ] **Step 1: Write the failing tests for `Pick`**
Append to `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (inside the same class, before the closing `}`):
```csharp
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
{
Id = serverGuid == 0u ? 1u : serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void Pick_RayThroughEntity_ReturnsServerGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
[Fact]
public void Pick_RayMisses_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitX,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Null(result);
}
[Fact]
public void Pick_TwoEntitiesInLine_ReturnsCloser()
{
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { far, near }, // iteration order shouldn't matter
skipServerGuid: 0u);
Assert.Equal(0x1111u, result);
}
[Fact]
public void Pick_SkipsSkipGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xABCDu);
Assert.Null(result);
}
[Fact]
public void Pick_SkipsZeroServerGuid()
{
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
// and aren't valid Use targets — server would reject guid=0.
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xDEADu);
Assert.Null(result);
}
[Fact]
public void Pick_BeyondMaxDistance_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u); // default maxDistance = 50f
Assert.Null(result);
}
```
Also add `using AcDream.Core.World;` to the top of `WorldPickerTests.cs` (next to the existing `using AcDream.Core.Selection;`).
- [ ] **Step 2: Run the tests, expect fail (Pick doesn't exist)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: build error `CS0117: 'WorldPicker' does not contain a definition for 'Pick'`.
- [ ] **Step 3: Add `Pick` to `WorldPicker.cs`**
Open `src/AcDream.Core/Selection/WorldPicker.cs`, add `using System;` and `using System.Collections.Generic;` to the imports, and append this method inside the `WorldPicker` class (after `BuildRay`):
```csharp
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <remarks>
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
/// statics) are skipped — they have no server-side identity and can't be
/// the target of a Use packet. The player's own guid is skipped via
/// <paramref name="skipServerGuid"/>.
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f)
{
const float Radius = 5f;
const float Radius2 = Radius * Radius;
if (direction.LengthSquared() < 1e-10f) return null;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
// the ray misses the sphere. Otherwise nearest intersection is
// t = -b - sqrt(discriminant).
var oc = origin - entity.Position;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - Radius2;
float d = b * b - c;
if (d < 0f) continue;
float t = -b - MathF.Sqrt(d);
if (t < 0f) continue; // ray points away or origin inside
if (t >= maxDistance) continue;
if (t < bestT)
{
bestT = t;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
```
- [ ] **Step 4: Run the tests, expect pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: `Passed: 8, Failed: 0`. All 8 `WorldPicker*` tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): WorldPicker.Pick — ray-sphere entity pick
Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance)
to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips
entities with ServerGuid==0 (atlas/dat-hydrated statics — no server
identity) and the caller's skipServerGuid (the player self).
Geometric ray-sphere intersection at 5m radius (matches
WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid
within maxDistance (50m default), or null on miss.
6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid,
skip-zero-server-guid, beyond-max-distance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3 — Rename `_selectedTargetGuid``_selectedGuid`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Refactor only — no behavior change. Unifies combat (Q-cycle) and interaction (B.4b click) selection on one field. Retail-faithful: AC has one "current target," not two.
- [ ] **Step 1: Locate every reference**
Run (Grep tool):
```
pattern: _selectedTargetGuid
path: src/AcDream.App/Rendering/GameWindow.cs
output: content with -n
```
Expected: ~5 hits, all inside `GameWindow.cs`. Then verify there are no references elsewhere:
```
pattern: _selectedTargetGuid
path: src
output: files_with_matches
```
Expected: only `GameWindow.cs` matches.
- [ ] **Step 2: Replace via the Edit tool (replace_all)**
Edit `src/AcDream.App/Rendering/GameWindow.cs` with `replace_all: true`:
- `old_string: _selectedTargetGuid`
- `new_string: _selectedGuid`
- [ ] **Step 3: Build green**
Run: `dotnet build -c Debug`
Expected: build succeeds with no new errors or warnings tied to the rename.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: 8 new `WorldPickerTests` pass on top of the prior baseline. The L.2g slice 1 handoff reported "1037 pass / 8 pre-existing-baseline fail." With +8 from Tasks 1+2, expect **1045 pass / 8 pre-existing fail**.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid
Retail's selection model is a single "current target" used by combat,
interaction, NPC dialog, and HUD alike — not two parallel selections.
Renames the existing combat-only field on GameWindow so the upcoming
B.4b click handler and the existing Q-cycle SelectClosestCombatTarget
share the same selection state.
Mechanical rename, no behavior change. Build + tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4 — Wire `OnInputAction` handlers
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Add three private helper methods + three switch cases. Switch-case behavior is verified at runtime (Task 5 visual test); helpers depend on `GameWindow` state and aren't unit-tested.
- [ ] **Step 1: Add the three helper methods**
Insert these three methods immediately above `SelectClosestCombatTarget` (around line 8706 — keep the selection-related helpers grouped). Use the `Edit` tool anchored on the line `private uint? SelectClosestCombatTarget(bool showToast)`:
`old_string`:
```
private uint? SelectClosestCombatTarget(bool showToast)
```
`new_string`:
```
// ============================================================
// Phase B.4b — outbound Use handler. Wires three input actions
// (LMB click select, LMB-double-click select+use, R hotkey
// use-selected) through WorldPicker into InteractRequests.BuildUse.
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
// ============================================================
private void PickAndStoreSelection(bool useImmediately)
{
if (_cameraController is null || _window is null) return;
var camera = _cameraController.Active;
var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
mouseX: _lastMouseX, mouseY: _lastMouseY,
viewportW: _window.Size.X, viewportH: _window.Size.Y,
view: camera.View, projection: camera.Projection);
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
var picked = AcDream.Core.Selection.WorldPicker.Pick(
origin, direction,
_entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
maxDistance: 50f);
if (picked is uint guid)
{
_selectedGuid = guid;
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
else
{
_debugVm?.AddToast("Nothing to select");
}
}
private void UseCurrentSelection()
{
if (_selectedGuid is uint sel)
SendUse(sel);
else
_debugVm?.AddToast("Nothing selected");
}
private void SendUse(uint guid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
{
_debugVm?.AddToast("Not in world");
return;
}
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
}
private uint? SelectClosestCombatTarget(bool showToast)
```
(The `Edit` replaces the single anchor line with the three new helpers + the same anchor line at the end, leaving `SelectClosestCombatTarget`'s body untouched.)
- [ ] **Step 2: Add the three switch cases**
In `GameWindow.OnInputAction`'s switch (currently `GameWindow.cs:8546-8646`), add three new `case` blocks immediately before the `case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:` branch.
Use the `Edit` tool anchored on:
`old_string`:
```
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
```
`new_string`:
```
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
UseCurrentSelection();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
```
- [ ] **Step 3: Build green**
Run: `dotnet build -c Debug`
Expected: build succeeds with no new errors. Any new warnings should be tied only to the additions.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: same **1045 pass / 8 pre-existing-baseline fail** from Task 3.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker
Closes #57. Adds three OnInputAction switch cases (SelectLeft,
SelectDblLeft, UseSelected) and three private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse). Single-click
selects but does not Use; double-click selects + Uses; R hotkey
sends Use on the existing _selectedGuid. ImGui mouse-capture
filtering already happens in InputDispatcher — no new guard needed.
Diagnostic lines emitted for log grep:
[B.4b] pick guid=0x{guid:X8} name={label}
[B.4b] use guid=0x{guid:X8} seq={seq}
Build green; tests 1045/1053 (8 pre-existing-baseline fails
unchanged). Switch-case behavior verified at runtime via the Holtburg
inn doorway visual test (per spec §Testing → Runtime verification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5 — Visual verification at Holtburg inn doorway
**This task is performed by the user.** The implementing agent runs the launch command (background) and reports completion; the user observes the running client and reports the result.
- [ ] **Step 1: Kill any stale client process**
Run via Bash tool:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
```
- [ ] **Step 2: Launch the client with B.4b + L.2g probes enabled**
Run via Bash tool with `run_in_background: true`:
```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"
```
- [ ] **Step 3: User performs the scenario**
In the running client:
1. Wait ~8s for the player to spawn at Holtburg.
2. Walk to the inn doorway (north side of the south building).
3. Double-left-click the closed door.
4. Observe: swing animation should play.
5. Walk forward through the open doorway.
6. Wait ~30s in the inn.
7. Observe: auto-close animation should fire.
8. Close the client window.
- [ ] **Step 4: Grep the log**
```powershell
Select-String -Path launch-b4b.log -Pattern `
"B\.4b|setstate-hex|\[setstate\]|input.*SelectDblLeft|entity-source.*Door"
```
Expected matches (approximate order):
- `[entity-source] name=Door ... state=0x00000000 flags=None ...` (door spawn at world load)
- `[input] SelectDblLeft Press` (dispatcher fires on the user's click)
- `[B.4b] pick guid=0x000F???? name=Door` (picker hit)
- `[B.4b] use guid=0x000F???? seq=N` (outbound Use fires)
- `[setstate-hex] body.len=16 ...` (L.2g hex probe — first SetState body)
- `[setstate] guid=0x000F???? state=0x00000014` (or `0x00000004`) (door opens)
- `[setstate] guid=0x000F???? state=0x00000000` ~30s later (auto-close)
- [ ] **Step 5: Decide on follow-up based on the observed state value**
- If the state bits include `0x10` (so the value is `0x14` or higher), `CollisionExemption.ShouldSkip` short-circuits as designed — no follow-up needed.
- If the state is `0x4` (ETHEREAL only, no IGNORE_COLLISIONS), file a tiny **L.2g slice 1b** to widen the check. The fix is a one-line edit to `src/AcDream.Core/Physics/CollisionExemption.cs`. **Out of B.4b scope** — record the finding and move on.
---
## Task 6 — Ship handoff + post-merge updates
**Files (all modified or created):**
- Create: `docs/research/2026-05-13-b4b-shipped-handoff.md`
- Modify: `docs/ISSUES.md` (close #57)
- Modify: `docs/plans/2026-04-11-roadmap.md` (add B.4b row to "shipped" table)
- Modify: `CLAUDE.md` ("Currently in Phase L.2" paragraph)
- Modify (outside-repo): `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`
- [ ] **Step 1: Write the ship-handoff doc**
Create `docs/research/2026-05-13-b4b-shipped-handoff.md` summarizing:
- 4 commits (BuildRay, Pick, rename, handler wiring)
- The actual `state=0x??` value observed in Task 5 step 4
- Whether L.2g slice 1b is needed (decided in Task 5 step 5)
- Whether picker tuning is needed (5m radius too generous/strict)
Use the same structure as `docs/research/2026-05-12-l2g-slice1-shipped-handoff.md` — TL;DR + commit table + end-to-end flow + open notes + reproducibility.
- [ ] **Step 2: Move #57 from Active to Recently Closed in `docs/ISSUES.md`**
Edit `docs/ISSUES.md`:
- Cut the `## #57 — B.4 interaction-handler missing` block from "Active issues".
- Paste it under "Recently closed" with header changed to `## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing` and add a **Closed:** line with this PR's merge commit SHA.
- [ ] **Step 3: Update the roadmap's shipped table**
Edit `docs/plans/2026-04-11-roadmap.md`. Add a new row to the "shipped" table (preserving existing column structure):
```
| 2026-05-13 | Phase B.4b — Outbound Use handler wiring | <commit> | Closes #57. WorldPicker + 3 switch cases. M1 demo target "open the inn door" verified at Holtburg. |
```
- [ ] **Step 4: Update `CLAUDE.md` "Currently in Phase L.2" paragraph**
Edit `CLAUDE.md`:
- Change the "L.2g slice 1 is CODE-COMPLETE..." paragraph to "L.2g slice 1 + B.4b shipped and visual-verified 2026-05-13 at the Holtburg inn doorway."
- Remove the "natural next step is Phase B.4b" paragraph; replace with the next phase candidate from the existing candidate list (the user picks order; in absence of new evidence, the **Triage open issues** option is the natural follow-up).
- [ ] **Step 5: Update the memory file**
Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`:
- Mark Phase B.4 outbound-handler gap as closed by B.4b (2026-05-13).
- Add the new flow: LMB-dblclick → WorldPicker → BuildUse → SendGameAction.
- Update the `WorldPicker` and `SelectionState` claims:
- `WorldPicker` now exists in `AcDream.Core.Selection`.
- `SelectionState` still doesn't exist — deferred to M2 HUD work.
- [ ] **Step 6: Commit the in-repo docs**
```bash
git add docs/research/2026-05-13-b4b-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(B.4b): ship handoff + close #57 + roadmap/CLAUDE update
L.2g slice 1 + B.4b verified at Holtburg inn doorway:
- Player double-clicks closed door
- BuildUse fires, ACE responds with SetState 0xF74B
- ShadowObjectRegistry mutates ETHEREAL bit
- CollisionExemption short-circuits, player walks through
- 30s auto-close fires on schedule
Closes #57. Updates roadmap shipped table and CLAUDE.md Phase L.2
paragraph. Memory file project_interaction_pipeline.md updated outside
the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(The memory file lives outside the repo and isn't tracked by git — update it but don't include it in the commit.)
- [ ] **Step 7: Merge to main**
```bash
git checkout main
git merge --no-ff claude/compassionate-wilson-23ff99 -m "Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slice 1 visual-verified"
```
Do NOT push without explicit user authorization (CLAUDE.md rule).
---
## Self-review against the spec
| Spec section | Plan task(s) | Coverage |
|---|---|---|
| §Architecture: `WorldPicker.cs` in `AcDream.Core.Selection` | Tasks 1, 2 | covered |
| §Architecture: rename `_selectedTargetGuid` | Task 3 | covered |
| §Architecture: 3 switch cases + 3 helpers | Task 4 | covered |
| §Components: `BuildRay` signature + math | Task 1 step 3 | covered |
| §Components: `Pick` signature + ServerGuid==0 skip | Task 2 step 3 | covered |
| §Components: `PickAndStoreSelection` toast + `[B.4b] pick` log | Task 4 step 1 | covered |
| §Components: `SendUse` gate + `[B.4b] use` log | Task 4 step 1 | covered |
| §Components: `UseCurrentSelection` | Task 4 step 1 | covered |
| §Components: 3 switch cases | Task 4 step 2 | covered |
| §Testing: 8 unit tests | Tasks 1+2 | covered (2 BuildRay + 6 Pick) |
| §Testing: runtime verification at Holtburg | Task 5 | covered |
| §Testing: log grep + state-value decision | Task 5 step 4-5 | covered |
| §Acceptance: build + tests green | Tasks 3+4 steps 3-4 | covered |
| §Acceptance: ISSUES.md #57 → Recently closed | Task 6 step 2 | covered |
| §Acceptance: roadmap update | Task 6 step 3 | covered |
| §Acceptance: CLAUDE.md update | Task 6 step 4 | covered |
| §Open question: state 0x4 vs 0x14 follow-up | Task 5 step 5 | covered (deferred to L.2g slice 1b if needed) |
| §Non-goals: BuildPickUp / UseWithTarget UX / SelectionState class | (none — explicitly deferred) | covered by omission |
No placeholders. No "TBD." Every code step has the actual code; every command step has the exact command and the expected output. Type names match across tasks (`WorldPicker.BuildRay` / `WorldPicker.Pick`, `_selectedGuid`, `PickAndStoreSelection` / `UseCurrentSelection` / `SendUse`).

View file

@ -0,0 +1,458 @@
# 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](../../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](../../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:
```csharp
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:
```csharp
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):
```csharp
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.BuildRay``WorldPicker.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 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):
```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"
```
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:
```powershell
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.

View file

@ -783,7 +783,8 @@ public sealed class GameWindow : IDisposable
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
private uint? _selectedTargetGuid;
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
private readonly record struct LiveEntityInfo(
string? Name,
AcDream.Core.Items.ItemType ItemType);
@ -2998,8 +2999,8 @@ public sealed class GameWindow : IDisposable
_liveEntityInfoByGuid.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
_lastSpawnByGuid.Remove(serverGuid);
if (_selectedTargetGuid == serverGuid)
_selectedTargetGuid = null;
if (_selectedGuid == serverGuid)
_selectedGuid = null;
if (logDelete)
_lightingSink?.UnregisterOwner(existingEntity.Id);
@ -3765,11 +3766,22 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
{
_physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState);
// L.2g slice 1c (2026-05-13): the server addresses entities by
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
// ShadowObjectRegistry's cell index is keyed by local entity.Id
// (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
// mutating the registry — otherwise the lookup misses and the
// state flip silently no-ops, leaving doors blocked even though
// ACE flipped the ETHEREAL bit.
uint registryKey = parsed.Guid;
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
registryKey = entity.Id;
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[setstate] guid=0x{parsed.Guid:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
}
private static bool IsRemoteLocomotion(uint motion)
@ -8517,8 +8529,10 @@ public sealed class GameWindow : IDisposable
// Every other action fires on Press only (no Release / Hold side-
// effects in the K.1b set). Filter out non-Press activations early
// so subscribers that have Release-mode bindings don't accidentally
// re-fire.
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
// re-fire. B.4b exception: DoubleClick must pass through so
// SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch.
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
// Q to start, press Q again to stop. Pressing Backup / Stop /
@ -8629,6 +8643,18 @@ public sealed class GameWindow : IDisposable
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
UseCurrentSelection();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
@ -8697,12 +8723,73 @@ public sealed class GameWindow : IDisposable
private uint? GetSelectedOrClosestCombatTarget()
{
if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected))
return selected;
return SelectClosestCombatTarget(showToast: false);
}
// ============================================================
// Phase B.4b — outbound Use handler. Wires three input actions
// (LMB click select, LMB-double-click select+use, R hotkey
// use-selected) through WorldPicker into InteractRequests.BuildUse.
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
// ============================================================
private void PickAndStoreSelection(bool useImmediately)
{
if (_cameraController is null || _window is null) return;
var camera = _cameraController.Active;
var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
mouseX: _lastMouseX, mouseY: _lastMouseY,
viewportW: _window.Size.X, viewportH: _window.Size.Y,
view: camera.View, projection: camera.Projection);
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
var picked = AcDream.Core.Selection.WorldPicker.Pick(
origin, direction,
_entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
maxDistance: 50f);
if (picked is uint guid)
{
_selectedGuid = guid;
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
else
{
_debugVm?.AddToast("Nothing to select");
}
}
private void UseCurrentSelection()
{
if (_selectedGuid is uint sel)
SendUse(sel);
else
_debugVm?.AddToast("Nothing selected");
}
private void SendUse(uint guid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
{
_debugVm?.AddToast("Not in world");
return;
}
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
}
private uint? SelectClosestCombatTarget(bool showToast)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
@ -8725,7 +8812,7 @@ public sealed class GameWindow : IDisposable
bestGuid = guid;
}
_selectedTargetGuid = bestGuid;
_selectedGuid = bestGuid;
if (bestGuid is { } selected)
{
string label = DescribeLiveEntity(selected);

View file

@ -59,14 +59,24 @@ public static class CollisionExemption
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
ObjectInfoState moverState)
{
// 1. Target ETHEREAL + IGNORE_COLLISIONS → walk through.
// acclient_2013_pseudo_c.txt:276782 — wraps the entire body of
// FindObjCollisions; we hoist it as the first early-out.
if ((targetState & ETHEREAL_PS) != 0
&& (targetState & IGNORE_COLLISIONS_PS) != 0)
{
// 1. Target ETHEREAL → walk through.
// Retail (acclient_2013_pseudo_c.txt:276782) requires BOTH
// ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) to wrap
// the entire body of FindObjCollisions and skip collision.
// ETHEREAL alone takes a different retail path (line 276795
// sets sphere_path.obstruction_ethereal = 1 and downstream
// movement allows passage despite the contact). We haven't
// ported that downstream path yet.
//
// L.2g slice 1b (2026-05-13): ACE's Door.Open() sends only
// ETHEREAL (state=0x0001000C observed live), not the
// ETHEREAL|IGNORE_COLLISIONS combo retail servers broadcast.
// Pragmatic shortcut: exempt on ETHEREAL alone so doors
// become passable when ACE flips the bit. Retail-server
// broadcasts (state=0x14+) still hit this branch correctly
// because both bits set implies ETHEREAL set.
if ((targetState & ETHEREAL_PS) != 0)
return true;
}
// 2. Viewer mover + creature target → walk through.
// acclient_2013_pseudo_c.txt:276787-276790.

View file

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.Core.Selection;
/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// </summary>
public static class WorldPicker
{
/// <summary>
/// Unprojects a pixel coordinate to a world-space ray using the supplied
/// view + projection matrices (System.Numerics row-vector convention,
/// composed as view * projection — same as the rest of acdream's camera
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
/// </summary>
/// <returns>
/// (origin = world point on the near plane, direction = normalized
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
/// if the view-projection composition is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
{
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
float ndcX = (2f * mouseX) / viewportW - 1f;
float ndcY = 1f - (2f * mouseY) / viewportH;
var vp = view * projection;
if (!Matrix4x4.Invert(vp, out var invVp))
return (Vector3.Zero, Vector3.Zero);
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
var n4 = Vector4.Transform(nearClip, invVp);
var f4 = Vector4.Transform(farClip, invVp);
if (n4.W == 0f || f4.W == 0f)
return (Vector3.Zero, Vector3.Zero);
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
var dir = farWorld - nearWorld;
if (dir.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <param name="direction">
/// World-space ray direction. <b>Must be normalized</b> — the geometric
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
/// makes <c>maxDistance</c> compare against ray-parameter units instead
/// of world meters.
/// </param>
/// <remarks>
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
/// statics) are skipped — they have no server-side identity and can't be
/// the target of a Use packet. The player's own guid is skipped via
/// <paramref name="skipServerGuid"/>.
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f)
{
const float Radius = 5f;
const float Radius2 = Radius * Radius;
if (direction.LengthSquared() < 1e-10f) return null;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
// the ray misses the sphere. Otherwise nearest intersection is
// t = -b - sqrt(discriminant).
var oc = origin - entity.Position;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - Radius2;
float d = b * b - c;
if (d < 0f) continue;
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
// to t_far so the entity is still pickable at point-blank range.
float sqrtD = MathF.Sqrt(d);
float t = -b - sqrtD;
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
if (t >= maxDistance) continue;
if (t < bestT)
{
bestT = t;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
}

View file

@ -38,6 +38,14 @@ public sealed class InputDispatcher
private readonly Stack<InputScope> _scopes = new();
private readonly HashSet<KeyChord> _heldHoldChords = new();
// Double-click detection. _lastMouseDownButton == null means no recent press.
// _lastMouseDownTickMs is Environment.TickCount64 at the time of that press.
// A subsequent mouse-down on the same button within DoubleClickThresholdMs
// additionally fires ActivationType.DoubleClick for the matching binding.
private MouseButton? _lastMouseDownButton;
private long _lastMouseDownTickMs;
private const long DoubleClickThresholdMs = 500;
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
/// chord is reported via this callback INSTEAD of firing actions. Esc
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
@ -325,6 +333,24 @@ public sealed class InputDispatcher
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
}
// Double-click recognition. Same button within DoubleClickThresholdMs
// -> additionally fire ActivationType.DoubleClick for any matching
// binding. Press has already fired for the second click (same as a
// single click); DoubleClick is the *additional* signal.
long nowMs = Environment.TickCount64;
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
var dbl = _bindings.Find(chord, ActivationType.DoubleClick);
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next
}
else
{
_lastMouseDownButton = button;
_lastMouseDownTickMs = nowMs;
}
}
private void OnMouseUp(MouseButton button, ModifierMask mods)

View file

@ -42,12 +42,16 @@ public class CollisionExemptionTests
}
[Fact]
public void EtherealOnly_NotSkipped()
public void EtherealOnly_Skipped()
{
// Target with ETHEREAL but NOT IGNORE_COLLISIONS does not bail
// out at the first gate — collision proceeds. (Step-down marks
// obstruction_ethereal, but does not exempt.)
Assert.False(CollisionExemption.ShouldSkip(
// L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision.
// Retail (acclient_2013_pseudo_c.txt:276782) required both bits,
// but ACE's Door.Open() broadcasts ETHEREAL alone — observed
// live: state=0x0001000C (HasPhysicsBSP | Ethereal | ReportCollisions).
// Pragmatic shortcut: widen the early-out to ETHEREAL alone so
// doors become passable when ACE flips the bit. Retail-server
// broadcasts (state=0x14+) still hit the same branch correctly.
Assert.True(CollisionExemption.ShouldSkip(
targetState: ETHEREAL_PS,
targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsPlayer));

View file

@ -0,0 +1,169 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerTests
{
private const float Epsilon = 0.01f;
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
fieldOfView: MathF.PI / 3f,
aspectRatio: 16f / 9f,
nearPlaneDistance: 0.1f,
farPlaneDistance: 100f);
return (view, proj);
}
[Fact]
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW / 2f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
// Right-handed perspective + identity view -> camera looks down -Z.
// Center pixel ray = (0, 0, -1) within float epsilon.
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
}
[Fact]
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
{
Id = serverGuid == 0u ? 1u : serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void Pick_RayThroughEntity_ReturnsServerGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
[Fact]
public void Pick_RayMisses_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitX,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Null(result);
}
[Fact]
public void Pick_TwoEntitiesInLine_ReturnsCloser()
{
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { far, near }, // iteration order shouldn't matter
skipServerGuid: 0u);
Assert.Equal(0x1111u, result);
}
[Fact]
public void Pick_SkipsSkipGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xABCDu);
Assert.Null(result);
}
[Fact]
public void Pick_SkipsZeroServerGuid()
{
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
// and aren't valid Use targets — server would reject guid=0.
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xDEADu);
Assert.Null(result);
}
[Fact]
public void Pick_BeyondMaxDistance_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u); // default maxDistance = 50f
Assert.Null(result);
}
[Fact]
public void Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid()
{
// Player ~3m from a door -> camera near-plane sits INSIDE the door's
// 5m bounding sphere. Naive t_near < 0 guard would skip; correct
// behavior is to fall through to t_far (the sphere exit point).
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -3));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
}

View file

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Threading;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Tests for double-click detection added to <see cref="InputDispatcher.OnMouseDown"/>
/// in Phase B.4b. The dispatcher tracks the most-recent mouse-down button +
/// timestamp; a same-button press within DoubleClickThresholdMs (500 ms)
/// additionally fires <see cref="ActivationType.DoubleClick"/> for the matching
/// binding on top of the normal <see cref="ActivationType.Press"/>.
/// </summary>
public class InputDispatcherDoubleClickTests
{
/// <summary>
/// Build a dispatcher wired with LMB Press → SelectLeft,
/// LMB DoubleClick → SelectDblLeft, and RMB Press → SelectRight.
/// </summary>
private static (InputDispatcher dispatcher, FakeMouseSource mouse, List<(InputAction, ActivationType)> fired)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var lmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
var rmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1);
bindings.Add(new Binding(lmbChord, InputAction.SelectLeft));
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, mouse, fired);
}
/// <summary>
/// Two LMB clicks in rapid succession (~10 ms) → Press fires twice AND
/// DoubleClick fires once (on the second click).
/// </summary>
[Fact]
public void SecondClick_WithinThreshold_FiresDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
// Two SelectLeft Press events (one per click).
Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
// One SelectDblLeft DoubleClick event on the second click.
Assert.Single(fired, e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick));
}
/// <summary>
/// Two LMB clicks 600 ms apart → Press fires twice but NO DoubleClick
/// (interval exceeds the 500 ms threshold).
/// </summary>
[Fact]
public void SecondClick_BeyondThreshold_DoesNotFireDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(600);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick));
}
/// <summary>
/// LMB then RMB in rapid succession → no DoubleClick (different buttons).
/// </summary>
[Fact]
public void DifferentButtons_DoNotFireDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick));
}
/// <summary>
/// Three rapid LMB clicks → exactly one DoubleClick (between clicks 1 and 2).
/// The third click resets the pair-state, so it acts as the "first click" of
/// a new potential double-click rather than firing a second DoubleClick.
/// </summary>
[Fact]
public void ThirdClick_AfterDoubleClick_RequiresFreshPair()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 1
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 2 → DoubleClick fires, state reset
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 3 → no DoubleClick (fresh pair started)
// Three Press events total.
Assert.Equal(3, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
// Exactly one DoubleClick (between clicks 1 and 2).
Assert.Single(fired.FindAll(e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick)));
}
}