Merge branch 'claude/phase-b5-pickup' — Phase B.5 pickup

This commit is contained in:
Erik 2026-05-14 16:23:34 +02:00
commit cf22f9c031
12 changed files with 1117 additions and 17 deletions

View file

@ -619,19 +619,26 @@ acdream's plan lives in two files committed to the repo:
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13.
The M1 demo target *"open the inn door"* is met **with full visual feedback**:
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 → door swing animation
plays (B.4c: spawn-time `AnimationSequencer` registration + `OnLiveMotionUpdated`
routing for door entities). Issue #57 (B.4 handler gap) is closed. Issue #58
(door swing animation) is closed by B.4c. Issues #61 (link→cycle boundary
flash) and #62 (PARTSDIAG null-guard) are filed as M1-deferred polish.
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
2026-05-14. The M1 demo target *"pick up an item"* is met for the
close-range path — single-click a ground item to select, walk within
~0.6 m of it, press F, and the item is removed from the world and added
to the player's inventory. Wire chain: `InteractRequests.BuildPickUp`
sends `PutItemInContainer (0xF7B1/0x0019)`; ACE despawns the item with
`GameMessagePickupEvent (0xF74A)` (NOT `0xF747 DeleteObject` — the
distinction surfaced during visual testing and is fixed by the new
`PickupEvent.cs` parser routed through the shared `EntityDeleted`
event). The M1 demo target *"open the inn door"* remains met from B.4b
+ B.4c. Issue #57 (B.4 handler gap) is closed. Issue #58 (door swing
animation) is closed by B.4c. Issues #61 (link→cycle boundary flash),
#62 (PARTSDIAG null-guard), **#63 (server-initiated MoveToObject
auto-walk not honored — blocks out-of-range pickup / Use)**, and **#64
(local-player pickup animation does not render)** are filed as
M1-deferred follow-up.
**B.5 ship handoff:** [`docs/research/2026-05-14-b5-shipped-handoff.md`](docs/research/2026-05-14-b5-shipped-handoff.md)
— full evidence for the 5 commits across InteractRequests / GameWindow / WorldSession + the bonus `PickupEvent (0xF74A)` wire-handler fix that closes the despawn gap.
**B.4c ship handoff:** [`docs/research/2026-05-13-b4c-shipped-handoff.md`](docs/research/2026-05-13-b4c-shipped-handoff.md)
— full evidence for the 4 commits + 2 bonus discoveries (stance-value wrong
`0x01` vs `0x3D` causing underground doors; link→cycle boundary flash).
@ -725,11 +732,15 @@ project.
packets, wire the handlers; if it is silent, investigate ACE's NPC handler
configuration. ~30 min spike, outcome determines whether NPC interaction
needs a full phase or is a one-commit fix.
- **Phase B.5 — Ground item pickup (F key) (M1 critical path).** The
`SelectionPickUp` input action + F-key binding exist in `KeyBindings` but
`OnInputAction` has no case for it. `BuildUse` IS `BuildPickUp` (same wire
format). One-commit addition: add `SelectionPickUp` case to `GameWindow.
OnInputAction` → call `InteractRequests.BuildPickUp(seq, _selectedGuid)`. ~30 min.
- **Phase B.6 — Client-side MoveToObject auto-walk handling (closes #63).**
ACE auto-walks the player to out-of-range Use / Pickup targets via
`CreateMoveToChain` + `EnqueueBroadcastMotion(MoveToObject)`, but our client
doesn't honor the inbound motion broadcast — character drifts toward the
target and snaps back, ACE's chain times out. Reference implementation
exists in `references/holtburger/crates/holtburger-core/src/client/simulation.rs`
(the `approximate_move_to_object_projection_target` + `MoveToObject` case).
Unlocks double-click pickup, F-key pickup from any distance, Use on
out-of-range NPCs / corpses. Probably 1-2 commits + visual verification.
- **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #41 (remote-motion blips) have been open since April/early-May and

View file

@ -46,6 +46,89 @@ Copy this block when adding a new issue:
# Active issues
## #64 — Local-player pickup animation does not render
**Status:** OPEN
**Severity:** LOW (visual feedback only — pickup completes correctly)
**Filed:** 2026-05-14 (B.5 visual verification)
**Component:** motion / animation routing for local player
**Description:** When `+Acdream` picks up an item (B.5 close-range
path), retail observers see the character play the pickup animation
correctly, but the local view shows no pickup animation. The item
despawns, the inventory updates, but the character's own
bend-down-and-grab animation is missing.
**Root cause / hypothesis:** ACE broadcasts `Motion(MotionCommand.Pickup)`
via `Player_Inventory.AddPickupChainToMoveToChain` (line 711713,
`EnqueueBroadcastMotion(motion)`), which arrives as a normal
`UpdateMotion (0xF74D)` packet. Retail observers route it through
their remote-creature animation pipeline and render the pickup. For
the local player, our `OnLiveMotionUpdated` likely filters self-echoes
(local player drives its own motion via prediction, not server
echoes) and drops the pickup motion. The pickup is a one-shot
animation initiated by the server, so the prediction path has no
trigger — and the echo path is filtered.
**Acceptance:** When `+Acdream` picks up an item, the local view shows
the same pickup animation retail observers see. Probably resolved by
either (a) admitting server-initiated one-shot motions through the
local-player motion filter, or (b) generating the pickup animation
locally on send (mirroring retail's client behavior).
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
(motion routing); the self-echo filter is somewhere along this path.
**Estimated scope:** Small-to-medium. Mostly investigation +
12 commits.
---
## #63 — Server-initiated auto-walk (MoveToObject) not honored
**Status:** OPEN
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
works fine)
**Filed:** 2026-05-14 (B.5 visual verification)
**Component:** motion / inbound MoveToObject handling
**Description:** When the player triggers a Use or PutItemInContainer
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
runs server-side auto-walk via `CreateMoveToChain`
`PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(Motion(MoveToObject, target))`.
Our client receives the `UpdateMotion(MoveToObject)` broadcast for
the player but doesn't honor it: the character either visually
drifts a bit toward the target and snaps back, or just stands still.
ACE's MoveToChain then times out, the `success: false` path
broadcasts `InventoryServerSaveFailed (ActionCancelled)`, and the
pickup/use never completes.
**User-visible symptom:** Double-click a ground item from any
distance, or F-key it from > 0.6 m: character partially walks toward
the item, then flips back to original position. No pickup.
**Reference:** [holtburger simulation.rs:3341 + 178191](references/holtburger/crates/holtburger-core/src/client/simulation.rs)
already implements client-side `MoveToObject` motion projection +
auto-walk handling. That's the shape of the fix.
**Root cause:** Our `OnLiveMotionUpdated` has no handler for the
`MoveToObject` motion type; the broadcast is silently dropped.
**Acceptance:** Double-click a ground item from 25 m away. Character
auto-walks to within use radius, ACE's MoveToChain confirms success,
pickup completes (including the existing PickupEvent despawn). Same
behavior for Use on out-of-range NPCs.
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
(routing); likely a new `MoveToObjectMotion` handler in the motion /
prediction layer + a server-acked position-update echo so ACE sees the
player has reached the target.
**Estimated scope:** Medium. Probably its own phase (B.6 or similar);
not a one-commit fix. Compose from holtburger's pattern.
---
## #62 — PARTSDIAG null-guard for sequencer-driven entities
**Status:** OPEN

View file

@ -67,6 +67,7 @@
| 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 ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number:

View file

@ -0,0 +1,235 @@
# Phase B.5 — BuildPickUp + ground-item interaction — fresh-session handoff
**Date:** 2026-05-13 evening (after B.4c ship).
**Branch:** `claude/phase-b5-pickup` (renamed from `claude/investigate-npc-click`).
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\investigate-npc-click` (directory name kept; only the branch was renamed).
**Predecessor on main:** `e7842e0``Merge branch 'claude/phase-b4c-door-anim' — Phase B.4c door swing animation`.
---
## TL;DR
After B.4c shipped (doors visibly swing open/close), three of M1's four
demo targets are met: *walk through Holtburg*, *open the inn door*, and
likely *click an NPC* (per the investigation below; not yet
visual-verified). The remaining target is *pick up an item*, which
needs a new outbound wire builder + F-key handler in `GameWindow`.
Phase **B.5** is the slice that closes M1's "click + pickup" demo
path. Scope: ~50 LOC across two existing files (no new files).
Implementation pattern mirrors B.4b's outbound Use chain.
---
## Investigation findings (carry forward)
Before starting B.5 work, the controller agent investigated whether
B.4b's existing `BuildUse` chain already handles "click an NPC". Code
reading produced this answer: **yes, the basic chat-dialogue case
should already work end-to-end with zero new code**. Verify
opportunistically during B.5's visual test by clicking an NPC while
in-world.
Specifically:
- **ACE's `Creature.ActOnUse`** at
`references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:334`
defers to `base.OnActivate → EmoteManager.OnUse()`. The emote
manager walks the creature's emote table and emits `Tell`,
`CommunicationTransientString`, `Motion`, and other game events.
- **All those events are already wired** in
`src/AcDream.Core.Net/GameEventWiring.cs`:
- `Tell (0x0027)``chat.OnTellReceived` (line 78)
- `CommunicationTransientString (0x028B)``chat.OnSystemMessage` (line 83)
- `WeenieError / WeenieErrorWithString``chat.OnSystemMessage` (lines 139, 144)
Plus `UpdateMotion (0xF74D)` is already routed for creature entities
via `OnLiveMotionUpdated`.
- **`UseDone (0x01C7)`** — the completion ack — has a parser at
`GameEvents.ParseUseDone` but is **not registered** with the
dispatcher. Silent drop. Harmless for the basic demo (the chat
events arrive independently), but worth filing as a follow-up if not
picked up by B.5.
**Conclusion:** click-NPC chain is wired; no code change needed for the
M1 demo target 3 acceptance. Verify in-world during B.5's launch.
---
## B.5 scope (decisions already made)
| Decision | Value | Rationale |
|---|---|---|
| Trigger | F-key (`InputAction.SelectionPickUp`) | Already bound at `KeyBindings.cs:172` |
| Target selection | Requires `_selectedGuid` (B.4b's renamed field) | Mirrors retail F-key behavior + B.4b's `UseSelected` pattern. User single-clicks the ground item to select, then F to pickup. |
| Wire opcode | `GameAction.PutItemInContainer (0x0019)` | ACE source: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs` |
| Wire payload | 12 bytes: `itemGuid (u32) + containerGuid (u32) + placement (i32)` | Same source |
| Container destination | The player's own server guid (`_playerServerGuid`) | Single-bag pickup; bag-specific destinations are M2+ work |
| Placement value | 0 (let server pick slot) | Simplest; placement-control UI is M2+ |
| Visual feedback | Toast + `[pickup]` log line | No inventory UI yet; the existing `WieldObject` / `InventoryPutObjInContainer` server events already update `ItemRepository` so the state is correct internally |
| Pick under cursor fallback | **NO** | Out of scope per user decision. Strict select-first UX. |
Brainstorm explicitly **NOT** done with the user (interrupted before
the design sections were presented). The new session should re-confirm
these decisions are still desired before writing the spec — or just
proceed if they remain obviously right.
---
## Three changes B.5 needs to land
1. **`src/AcDream.Core.Net/Messages/InteractRequests.cs`** — add
`BuildPickUp(uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)`.
Pattern: same as the existing `BuildUseWithTarget` builder at line
51 of that file. 20-byte total body (`0xF7B1 envelope + seq + opcode
0x0019 + 12-byte payload`).
2. **`src/AcDream.App/Rendering/GameWindow.cs`** — add a private helper
`SendPickUp(uint itemGuid)`:
- Gate on `_liveSession?.CurrentState == InWorld` (same pattern as
B.4b's `SendUse`).
- `seq = _liveSession.NextGameActionSequence()`.
- `body = InteractRequests.BuildPickUp(seq, itemGuid, _playerServerGuid, 0)`.
- `_liveSession.SendGameAction(body)`.
- Diagnostic: `Console.WriteLine($"[pickup] item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}")`.
3. **`src/AcDream.App/Rendering/GameWindow.cs` `OnInputAction` switch**
— add `case InputAction.SelectionPickUp:` near the other `Select*` /
`UseSelected` cases (B.4b added those around line 8633+). Body:
`if (_selectedGuid is uint sel) SendPickUp(sel); else _debugVm?.AddToast("Nothing selected");`.
That's the whole code change. ~50 LOC including diagnostics.
---
## Likely ID-translation gotcha (the L.2g slice 1c pattern)
B.4b's L.2g slice 1c surfaced an ID-space mismatch: the **`BuildUse`**
wire builder takes a `targetGuid` which is `entity.ServerGuid`, but
`ShadowObjectRegistry` keys by `entity.Id`. For `BuildPickUp`:
- `itemGuid` argument must be `entity.ServerGuid` (the server's
identifier — ACE looks it up in its world). ✅ B.4b's picker returns
`ServerGuid`, so `_selectedGuid` already carries the right value.
- `containerGuid` argument must be `_playerServerGuid` (the server's
identifier for the player). ✅ Already a ServerGuid in `GameWindow`.
So B.5 should NOT hit the same ID-mismatch trap L.2g slice 1c did. But
re-check at implementation time.
---
## ACE inbound chain (already wired)
After ACE processes a `BuildPickUp`, it broadcasts:
- `0x019B InventoryPutObjInContainer` — moves the item record into the
player's container. Already wired to
`ItemRepository.MoveItem(itemGuid, containerGuid, placement)` at
`GameEventWiring.cs:239`.
- `RemoveObject` for the world-spawned item — already wired (existing
despawn path removes the ground item from view).
- Possibly `WieldObject` if the item auto-equips — already wired
(`GameEventWiring.cs:231`).
No new inbound wiring needed for the minimum demo. The user will see:
1. Click ground item → selection updates.
2. Press F → diagnostic logs, packet sent.
3. ACE processes; sends inventory + despawn events.
4. Item disappears from ground.
5. (No inventory UI yet, but item is in `ItemRepository`.)
---
## Acceptance criteria
- [ ] `dotnet build` green.
- [ ] `dotnet test` green: 1046 / 8 pre-existing-baseline fail
(unchanged from main HEAD).
- [ ] At Holtburg, drop a test item on the ground (via `/drop` server
command or have ACE spawn one for the test character), then:
- [ ] Single-click the item — `_selectedGuid` updates, B.4b's
`[pick]` diagnostic shows the item's guid.
- [ ] Press F — log shows `[pickup] item=0x... container=0x5000000A
seq=N`.
- [ ] Item disappears from the ground.
- [ ] No regressions on door interaction (B.4b/B.4c still work).
- [ ] **Bonus: click-NPC verification.** While in-world, single-click
an NPC and press F (or double-click). Expected: NPC chat appears in
the chat panel. If it does → M1 demo target 3 confirmed met. If not
→ file the gap.
- [ ] `docs/ISSUES.md` closure entry for whatever issue (if any) was
filed for the pickup gap.
- [ ] Roadmap + CLAUDE.md updated.
---
## Reproducibility
Same launch recipe as B.4c. Per CLAUDE.md "Logout-before-reconnect",
wait 20-45s between client launches to let ACE clear stale sessions.
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
$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"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
Log grep:
```powershell
Select-String -Path launch-b5.log -Pattern "pickup|\[pick\] guid=|UseDone|\[B.4b\] pick"
```
---
## Carry-overs from B.4c (don't lose track)
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing.
Visible as brief flap at end of swing animation. Low-severity polish.
- **#62** — PARTSDIAG null-guard for sequencer-driven entities.
Latent; not currently reachable for doors. One-line fix.
- **Worktree at `.claude/worktrees/phase-b4c-door-anim`** still on disk
(submodules blocked `git worktree remove` per B.4b precedent). Manual
cleanup after this session: `rm -rf` the directory + `git worktree
prune` + `git branch -D claude/phase-b4c-door-anim`.
---
## State at handoff
- **Branch:** `claude/phase-b5-pickup` (renamed from
`claude/investigate-npc-click`).
- **Worktree directory:** `.claude/worktrees/investigate-npc-click`
(cosmetic mismatch with branch name; harmless).
- **Commits ahead of main:** 1 after this handoff lands.
- **Main HEAD:** `e7842e0`.
- **Build state:** worktree compiles cleanly (verified via
`dotnet build -c Debug`). Tests at 1046/8 baseline.
- **Submodule state:** `references/WorldBuilder` initialized.
`references/ACE` NOT initialized in this worktree — use the main
repo's `references/ACE` for ACE source reads, or init via
`git submodule update --init --depth=1 references/ACE` if extensive
reading is needed.
---
## Why a fresh session
This session accumulated ~10 hours of context across L.2g, B.4b, and
B.4c — the working set is large enough that starting B.5 cold lets the
new session work with a clean context budget and avoids the compaction
risk that hit the prior B.4b session.
The prompt for the new session is in the controller's reply that
created this handoff (the chat message immediately after this commit).

View file

@ -0,0 +1,252 @@
# Phase B.5 shipped — handoff (visual-verified 2026-05-14)
**Date:** 2026-05-14.
**Branch:** `claude/phase-b5-pickup` (ready to merge to main; controller handles the merge after this doc lands).
**Predecessors:**
- [docs/research/2026-05-13-b4c-shipped-handoff.md](2026-05-13-b4c-shipped-handoff.md) — B.4c (door swing) shipped immediately before.
- [docs/research/2026-05-13-b5-pickup-handoff.md](2026-05-13-b5-pickup-handoff.md) — fresh-session handoff that scoped this phase.
- [docs/superpowers/plans/2026-05-14-phase-b5-pickup.md](../superpowers/plans/2026-05-14-phase-b5-pickup.md) — implementation plan (2 tasks).
---
## TL;DR
Phase B.5 **shipped end-to-end and is visual-verified 2026-05-14.** The
M1 demo target *"pick up an item"* is met for the close-range path —
single-click a ground item to select, walk within ~0.6 m of it, press
F, and the item is removed from the world and added to the player's
inventory.
The plan budgeted 2 implementation tasks (~50 LOC). Visual testing
surfaced **one wire-handler gap** that became Task 2b: ACE despawns
picked-up items via `GameMessagePickupEvent (0xF74A)`, not the
`GameMessageDeleteObject (0xF747)` we already handled — without that
fix the pickup succeeded server-side but the item kept rendering on
the ground locally. Caught and fixed in the same session.
Two known gaps remain, filed as issues for follow-up:
- **#63 (MEDIUM)** — Server-initiated auto-walk for out-of-range Use /
PickUp not honored. Double-click a ground item from > 0.6 m and the
character partially walks then snaps back; ACE's `MoveToChain` times
out. This is a separate motion-handling phase, not a B.5 regression.
- **#64 (LOW)** — Local-player pickup animation doesn't render
(retail observers see it correctly; local view is silent). Likely a
self-echo filter dropping `UpdateMotion(Pickup)` on the local player.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `e8a20f2` | `feat(B.5): InteractRequests.BuildPickUp — PutItemInContainer 0x0019` | Task 1 |
| 2 | `ced1b85` | `test(B.5): exercise i32 sign-correctness for BuildPickUp.placement` | Task 1 code-review fix |
| 3 | `54d9bb9` | `feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring` | Task 2 |
| 4 | `5c24f6c` | `docs(B.5): implementation plan from writing-plans skill` | Plan doc |
| 5 | `f7636a9` | `fix(B.5): handle PickupEvent 0xF74A so picked-up items despawn locally` | Task 2b (post-visual-test fix) |
Plus the predecessor handoff (`86440ff`) that started the branch.
**Build:** clean.
**Tests:** `dotnet test -c Debug` shows AcDream.Core.Net.Tests 290/290
passing (was 287 at branch start; +3 from Task 2b's PickupEvent tests;
the two BuildPickUp tests landed inside the same project's existing
file). Failure count unchanged at 8 pre-existing baseline in
AcDream.Core.Tests.
---
## What the code does end-to-end
**Outbound (Tasks 1 & 2):**
1. User single-clicks a ground item near `+Acdream`.
`case InputAction.SelectLeft → PickAndStoreSelection(useImmediately: false)`
runs B.4b's `WorldPicker.Pick`, finds the item, sets `_selectedGuid`.
Log: `[B.4b] pick guid=0x… name=…`.
2. User presses F.
`case InputAction.SelectionPickUp → SendPickUp(_selectedGuid)` builds
the wire body via `InteractRequests.BuildPickUp(seq, itemGuid,
_playerServerGuid, placement: 0)` and posts it through
`_liveSession.SendGameAction`. Log: `[B.5] pickup item=… container=… seq=…`.
3. Wire layout (24 bytes): `0xF7B1 envelope | seq | 0x0019 opcode |
itemGuid u32 | containerGuid u32 | placement i32`. Verified against
`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`.
**Inbound (Task 2b — surfaced during visual test):**
4. ACE runs `HandleActionPutItemInContainer`. If the player is within
`WithinUseRadius` (~0.6 m), the close-range branch in
`CreateMoveToChain` skips the auto-walk and runs the pickup chain
directly: server-side `Landblock.RemoveWorldObject(item.Guid,
adjacencyMove: false, fromPickup: true)` → per-player
`Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)`
broadcast `GameMessagePickupEvent (0xF74A)` to all observers.
5. Our `WorldSession.Dispatch` now routes `0xF74A` (in addition to
`0xF747 DeleteObject`) through the shared `EntityDeleted` event,
adapting the `PickupEvent.Parsed` to a `DeleteObject.Parsed` so
`OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` runs unchanged.
The item disappears from the local view.
---
## Wire-handler gap (Task 2b)
ACE distinguishes two despawn opcodes:
- `0xF747 GameMessageDeleteObject` — "object is gone" (timeout / death /
out-of-LOS). Our existing handler.
- `0xF74A GameMessagePickupEvent` — "object was picked up by a player."
Sent by `Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)`.
Both are functionally identical from the client's view (remove the
entity from the world), but only one was handled. Wire format adds
one `u16 objectPositionSequence` field over DeleteObject's layout, so
`PickupEvent.cs` is its own parser; the dispatcher adapts to
`DeleteObject.Parsed` for the downstream consumer.
This is exactly the kind of trap CLAUDE.md's reference-repo discipline
exists to prevent — the handoff spec said "the existing despawn path
removes the ground item from view," which was *almost* true. Took one
visual-verification round-trip to surface, ten minutes to fix with a
clean wire parser + 3 new unit tests.
---
## Visual verification — what was observed
**Test scenario:** ACE dropped a Pink Taper, then a Violet Taper, then
two more tapers near `+Acdream` at Holtburg. Player walked up close,
single-clicked, pressed F. Three pickups completed in the post-fix
log: items `0x80000725`, `0x8000072A`, `0x80000729`.
**Before Task 2b:** Server-side pickup succeeded — `[B.5] pickup …
seq=46` in log; retail observer saw item disappear from world. Local
view still rendered the item on the ground.
**After Task 2b:** Item disappears locally as soon as ACE acks the
pickup. Three successful close-range pickups recorded in the log.
**Door-interaction regression check (B.4c carry-forward):** Not
explicitly re-tested this session; no code path touched by B.5
affects door interaction.
**Click-NPC bonus (M1 demo target 3 verification):** Not visually
verified this session — log shows `[B.4b] use guid=… name=Novedion
the Gem Seller seq=…` from B.4c testing but ACE response not
re-confirmed here. Carry-forward to next session.
---
## What did NOT work (and why it's not B.5's bug)
1. **Double-click on a ground item from any distance, or F from > 0.6 m.**
ACE auto-walks the player toward the item (`CreateMoveToChain`
`PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(MoveToObject)`),
but our client doesn't handle inbound `MoveToObject` motion broadcasts.
ACE's MoveToChain times out, the chain's `success: false` path sends
`InventoryServerSaveFailed (ActionCancelled)`, and the pickup never
completes. Visible as "character drifts toward item then flips back."
**Filed as #63.** Out of B.5's stated scope (which was: select-first
+ F-key wire chain). holtburger's `simulation.rs` has the reference
implementation; would be its own phase (B.6 or similar).
2. **Local-player pickup animation doesn't render.** Retail observers
see `+Acdream` play the bend-down-and-grab animation; our local view
shows nothing. ACE broadcasts `Motion(MotionCommand.Pickup)` via
`EnqueueBroadcastMotion`, our motion routing probably filters
self-echoes for the local player (motion is normally predicted
locally, not echoed from server). Server-initiated one-shot motions
like Pickup have no local prediction trigger, so they're dropped.
**Filed as #64.** Visual feedback gap only; pickup completes
correctly.
Both are well-defined follow-up work; neither blocks M1.
---
## Carry-overs from B.4c
Both pre-existed B.5; neither was touched.
- **#61** — AnimationSequencer link→cycle boundary frame-0 flash on
door swing. Low severity polish.
- **#62** — PARTSDIAG null-guard for sequencer-driven entities.
Latent; not currently reachable for doors.
---
## M1 status after B.5
Demo targets:
1. Walk through Holtburg — met (L.2a-d + L.2g shipped earlier)
2. Open the inn door — met (B.4b + B.4c shipped 2026-05-13)
3. Click an NPC — chain wired (B.4b), not visually re-verified this
session
4. Pick up an item — met, close-range path (this phase)
Outstanding work for the M1 demo recording:
- Optionally re-verify target 3 (NPC click) once and either confirm
met or file a gap.
- Optionally resolve #63 if the demo wants to show double-click /
out-of-range pickup. The close-range path is sufficient for the
scripted demo scenario.
- Carry-overs #61, #62, #64 are polish; do before recording if
visible on tape.
---
## Reproducibility
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
$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"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
Log evidence:
```powershell
Get-Content launch-b5.log -Encoding Unicode |
Select-String -Pattern "\[B\.5\] pickup|\[B\.4b\] pick"
```
Expected: a `[B.5] pickup item=… container=0x5000000A seq=…` line for
each successful F-press, preceded by `[B.4b] pick guid=…` from the
single-click that set the selection.
---
## Files touched this session
- New: `src/AcDream.Core.Net/Messages/PickupEvent.cs`
- New: `tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs`
- New: `docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`
- New: `docs/research/2026-05-14-b5-shipped-handoff.md` (this file)
- Modified: `src/AcDream.Core.Net/Messages/InteractRequests.cs`
- Modified: `src/AcDream.Core.Net/WorldSession.cs`
- Modified: `src/AcDream.App/Rendering/GameWindow.cs`
- Modified: `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`
- Modified: `docs/ISSUES.md` (added #63, #64)
---
## State at handoff
- **Branch:** `claude/phase-b5-pickup`, 6 commits ahead of `main`
(predecessor handoff + 5 implementation commits + this docs commit
land in the same merge).
- **Main HEAD before merge:** `e7842e0` — Merge B.4c.
- **Build state:** worktree compiles cleanly under `dotnet build -c Debug`.
- **Tests:** baseline + 3 new (PickupEvent) + 2 new (BuildPickUp +
sign-correctness) — failure count unchanged.
Ready for non-fast-forward merge into `main`.

View file

@ -0,0 +1,319 @@
# Phase B.5 — BuildPickUp + ground-item interaction — 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:** Close M1 demo target 4/4 — make F-key pick up the currently-selected ground item by sending ACE's `PutItemInContainer (0x0019)` GameAction.
**Architecture:** Mirror B.4b's outbound `BuildUse` chain. Add one new wire-builder (`InteractRequests.BuildPickUp`) and one new GameWindow helper (`SendPickUp`); wire the F-key action into the existing `OnInputAction` switch using the existing `_selectedGuid` field. The ACE inbound handlers (`InventoryPutObjInContainer 0x019B`, `RemoveObject`) are already wired in `GameEventWiring.cs` and will despawn the ground item once ACE acknowledges the pickup — no inbound work needed.
**Tech Stack:** C# / .NET 10 / xUnit (existing). Wire format: `BinaryPrimitives.WriteUInt32LittleEndian` little-endian envelope same as every other `0xF7B1` GameAction in the file.
---
## Predecessor & branch state
- **Branch:** `claude/phase-b5-pickup` in worktree `.claude/worktrees/investigate-npc-click`.
- **Main HEAD at start:** `e7842e0` — Merge B.4c.
- **Existing commits on branch:** `86440ff` — the B.5 handoff doc (`docs/research/2026-05-13-b5-pickup-handoff.md`).
---
## Wire format (verified against ACE source)
`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`:
```csharp
var itemGuid = message.Payload.ReadUInt32();
var containerGuid = message.Payload.ReadUInt32();
var placement = message.Payload.ReadInt32();
```
`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:13`:
```
PutItemInContainer = 0x0019,
```
Therefore the full **24-byte** GameAction body is:
| Offset | Field | Bytes |
|---|---|---|
| 0 | `0xF7B1` (GameAction envelope) | 4 |
| 4 | `gameActionSequence` | 4 |
| 8 | `0x0019` (PutItemInContainer subopcode) | 4 |
| 12 | `itemGuid` (u32, the server guid of the ground item) | 4 |
| 16 | `containerGuid` (u32, the player's server guid) | 4 |
| 20 | `placement` (i32, 0 = let server choose slot) | 4 |
**NB:** The handoff doc (`2026-05-13-b5-pickup-handoff.md`) said "20-byte total body." That was an arithmetic error in the handoff — corrected here.
---
## File structure (which files touched)
- **Modify:** `src/AcDream.Core.Net/Messages/InteractRequests.cs` — add `PutItemInContainerOpcode` constant + `BuildPickUp(seq, itemGuid, containerGuid, placement)` builder.
- **Modify:** `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs` — add a unit test for `BuildPickUp` covering byte layout + opcode.
- **Modify:** `src/AcDream.App/Rendering/GameWindow.cs` — add private `SendPickUp(uint itemGuid)` helper next to `SendUse`; add `case InputAction.SelectionPickUp` in the `OnInputAction` switch.
No new files. No tests for the GameWindow helper (it's a thin pass-through wrapper around `_liveSession.SendGameAction` mirroring `SendUse`, which itself has no unit test for the same reason).
---
## Task 1: TDD — InteractRequests.BuildPickUp
**Files:**
- Modify: `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`
- Modify: `src/AcDream.Core.Net/Messages/InteractRequests.cs`
- [ ] **Step 1: Write the failing test.** Append this new `[Fact]` immediately after `BuildUseWithTarget_WritesBothGuids` in `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`:
```csharp
[Fact]
public void BuildPickUp_WritesOpcode0x0019AndPayload()
{
byte[] body = InteractRequests.BuildPickUp(
gameActionSequence: 5,
itemGuid: 0xABCDu,
containerGuid: 0x5000000Au,
placement: 0);
Assert.Equal(24, body.Length);
Assert.Equal(InteractRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(5u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(InteractRequests.PutItemInContainerOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0xABCDu,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(0x5000000Au,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
}
```
- [ ] **Step 2: Run test to verify it fails.**
```powershell
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj `
--filter "FullyQualifiedName~InteractRequestsTests.BuildPickUp"
```
Expected: build fails with `CS0117: 'InteractRequests' does not contain a definition for 'BuildPickUp'` (and a second error for `PutItemInContainerOpcode`).
- [ ] **Step 3: Add the constant + builder.** Edit `src/AcDream.Core.Net/Messages/InteractRequests.cs`. Add this opcode constant immediately after the existing `TeleToLifestoneOpcode` declaration (~line 31):
```csharp
public const uint PutItemInContainerOpcode = 0x0019u;
```
Then append the new builder method immediately after `BuildTeleToLifestone` (~line 75, just before the closing `}` of the class). Use the same `XmlDoc + BinaryPrimitives` style as the existing builders:
```csharp
/// <summary>
/// Pick up a ground item or move an item between containers. The
/// server places the item in <paramref name="containerGuid"/> at
/// the given <paramref name="placement"/> slot (pass 0 to let the
/// server choose). For F-key ground-pickup, pass the player's own
/// server guid as <paramref name="containerGuid"/>.
///
/// <para>
/// Wire layout (ACE GameActionPutItemInContainer.Handle):
/// <code>
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x0019 // PutItemInContainer
/// u32 itemGuid // server guid of the item
/// u32 containerGuid // destination container (player or bag)
/// i32 placement // 0 = server picks slot
/// </code>
/// </para>
/// </summary>
public static byte[] BuildPickUp(
uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PutItemInContainerOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), containerGuid);
BinaryPrimitives.WriteInt32LittleEndian (body.AsSpan(20), placement);
return body;
}
```
- [ ] **Step 4: Run test to verify it passes.**
```powershell
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj `
--filter "FullyQualifiedName~InteractRequestsTests.BuildPickUp"
```
Expected: 1 passing, 0 failing.
- [ ] **Step 5: Commit.**
```powershell
git add src/AcDream.Core.Net/Messages/InteractRequests.cs `
tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs
git commit -m "feat(B.5): InteractRequests.BuildPickUp — PutItemInContainer 0x0019"
```
---
## Task 2: GameWindow integration — SendPickUp + OnInputAction case
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
This task does NOT include new tests. `SendPickUp` is a 6-line passthrough that gates on `InWorld` state and routes to `_liveSession.SendGameAction`. It mirrors `SendUse` (also untested by unit tests) and is verified end-to-end via the in-world visual check in Task 3.
- [ ] **Step 1: Add the `SendPickUp` helper.** Locate `SendUse` (currently around line 8870 in `src/AcDream.App/Rendering/GameWindow.cs`). Insert this new helper immediately after `SendUse`'s closing brace:
```csharp
private void SendPickUp(uint itemGuid)
{
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.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
}
```
- [ ] **Step 2: Wire the F-key action.** Locate the `OnInputAction` switch's `case InputAction.UseSelected` (currently around line 8745). Insert a new case immediately after it:
```csharp
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget)
SendPickUp(pickupTarget);
else
_debugVm?.AddToast("Nothing selected");
break;
```
- [ ] **Step 3: Build the whole solution.**
```powershell
dotnet build -c Debug
```
Expected: build succeeds with zero errors. (Existing warnings unchanged.)
- [ ] **Step 4: Run the full test suite.**
```powershell
dotnet test -c Debug --nologo
```
Expected: 1047 passing, 8 failing (8 pre-existing baseline failures from main HEAD; same count as before B.5). The new `BuildPickUp_WritesOpcode0x0019AndPayload` test contributes +1 passing.
- [ ] **Step 5: Commit.**
```powershell
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring"
```
---
## Task 3: Visual verification in live client
**Not a code task — user-driven acceptance test.** Run the launch recipe, drop a test item, click-then-F.
- [ ] **Step 1: Kill stale client + wait for ACE session cleanup.**
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
```
- [ ] **Step 2: Launch the client (background).**
```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"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
- [ ] **Step 3: User-driven test scenario.** Hand off to user for visual verification:
1. ACE drops a test item near `+Acdream` (server `/drop` slash command, or whatever ACE supports for the testaccount). Specify what item type was dropped in the verification report.
2. **Single-click the item**`[B.4b] pick guid=0x<ITEM>` should appear in the log. The selection toast should show the item name.
3. **Press F**`[B.5] pickup item=0x<ITEM> container=0x5000000A seq=<N>` should appear in the log.
4. **Item should disappear from the ground** (ACE acks → `RemoveObject` → existing despawn path removes it from view).
5. **No regressions on door interaction:** double-click the inn door, observe it swings open as in B.4c.
6. **Bonus: NPC chat check.** Click an NPC, observe the chat panel. If NPC dialogue appears → M1 demo target 3 confirmed met. If silent → file an issue.
- [ ] **Step 4: Grep the log for evidence.**
```powershell
Select-String -Path launch-b5.log -Pattern "\[B\.5\] pickup|\[B\.4b\] pick|UseDone|InventoryPutObjInContainer|RemoveObject"
```
Expected: at least one `[B.5] pickup` line and a subsequent `RemoveObject` for the same guid.
---
## Task 4: Ship handoff + docs + merge
- [ ] **Step 1: Write the ship handoff doc** at `docs/research/2026-05-14-b5-shipped-handoff.md`. Follow the B.4c handoff structure: TL;DR, three-commit table (TDD builder + GameWindow integration + handoff itself), wire-format evidence, visual-verification evidence (with launch log excerpt), open issues carried forward (#61 #62 from B.4c), what shipped, what was left for later.
- [ ] **Step 2: Update `docs/plans/2026-04-11-roadmap.md`** — move Phase B.5 from "Next phase candidates" into the shipped-table with the merge SHA placeholder + link to the handoff doc.
- [ ] **Step 3: Update `CLAUDE.md`** — update the "Currently in Phase L.2" paragraph's M1 demo status from "3 of 4 met" to "4 of 4 met", reference the B.5 handoff, and add a fresh "Next phase candidates" list (chronic-issue triage, Phase C visual fidelity, N.6 slice 2, etc.).
- [ ] **Step 4: Update `docs/ISSUES.md`** — if any new issues surfaced during visual verification, file them. If the click-NPC bonus check succeeded, note it in the recent-progress section.
- [ ] **Step 5: Commit docs.**
```powershell
git add docs/research/2026-05-14-b5-shipped-handoff.md `
docs/plans/2026-04-11-roadmap.md `
CLAUDE.md `
docs/ISSUES.md
git commit -m "docs(B.5): ship handoff + roadmap/CLAUDE update + M1 4/4 met"
```
- [ ] **Step 6: Merge to main.**
```powershell
git checkout main
git merge --no-ff claude/phase-b5-pickup -m "Merge branch 'claude/phase-b5-pickup' — Phase B.5 pickup"
```
- [ ] **Step 7: Optional worktree cleanup.** (Per B.4c precedent, submodules block `git worktree remove`; do `git worktree prune` after manually deleting the directory if disk pressure warrants. Otherwise skip — the directory is small.)
---
## Acceptance criteria summary
- [ ] `dotnet build -c Debug` green.
- [ ] `dotnet test -c Debug` shows +1 new passing (the `BuildPickUp_WritesOpcode0x0019AndPayload` test).
- [ ] Total pass count = baseline + 1; failure count unchanged (8 pre-existing).
- [ ] Visual: click ground item → F → log shows `[B.5] pickup ...` → item disappears.
- [ ] No regression on B.4c door interaction (double-click inn door still swings).
- [ ] Bonus: click NPC → chat appears in chat panel (or filed as a follow-up issue).
- [ ] Branch merged into main with non-fast-forward merge commit.
---
## Carry-overs from B.4c (do not lose track)
- **#61** — AnimationSequencer link→cycle frame-0 flash. Low severity. Not blocking M1 demo.
- **#62** — PARTSDIAG null-guard. Latent (not reachable for doors currently). One-line fix.
Neither blocks B.5. Address before recording the M1 demo video if the door-swing flap is distracting on tape.

View file

@ -8746,6 +8746,13 @@ public sealed class GameWindow : IDisposable
UseCurrentSelection();
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget)
SendPickUp(pickupTarget);
else
_debugVm?.AddToast("Nothing selected");
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
@ -8881,6 +8888,21 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
}
private void SendPickUp(uint itemGuid)
{
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.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
}
private uint? SelectClosestCombatTarget(bool showToast)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))

View file

@ -29,6 +29,7 @@ public static class InteractRequests
public const uint UseOpcode = 0x0036u;
public const uint UseWithTargetOpcode = 0x0035u;
public const uint TeleToLifestoneOpcode = 0x0063u;
public const uint PutItemInContainerOpcode = 0x0019u;
/// <summary>
/// Use an object: click a door, loot a corpse, talk to an NPC,
@ -73,4 +74,36 @@ public static class InteractRequests
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TeleToLifestoneOpcode);
return body;
}
/// <summary>
/// Pick up a ground item or move an item between containers. The
/// server places the item in <paramref name="containerGuid"/> at
/// the given <paramref name="placement"/> slot (pass 0 to let the
/// server choose). For F-key ground-pickup, pass the player's own
/// server guid as <paramref name="containerGuid"/>.
///
/// <para>
/// Wire layout (ACE GameActionPutItemInContainer.Handle):
/// <code>
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x0019 // PutItemInContainer
/// u32 itemGuid // server guid of the item
/// u32 containerGuid // destination container (player or bag)
/// i32 placement // 0 = server picks slot
/// </code>
/// </para>
/// </summary>
public static byte[] BuildPickUp(
uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PutItemInContainerOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), containerGuid);
BinaryPrimitives.WriteInt32LittleEndian (body.AsSpan(20), placement);
return body;
}
}

View file

@ -0,0 +1,48 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PickupEvent</c> GameMessage (opcode <c>0xF74A</c>).
///
/// <para>
/// ACE emits this from <c>Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)</c>
/// when a player picks up a world item — distinguishes the despawn
/// from a generic <c>0xF747 DeleteObject</c> (timeout / death /
/// out-of-LOS). Downstream effect on the client view is the same
/// (remove the entity from the world), so <see cref="WorldSession"/>
/// routes both opcodes to the same <c>EntityDeleted</c> event.
/// </para>
///
/// <para>
/// Wire layout (ACE <c>GameMessagePickupEvent.cs</c>):
/// <code>
/// u32 0xF74A
/// u32 guid
/// u16 objectInstanceSequence
/// u16 objectPositionSequence
/// </code>
/// </para>
/// </summary>
public static class PickupEvent
{
public const uint Opcode = 0xF74Au;
public readonly record struct Parsed(
uint Guid, ushort InstanceSequence, ushort PositionSequence);
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 12)
return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
if (opcode != Opcode)
return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
ushort positionSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(10, 2));
return new Parsed(guid, instanceSequence, positionSequence);
}
}

View file

@ -712,6 +712,19 @@ public sealed class WorldSession : IDisposable
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
else if (op == PickupEvent.Opcode)
{
// ACE sends PickupEvent (0xF74A) instead of DeleteObject
// when a player picks up a world item (Player_Tracking
// .RemoveTrackedObject with fromPickup=true). Downstream
// view-removal semantics are identical, so we adapt to
// DeleteObject.Parsed and reuse the existing handler.
var parsed = PickupEvent.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence));
}
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an

View file

@ -42,4 +42,46 @@ public sealed class InteractRequestsTests
Assert.Equal(InteractRequests.TeleToLifestoneOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void BuildPickUp_WritesOpcode0x0019AndPayload()
{
byte[] body = InteractRequests.BuildPickUp(
gameActionSequence: 5,
itemGuid: 0xABCDu,
containerGuid: 0x5000000Au,
placement: 0);
Assert.Equal(24, body.Length);
Assert.Equal(InteractRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(5u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(InteractRequests.PutItemInContainerOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0xABCDu,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(0x5000000Au,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
}
[Fact]
public void BuildPickUp_NegativePlacement_WritesSignedLittleEndian()
{
// Sign-correctness guard: placement is i32 on the wire (ACE
// GameActionPutItemInContainer.Handle reads ReadInt32). A
// placement=0 test would pass even if the builder used
// WriteUInt32, so we also exercise a negative value where the
// unsigned/signed encodings would diverge.
byte[] body = InteractRequests.BuildPickUp(
gameActionSequence: 1,
itemGuid: 0x1u,
containerGuid: 0x2u,
placement: -1);
Assert.Equal(-1,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
}
}

View file

@ -0,0 +1,41 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class PickupEventTests
{
[Fact]
public void RejectsWrongOpcode()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(PickupEvent.TryParse(body));
}
[Fact]
public void RejectsTruncated()
{
Assert.Null(PickupEvent.TryParse(ReadOnlySpan<byte>.Empty));
Assert.Null(PickupEvent.TryParse(new byte[11]));
}
[Fact]
public void ParsesGuidAndSequences()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, PickupEvent.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000727u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(10), 0x5678);
var parsed = PickupEvent.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x80000727u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
Assert.Equal((ushort)0x5678, parsed.Value.PositionSequence);
}
}