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:
commit
3e08e109d6
13 changed files with 2352 additions and 113 deletions
67
CLAUDE.md
67
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
160
docs/ISSUES.md
160
docs/ISSUES.md
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 30–50× 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:
|
||||
|
|
|
|||
417
docs/research/2026-05-13-b4b-shipped-handoff.md
Normal file
417
docs/research/2026-05-13-b4b-shipped-handoff.md
Normal 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.
|
||||
788
docs/superpowers/plans/2026-05-13-phase-b4b-plan.md
Normal file
788
docs/superpowers/plans/2026-05-13-phase-b4b-plan.md
Normal 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`).
|
||||
458
docs/superpowers/specs/2026-05-13-phase-b4b-design.md
Normal file
458
docs/superpowers/specs/2026-05-13-phase-b4b-design.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
120
src/AcDream.Core/Selection/WorldPicker.cs
Normal file
120
src/AcDream.Core/Selection/WorldPicker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
169
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
Normal file
169
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue