acdream/docs/superpowers/plans/2026-05-13-phase-b4b-plan.md
Erik 179e441d11 docs(B.4b): implementation plan — 6 tasks, TDD picker + handler wiring
Task-by-task plan with full code in every step, no placeholders.

Tasks 1+2: WorldPicker.BuildRay + WorldPicker.Pick (TDD: tests first,
8 xUnit Facts total).
Task 3: rename _selectedTargetGuid -> _selectedGuid (mechanical).
Task 4: add 3 OnInputAction switch cases + 3 private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse).
Task 5: Holtburg inn doorway visual test (user-performed).
Task 6: ship handoff + close #57 + roadmap/CLAUDE.md/memory updates.

Self-review table at bottom maps every spec section to its task(s);
all covered. Companion to spec
docs/superpowers/specs/2026-05-13-phase-b4b-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:37:09 +02:00

30 KiB

Phase B.4b — Outbound Use Handler Wiring Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire double-left-click and the R hotkey to a server BuildUse packet via a new WorldPicker so the M1 demo target "open the inn door" works and L.2g slice 1's deferred visual test verifies in the same scenario.

Architecture: New static AcDream.Core.Selection.WorldPicker (pure BuildRay + Pick functions, no state); rename _selectedTargetGuid_selectedGuid on GameWindow (unify combat + interaction selection on one field); add three switch cases (SelectLeft, SelectDblLeft, UseSelected) to GameWindow.OnInputAction calling three private helpers (PickAndStoreSelection, UseCurrentSelection, SendUse). Spec: docs/superpowers/specs/2026-05-13-phase-b4b-design.md.

Tech Stack: C# .NET 10 · xUnit · Silk.NET · System.Numerics


File map

File Op Why
src/AcDream.Core/Selection/WorldPicker.cs Create Static helper with BuildRay(mouse→world ray) + Pick(ray→entity guid). No state, no deps beyond WorldEntity + System.Numerics.
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs Create 8 xUnit [Fact]s covering BuildRay (center + offset) and Pick (hit/miss/closer/skip-guid/skip-zero/max-distance).
src/AcDream.App/Rendering/GameWindow.cs Modify Rename _selectedTargetGuid_selectedGuid (~5 sites). Add 3 switch cases + 3 helper methods.

No solution-file edits. New files land in existing projects (AcDream.Core for the picker, AcDream.Core.Tests for its tests; AcDream.App for the handler).


Task 1 — WorldPicker.BuildRay (TDD)

Files:

  • Create: src/AcDream.Core/Selection/WorldPicker.cs

  • Create: tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs

  • Step 1: Write the failing tests for BuildRay

Create tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs with:

using System;
using System.Numerics;
using AcDream.Core.Selection;
using Xunit;

namespace AcDream.Core.Tests.Selection;

public class WorldPickerTests
{
    private const float Epsilon = 0.01f;

    private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
    {
        var view = Matrix4x4.Identity;
        var proj = Matrix4x4.CreatePerspectiveFieldOfView(
            fieldOfView: MathF.PI / 3f,
            aspectRatio: 16f / 9f,
            nearPlaneDistance: 0.1f,
            farPlaneDistance: 100f);
        return (view, proj);
    }

    [Fact]
    public void BuildRay_CenterOfViewport_ReturnsForwardRay()
    {
        var (view, proj) = MakeIdentityCamera();
        const float vpW = 1920f, vpH = 1080f;

        var (_, direction) = WorldPicker.BuildRay(
            mouseX: vpW / 2f, mouseY: vpH / 2f,
            viewportW: vpW, viewportH: vpH,
            view, proj);

        // Right-handed perspective + identity view -> camera looks down -Z.
        // Center pixel ray = (0, 0, -1) within float epsilon.
        Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
        Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
        Assert.True(direction.Z < -0.99f,             $"direction.Z = {direction.Z}");
    }

    [Fact]
    public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
    {
        var (view, proj) = MakeIdentityCamera();
        const float vpW = 1920f, vpH = 1080f;

        var (_, direction) = WorldPicker.BuildRay(
            mouseX: vpW * 0.75f, mouseY: vpH / 2f,
            viewportW: vpW, viewportH: vpH,
            view, proj);

        Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
    }
}
  • Step 2: Run the tests, expect fail (class doesn't exist)

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"

Expected: build error CS0246: The type or namespace name 'WorldPicker' could not be found (or equivalent — AcDream.Core.Selection namespace doesn't exist yet).

  • Step 3: Create WorldPicker.cs with BuildRay

Create src/AcDream.Core/Selection/WorldPicker.cs:

using System.Numerics;
using AcDream.Core.World;

namespace AcDream.Core.Selection;

/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
///   <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
///   <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// </summary>
public static class WorldPicker
{
    /// <summary>
    /// Unprojects a pixel coordinate to a world-space ray using the supplied
    /// view + projection matrices (System.Numerics row-vector convention,
    /// composed as view * projection — same as the rest of acdream's camera
    /// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
    /// </summary>
    /// <returns>
    /// (origin = world point on the near plane, direction = normalized
    /// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
    /// if the view-projection composition is singular.
    /// </returns>
    public static (Vector3 Origin, Vector3 Direction) BuildRay(
        float mouseX, float mouseY,
        float viewportW, float viewportH,
        Matrix4x4 view, Matrix4x4 projection)
    {
        // Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
        float ndcX = (2f * mouseX) / viewportW - 1f;
        float ndcY = 1f - (2f * mouseY) / viewportH;

        var vp = view * projection;
        if (!Matrix4x4.Invert(vp, out var invVp))
            return (Vector3.Zero, Vector3.Zero);

        // Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
        var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
        var farClip  = new Vector4(ndcX, ndcY, +1f, 1f);
        var n4 = Vector4.Transform(nearClip, invVp);
        var f4 = Vector4.Transform(farClip,  invVp);
        if (n4.W == 0f || f4.W == 0f)
            return (Vector3.Zero, Vector3.Zero);

        var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
        var farWorld  = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
        var dir = farWorld - nearWorld;
        if (dir.LengthSquared() < 1e-10f)
            return (Vector3.Zero, Vector3.Zero);
        return (nearWorld, Vector3.Normalize(dir));
    }
}
  • Step 4: Run the tests, expect pass

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"

Expected: Passed: 2, Failed: 0. Both BuildRay_* tests pass.

  • Step 5: Commit
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection

New AcDream.Core.Selection.WorldPicker static helper. BuildRay
unprojects pixel (mouseX, mouseY) through a view+projection matrix
pair into a world-space (origin, direction) ray. Used by
GameWindow.OnInputAction to drive entity picking on click.

Pure math, no state, no DI. Composes view*projection (System.Numerics
row-vector convention, matching the rest of acdream's camera path —
see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit
tests cover center-of-viewport (forward ray) and right-of-center
(positive-X deflection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2 — WorldPicker.Pick (TDD)

Files:

  • Modify: src/AcDream.Core/Selection/WorldPicker.cs

  • Modify: tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs

  • Step 1: Write the failing tests for Pick

Append to tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs (inside the same class, before the closing }):

    private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
    {
        Id = serverGuid == 0u ? 1u : serverGuid,
        ServerGuid = serverGuid,
        SourceGfxObjOrSetupId = 0u,
        Position = position,
        Rotation = Quaternion.Identity,
        MeshRefs = Array.Empty<MeshRef>(),
    };

    [Fact]
    public void Pick_RayThroughEntity_ReturnsServerGuid()
    {
        var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: -Vector3.UnitZ,
            candidates: new[] { entity },
            skipServerGuid: 0u);

        Assert.Equal(0xABCDu, result);
    }

    [Fact]
    public void Pick_RayMisses_ReturnsNull()
    {
        var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: Vector3.UnitX,
            candidates: new[] { entity },
            skipServerGuid: 0u);

        Assert.Null(result);
    }

    [Fact]
    public void Pick_TwoEntitiesInLine_ReturnsCloser()
    {
        var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
        var far  = MakeEntity(0x2222u, new Vector3(0, 0, -20));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: -Vector3.UnitZ,
            candidates: new[] { far, near },  // iteration order shouldn't matter
            skipServerGuid: 0u);

        Assert.Equal(0x1111u, result);
    }

    [Fact]
    public void Pick_SkipsSkipGuid()
    {
        var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: -Vector3.UnitZ,
            candidates: new[] { entity },
            skipServerGuid: 0xABCDu);

        Assert.Null(result);
    }

    [Fact]
    public void Pick_SkipsZeroServerGuid()
    {
        // Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
        // and aren't valid Use targets — server would reject guid=0.
        var entity = MakeEntity(0u, new Vector3(0, 0, -10));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: -Vector3.UnitZ,
            candidates: new[] { entity },
            skipServerGuid: 0xDEADu);

        Assert.Null(result);
    }

    [Fact]
    public void Pick_BeyondMaxDistance_ReturnsNull()
    {
        var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: -Vector3.UnitZ,
            candidates: new[] { entity },
            skipServerGuid: 0u);  // default maxDistance = 50f

        Assert.Null(result);
    }

Also add using AcDream.Core.World; to the top of WorldPickerTests.cs (next to the existing using AcDream.Core.Selection;).

  • Step 2: Run the tests, expect fail (Pick doesn't exist)

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"

Expected: build error CS0117: 'WorldPicker' does not contain a definition for 'Pick'.

  • Step 3: Add Pick to WorldPicker.cs

Open src/AcDream.Core/Selection/WorldPicker.cs, add using System; and using System.Collections.Generic; to the imports, and append this method inside the WorldPicker class (after BuildRay):

    /// <summary>
    /// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
    /// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
    /// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
    /// </summary>
    /// <remarks>
    /// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
    /// statics) are skipped — they have no server-side identity and can't be
    /// the target of a Use packet. The player's own guid is skipped via
    /// <paramref name="skipServerGuid"/>.
    /// </remarks>
    public static uint? Pick(
        Vector3 origin, Vector3 direction,
        IEnumerable<WorldEntity> candidates,
        uint skipServerGuid,
        float maxDistance = 50f)
    {
        const float Radius  = 5f;
        const float Radius2 = Radius * Radius;

        if (direction.LengthSquared() < 1e-10f) return null;

        uint? bestGuid = null;
        float bestT    = float.PositiveInfinity;
        foreach (var entity in candidates)
        {
            if (entity.ServerGuid == 0u)              continue;
            if (entity.ServerGuid == skipServerGuid)  continue;

            // Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
            // c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
            // the ray misses the sphere. Otherwise nearest intersection is
            // t = -b - sqrt(discriminant).
            var oc   = origin - entity.Position;
            float b  = Vector3.Dot(oc, direction);
            float c  = Vector3.Dot(oc, oc) - Radius2;
            float d  = b * b - c;
            if (d < 0f) continue;

            float t = -b - MathF.Sqrt(d);
            if (t < 0f)            continue;   // ray points away or origin inside
            if (t >= maxDistance)  continue;
            if (t < bestT)
            {
                bestT    = t;
                bestGuid = entity.ServerGuid;
            }
        }
        return bestGuid;
    }
  • Step 4: Run the tests, expect pass

Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"

Expected: Passed: 8, Failed: 0. All 8 WorldPicker* tests pass.

  • Step 5: Commit
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): WorldPicker.Pick — ray-sphere entity pick

Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance)
to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips
entities with ServerGuid==0 (atlas/dat-hydrated statics — no server
identity) and the caller's skipServerGuid (the player self).
Geometric ray-sphere intersection at 5m radius (matches
WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid
within maxDistance (50m default), or null on miss.

6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid,
skip-zero-server-guid, beyond-max-distance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3 — Rename _selectedTargetGuid_selectedGuid

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs

Refactor only — no behavior change. Unifies combat (Q-cycle) and interaction (B.4b click) selection on one field. Retail-faithful: AC has one "current target," not two.

  • Step 1: Locate every reference

Run (Grep tool):

pattern: _selectedTargetGuid
path:    src/AcDream.App/Rendering/GameWindow.cs
output:  content with -n

Expected: ~5 hits, all inside GameWindow.cs. Then verify there are no references elsewhere:

pattern: _selectedTargetGuid
path:    src
output:  files_with_matches

Expected: only GameWindow.cs matches.

  • Step 2: Replace via the Edit tool (replace_all)

Edit src/AcDream.App/Rendering/GameWindow.cs with replace_all: true:

  • old_string: _selectedTargetGuid

  • new_string: _selectedGuid

  • Step 3: Build green

Run: dotnet build -c Debug

Expected: build succeeds with no new errors or warnings tied to the rename.

  • Step 4: Tests green

Run: dotnet test

Expected: 8 new WorldPickerTests pass on top of the prior baseline. The L.2g slice 1 handoff reported "1037 pass / 8 pre-existing-baseline fail." With +8 from Tasks 1+2, expect 1045 pass / 8 pre-existing fail.

  • Step 5: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid

Retail's selection model is a single "current target" used by combat,
interaction, NPC dialog, and HUD alike — not two parallel selections.
Renames the existing combat-only field on GameWindow so the upcoming
B.4b click handler and the existing Q-cycle SelectClosestCombatTarget
share the same selection state.

Mechanical rename, no behavior change. Build + tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4 — Wire OnInputAction handlers

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs

Add three private helper methods + three switch cases. Switch-case behavior is verified at runtime (Task 5 visual test); helpers depend on GameWindow state and aren't unit-tested.

  • Step 1: Add the three helper methods

Insert these three methods immediately above SelectClosestCombatTarget (around line 8706 — keep the selection-related helpers grouped). Use the Edit tool anchored on the line private uint? SelectClosestCombatTarget(bool showToast):

old_string:

    private uint? SelectClosestCombatTarget(bool showToast)

new_string:

    // ============================================================
    // Phase B.4b — outbound Use handler. Wires three input actions
    // (LMB click select, LMB-double-click select+use, R hotkey
    // use-selected) through WorldPicker into InteractRequests.BuildUse.
    // The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
    // ============================================================

    private void PickAndStoreSelection(bool useImmediately)
    {
        if (_cameraController is null || _window is null) return;

        var camera = _cameraController.Active;
        var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
            mouseX: _lastMouseX, mouseY: _lastMouseY,
            viewportW: _window.Size.X, viewportH: _window.Size.Y,
            view: camera.View, projection: camera.Projection);

        if (direction.LengthSquared() < 1e-6f) return;  // degenerate ray

        var picked = AcDream.Core.Selection.WorldPicker.Pick(
            origin, direction,
            _entitiesByServerGuid.Values,
            skipServerGuid: _playerServerGuid,
            maxDistance: 50f);

        if (picked is uint guid)
        {
            _selectedGuid = guid;
            string label = DescribeLiveEntity(guid);
            Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
            _debugVm?.AddToast($"Selected: {label}");
            if (useImmediately) SendUse(guid);
        }
        else
        {
            _debugVm?.AddToast("Nothing to select");
        }
    }

    private void UseCurrentSelection()
    {
        if (_selectedGuid is uint sel)
            SendUse(sel);
        else
            _debugVm?.AddToast("Nothing selected");
    }

    private void SendUse(uint guid)
    {
        if (_liveSession is null
            || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
        {
            _debugVm?.AddToast("Not in world");
            return;
        }
        var seq  = _liveSession.NextGameActionSequence();
        var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
        _liveSession.SendGameAction(body);
        Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
    }

    private uint? SelectClosestCombatTarget(bool showToast)

(The Edit replaces the single anchor line with the three new helpers + the same anchor line at the end, leaving SelectClosestCombatTarget's body untouched.)

  • Step 2: Add the three switch cases

In GameWindow.OnInputAction's switch (currently GameWindow.cs:8546-8646), add three new case blocks immediately before the case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: branch.

Use the Edit tool anchored on:

old_string:

            case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
                if (_cameraController?.IsFlyMode == true)

new_string:

            case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
                PickAndStoreSelection(useImmediately: false);
                break;

            case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
                PickAndStoreSelection(useImmediately: true);
                break;

            case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
                UseCurrentSelection();
                break;

            case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
                if (_cameraController?.IsFlyMode == true)
  • Step 3: Build green

Run: dotnet build -c Debug

Expected: build succeeds with no new errors. Any new warnings should be tied only to the additions.

  • Step 4: Tests green

Run: dotnet test

Expected: same 1045 pass / 8 pre-existing-baseline fail from Task 3.

  • Step 5: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker

Closes #57. Adds three OnInputAction switch cases (SelectLeft,
SelectDblLeft, UseSelected) and three private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse). Single-click
selects but does not Use; double-click selects + Uses; R hotkey
sends Use on the existing _selectedGuid. ImGui mouse-capture
filtering already happens in InputDispatcher — no new guard needed.

Diagnostic lines emitted for log grep:
  [B.4b] pick guid=0x{guid:X8} name={label}
  [B.4b] use  guid=0x{guid:X8} seq={seq}

Build green; tests 1045/1053 (8 pre-existing-baseline fails
unchanged). Switch-case behavior verified at runtime via the Holtburg
inn doorway visual test (per spec §Testing → Runtime verification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5 — Visual verification at Holtburg inn doorway

This task is performed by the user. The implementing agent runs the launch command (background) and reports completion; the user observes the running client and reports the result.

  • Step 1: Kill any stale client process

Run via Bash tool:

Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
  • Step 2: Launch the client with B.4b + L.2g probes enabled

Run via Bash tool with run_in_background: true:

$env:ACDREAM_DAT_DIR        = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE           = "1"
$env:ACDREAM_TEST_HOST      = "127.0.0.1"
$env:ACDREAM_TEST_PORT      = "9000"
$env:ACDREAM_TEST_USER      = "testaccount"
$env:ACDREAM_TEST_PASS      = "testpassword"
$env:ACDREAM_DEVTOOLS       = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE  = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
  Tee-Object -FilePath "launch-b4b.log"
  • Step 3: User performs the scenario

In the running client:

  1. Wait ~8s for the player to spawn at Holtburg.
  2. Walk to the inn doorway (north side of the south building).
  3. Double-left-click the closed door.
  4. Observe: swing animation should play.
  5. Walk forward through the open doorway.
  6. Wait ~30s in the inn.
  7. Observe: auto-close animation should fire.
  8. Close the client window.
  • Step 4: Grep the log
Select-String -Path launch-b4b.log -Pattern `
  "B\.4b|setstate-hex|\[setstate\]|input.*SelectDblLeft|entity-source.*Door"

Expected matches (approximate order):

  • [entity-source] name=Door ... state=0x00000000 flags=None ... (door spawn at world load)

  • [input] SelectDblLeft Press (dispatcher fires on the user's click)

  • [B.4b] pick guid=0x000F???? name=Door (picker hit)

  • [B.4b] use guid=0x000F???? seq=N (outbound Use fires)

  • [setstate-hex] body.len=16 ... (L.2g hex probe — first SetState body)

  • [setstate] guid=0x000F???? state=0x00000014 (or 0x00000004) (door opens)

  • [setstate] guid=0x000F???? state=0x00000000 ~30s later (auto-close)

  • Step 5: Decide on follow-up based on the observed state value

  • If the state bits include 0x10 (so the value is 0x14 or higher), CollisionExemption.ShouldSkip short-circuits as designed — no follow-up needed.

  • If the state is 0x4 (ETHEREAL only, no IGNORE_COLLISIONS), file a tiny L.2g slice 1b to widen the check. The fix is a one-line edit to src/AcDream.Core/Physics/CollisionExemption.cs. Out of B.4b scope — record the finding and move on.


Task 6 — Ship handoff + post-merge updates

Files (all modified or created):

  • Create: docs/research/2026-05-13-b4b-shipped-handoff.md

  • Modify: docs/ISSUES.md (close #57)

  • Modify: docs/plans/2026-04-11-roadmap.md (add B.4b row to "shipped" table)

  • Modify: CLAUDE.md ("Currently in Phase L.2" paragraph)

  • Modify (outside-repo): C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md

  • Step 1: Write the ship-handoff doc

Create docs/research/2026-05-13-b4b-shipped-handoff.md summarizing:

  • 4 commits (BuildRay, Pick, rename, handler wiring)
  • The actual state=0x?? value observed in Task 5 step 4
  • Whether L.2g slice 1b is needed (decided in Task 5 step 5)
  • Whether picker tuning is needed (5m radius too generous/strict)

Use the same structure as docs/research/2026-05-12-l2g-slice1-shipped-handoff.md — TL;DR + commit table + end-to-end flow + open notes + reproducibility.

  • Step 2: Move #57 from Active to Recently Closed in docs/ISSUES.md

Edit docs/ISSUES.md:

  • Cut the ## #57 — B.4 interaction-handler missing block from "Active issues".

  • Paste it under "Recently closed" with header changed to ## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing and add a Closed: line with this PR's merge commit SHA.

  • Step 3: Update the roadmap's shipped table

Edit docs/plans/2026-04-11-roadmap.md. Add a new row to the "shipped" table (preserving existing column structure):

| 2026-05-13 | Phase B.4b — Outbound Use handler wiring | <commit> | Closes #57. WorldPicker + 3 switch cases. M1 demo target "open the inn door" verified at Holtburg. |
  • Step 4: Update CLAUDE.md "Currently in Phase L.2" paragraph

Edit CLAUDE.md:

  • Change the "L.2g slice 1 is CODE-COMPLETE..." paragraph to "L.2g slice 1 + B.4b shipped and visual-verified 2026-05-13 at the Holtburg inn doorway."

  • Remove the "natural next step is Phase B.4b" paragraph; replace with the next phase candidate from the existing candidate list (the user picks order; in absence of new evidence, the Triage open issues option is the natural follow-up).

  • Step 5: Update the memory file

Edit C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md:

  • Mark Phase B.4 outbound-handler gap as closed by B.4b (2026-05-13).

  • Add the new flow: LMB-dblclick → WorldPicker → BuildUse → SendGameAction.

  • Update the WorldPicker and SelectionState claims:

    • WorldPicker now exists in AcDream.Core.Selection.
    • SelectionState still doesn't exist — deferred to M2 HUD work.
  • Step 6: Commit the in-repo docs

git add docs/research/2026-05-13-b4b-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(B.4b): ship handoff + close #57 + roadmap/CLAUDE update

L.2g slice 1 + B.4b verified at Holtburg inn doorway:
- Player double-clicks closed door
- BuildUse fires, ACE responds with SetState 0xF74B
- ShadowObjectRegistry mutates ETHEREAL bit
- CollisionExemption short-circuits, player walks through
- 30s auto-close fires on schedule

Closes #57. Updates roadmap shipped table and CLAUDE.md Phase L.2
paragraph. Memory file project_interaction_pipeline.md updated outside
the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

(The memory file lives outside the repo and isn't tracked by git — update it but don't include it in the commit.)

  • Step 7: Merge to main
git checkout main
git merge --no-ff claude/compassionate-wilson-23ff99 -m "Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slice 1 visual-verified"

Do NOT push without explicit user authorization (CLAUDE.md rule).


Self-review against the spec

Spec section Plan task(s) Coverage
§Architecture: WorldPicker.cs in AcDream.Core.Selection Tasks 1, 2 covered
§Architecture: rename _selectedTargetGuid Task 3 covered
§Architecture: 3 switch cases + 3 helpers Task 4 covered
§Components: BuildRay signature + math Task 1 step 3 covered
§Components: Pick signature + ServerGuid==0 skip Task 2 step 3 covered
§Components: PickAndStoreSelection toast + [B.4b] pick log Task 4 step 1 covered
§Components: SendUse gate + [B.4b] use log Task 4 step 1 covered
§Components: UseCurrentSelection Task 4 step 1 covered
§Components: 3 switch cases Task 4 step 2 covered
§Testing: 8 unit tests Tasks 1+2 covered (2 BuildRay + 6 Pick)
§Testing: runtime verification at Holtburg Task 5 covered
§Testing: log grep + state-value decision Task 5 step 4-5 covered
§Acceptance: build + tests green Tasks 3+4 steps 3-4 covered
§Acceptance: ISSUES.md #57 → Recently closed Task 6 step 2 covered
§Acceptance: roadmap update Task 6 step 3 covered
§Acceptance: CLAUDE.md update Task 6 step 4 covered
§Open question: state 0x4 vs 0x14 follow-up Task 5 step 5 covered (deferred to L.2g slice 1b if needed)
§Non-goals: BuildPickUp / UseWithTarget UX / SelectionState class (none — explicitly deferred) covered by omission

No placeholders. No "TBD." Every code step has the actual code; every command step has the exact command and the expected output. Type names match across tasks (WorldPicker.BuildRay / WorldPicker.Pick, _selectedGuid, PickAndStoreSelection / UseCurrentSelection / SendUse).