diff --git a/CLAUDE.md b/CLAUDE.md index 7e343da..6f42fee 100644 --- a/CLAUDE.md +++ b/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. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 19562fe..27cd88d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,62 +46,70 @@ Copy this block when adding a new issue: # Active issues -## #57 — B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing +## #58 — Door swing animation: UpdateMotion not wired for non-creature entities **Status:** OPEN -**Severity:** HIGH (M1 blocker — demo target *"open the inn door, click an NPC, pick up an item"* is fully blocked) +**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 --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1c52f9e..15feb14 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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(...)?.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: diff --git a/docs/research/2026-05-13-b4b-shipped-handoff.md b/docs/research/2026-05-13-b4b-shipped-handoff.md new file mode 100644 index 0000000..306429c --- /dev/null +++ b/docs/research/2026-05-13-b4b-shipped-handoff.md @@ -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:** Added a `_entitiesByServerGuid` reverse-lookup dictionary to +`GameWindow` (populated at entity registration in `OnLiveCreateObject`). +`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.