feat(retail): Commit B — retail-faithful AP cadence + screen-rect picker

Retires divergences flagged in the 2026-05-16 faithfulness audit:

1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat
   with a diff-driven model gated on `Contact && OnWalkable`
   (acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on
   position or cell change while grounded on walkable, plus a 1 sec
   heartbeat; suppressed entirely airborne. PlayerMovementController
   exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps
   after each AutonomousPosition / MoveToState send — mirrors retail's
   shared `last_sent_position_time` between SendPositionEvent
   (0x006b4770) and SendMovementEvent (0x006b4680). Known divergence
   from retail: ours is per-frame-while-moving, retail's effective rate
   is ~1 Hz during smooth motion (cell/plane checks). Filed as #74,
   blocked by #63 — when #63 lands we revert to retail's narrower gate.

2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival)
   and the AP-flush before re-send (`SendAutonomousPositionNow`). The
   diff-driven cadence makes both obsolete. Close-range turn-first
   deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87
   mirrors retail's CreateMoveToChain pre-callback rotation), renamed
   `OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send.
   `isRetryAfterArrival` parameter dropped.

3. Far-range Use/PickUp retry. Restored — was load-bearing, not the
   "redundant cleanup" the Group 2 audit thought. Issue #63 means ACE
   drops the first Use as too-far without re-polling on subsequent APs;
   the arrival re-send is what makes far-range Use complete. Logs
   include `(queued for arrival re-send pending #63)` to make this
   explicit. Removes when #63 closes.

4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection`
   helper shared by `WorldPicker` and `TargetIndicatorPanel`. The
   `Setup.SelectionSphere` projects to a screen-space square (retail
   anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker
   hit-tests the mouse pixel against the same rect the indicator draws,
   inflated by 8 px (`TriangleSize`). Guarantees what-you-see is
   what-you-click — including rect corners that were dead zones under
   the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and
   vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired;
   `IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale
   defensive default. No defensive sphere synth — entities without a
   baked `SelectionSphere` are skipped, matching retail's
   `GfxObjUnderSelectionRay` (0x0054c740).

5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)`
   helper applies retail's `run_turn_factor = 1.5f` (PDB-named
   0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at
   0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s.
   Keyboard A/D + ApplyAutoWalkOverlay both use it.

6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to
   `useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero
   passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the
   USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail
   (ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697,
   UsingItem 0x367638, disable-button-state 0x198826). Added
   `ProbeUseabilityFallbackEnabled` diagnostic
   (`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the
   creature/BF_DOOR fallback fires for ACE-seed-DB entities with
   null useability.

CLAUDE.md updated with the graceful-shutdown rule for relaunch:
Stop-Process bypasses the logout packet, leaving ACE's session marked
logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the
shutdown hook runs and the logout packet reaches ACE.

Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9.
Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures
unchanged). Visual-verified 2026-05-16: rotation rate, useability,
screen-rect click area, double-click + R-key + F-key Use/PickUp at
short and long range — dialogue/door/pickup fire on arrival.

Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker
Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message
sweep pattern), #74 (per-frame AP chattier than retail — blocked by
#63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)`
overload kept for back-compat; no callers in acdream proper.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-16 13:56:08 +02:00
parent e2bc3a9e99
commit b5da17db76
10 changed files with 1348 additions and 573 deletions

View file

@ -46,6 +46,174 @@ Copy this block when adding a new issue:
# Active issues
## #74 — AP cadence is per-frame-while-moving, more chatty than retail
**Status:** OPEN
**Severity:** LOW (works; just sends ~60× the packets retail would during smooth motion)
**Filed:** 2026-05-16
**Component:** physics / net cadence
**Description:** The diff-driven AP cadence shipped in Commit B fires
`HeartbeatDue` on **any** position change each frame while grounded
on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
heartbeat when idle. Retail's `ShouldSendPositionEvent`
(`acclient_2013_pseudo_c.txt:700233`) only sends during the
sub-interval when cell or contact-plane changes, and only sends the
1 Hz heartbeat if `(cellId, frame)` changed since `last_sent`
truly idle = 0 Hz. So retail during continuous smooth movement is
effectively 1 Hz (cell crosses + plane changes don't happen every
frame); we are ~60 Hz.
**Root cause / status:** Deliberate ACE-targeted choice. The
per-frame cadence is load-bearing for ACE's `WithinUseRadius` poll
to see the player arrive at a target during local speculative
auto-walk (issue #63's workaround chain). Going to 1 Hz would
re-introduce the arrival-lag bug for far-range Use/PickUp.
**Files:** [PlayerMovementController.cs:1240-1275](src/AcDream.App/Input/PlayerMovementController.cs)
— the `HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed)`
gate.
**Acceptance:** Either (a) fix issue #63 so we honor ACE's
`MoveToObject` server-side, removing the need for the per-frame
cadence, then revert to retail's `cell-or-plane-change || (interval && frame-change)`
shape (~5 LOC change); or (b) document this as a permanent
divergence and update commit messages / code comments to match.
**Estimated scope:** Small (~5 LOC + commit-message rewrite) once
#63 is fixed. Currently blocked by #63.
---
## #73 — Retail-message centralization plan — per-feature string sweeps
**Status:** OPEN
**Severity:** LOW (per-feature work, not infrastructure)
**Filed:** 2026-05-16
**Component:** ui / retail messages
**Description:** Commit A added `AcDream.Core.Ui.RetailMessages` as
the home for retail-decomp-sourced UI strings (`CannotBeUsed`,
`CantBePickedUp`, `CannotPickUpCreatures`). The retail decomp has
~750 more user-facing strings we'll need over time — combat misses,
spell fizzles, vendor dialogs, "you do not have enough" etc. Rather
than bulk-port them once, port per-feature as the feature lands:
when wiring vendor purchase, sweep vendor strings into
`RetailMessages.Vendor.*`; when wiring spell-cast feedback, sweep
`RetailMessages.Spell.*`.
**Status:** No infrastructure work pending. Pattern is established;
new strings get added to `RetailMessages.cs` with retail anchor
comments at the call site that triggered the need.
**Files:** [RetailMessages.cs](src/AcDream.Core/Ui/RetailMessages.cs)
— class-level doc comment already describes the per-feature sweep
pattern.
**Acceptance:** Each phase / feature that adds new user-facing
strings sweeps its retail-anchor strings into `RetailMessages` and
calls them by name rather than literal-in-place. Closing condition:
"all M1 demo strings are in RetailMessages" or similar per-milestone
gate, decided when M1 ships.
---
## #72 — Confirm Humanoid TurnRight/TurnLeft `omega.z` base rate via cdb
**Status:** OPEN
**Severity:** LOW (current ±π/2 fallback matches all corroborating
evidence; cdb probe would settle the open question for good)
**Filed:** 2026-05-16
**Component:** physics / rotation / research
**Description:** Commit A's rotation rate uses
`BaseTurnRateRadPerSec = π/2` based on the documented
`AnimationSequencer.cs:734-741` claim that the Humanoid motion table
ships TurnRight/TurnLeft with `HasOmega` cleared (forcing the
convention fallback). The constant has 3 corroborating sources but
the actual dat content was never dumped — and the run-multiplier
`run_turn_factor = 1.5` at retail `0x007c8914` from
`apply_run_to_command` (decomp 0x00527be0) likewise hasn't been
verified live.
**Acceptance:** Set a cdb breakpoint on `CSequence::set_omega`
(`acclient_2013_pseudo_c.txt` — find exact symbol address) while
holding A or D in a retail client. Capture the `omega.z` argument
value walking, then running. If `±π/2` walking and `±π/2 × 1.5 ≈ 2.356`
running, close as confirmed. If different, file as a regression and
fix the constants in
[RemoteMoveToDriver.cs](src/AcDream.Core/Physics/RemoteMoveToDriver.cs).
**Estimated scope:** ~30 min cdb session + 1 commit if confirmed,
or +small fix if different. Not blocking M1.
---
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
**Status:** OPEN
**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
**Filed:** 2026-05-16
**Component:** selection / picker
**Description:** Retail's mouse picker does two-tier sphere-then-polygon
selection (`acclient_2013_pseudo_c.txt:0x0054c740`
`Render::GfxObjUnderSelectionRay`):
1. Per-part sphere reject via `CGfxObj::drawing_sphere`.
2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every
visual polygon; closest-t polygon hit wins over any sphere hit.
Commit B's Stage A
([WorldPicker.cs](src/AcDream.Core/Selection/WorldPicker.cs)) does
screen-space rect hit-test against the projected
`Setup.SelectionSphere` (matching the indicator rect, deliberately
broader than the visible mesh polygons). Stage B would tighten clicks
to the visible mesh — under-pick what looks like empty space inside
the rect, catch visible mesh that pokes past the sphere boundary
(creature outstretched arm, sign edge).
**Acceptance:** Pipe per-part GfxObj visual polygons through a
`PickPolygonProvider` interface (don't duplicate mesh decoding —
hook the existing `ObjectMeshManager` cached data). Two-tier in
`WorldPicker.Pick`: sphere reject → polygon scan → polygon hit
dominates sphere hit. Acceptance test: visible-mesh accuracy on
Holtburg sign, Royal Guard outstretched bow arm, inn-door wood
frame edges.
**Estimated scope:** Medium (~4-6 hours). Defer until visual
verification surfaces a Stage A miss in real play. The user
confirmed 2026-05-16 that "I can click on longer ranges now so
good" — Stage A is enough for M1's "click an NPC" demo.
---
## #70 — Triangle apex/size — final retail-feel UX pass
**Status:** OPEN
**Severity:** LOW (cosmetic — indicator already retail-anchored, this is final-feel polish)
**Filed:** 2026-05-16
**Component:** ui / target indicator
**Description:** Per 2026-05-16 user feedback during the
`SelectionSphere` indicator ship, the triangle apex direction
(flipped to point inward at the target) and sprite size (currently
8 px legs) are heuristic visual choices. Retail uses an actual DAT
sprite from `UIRegion::GetChild(0x1000003a/3b/3c)` — the bitmap
shape and size come from the dat, not constants.
**Acceptance:** Extract the retail triangle sprite from the dat
(probably via `tools/UiLayoutMockup` or a new `DatSpriteProbe`) and
either (a) blit the exact bitmap, or (b) pick a procedural size +
shape that matches it pixel-for-pixel at standard zoom.
**Files:** [TargetIndicatorPanel.cs](src/AcDream.App/UI/TargetIndicatorPanel.cs)
`TriangleSize` constant + the four `AddTriangleFilled` calls.
**Estimated scope:** Small (~1-2 hours, mostly dat exploration).
Not blocking M1.
---
## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
**Status:** OPEN

View file

@ -18,8 +18,9 @@
- `0x006b4770` `SendPositionEvent``transient_state & (CONTACT_TS | ON_WALKABLE_TS)` gate.
- `0x00588a80` `ItemHolder::UseObject` — single fire-and-forget `Event_UseEvent`.
- `0x00564900` `Handle_Item__UseDone` — server signals "use done" inbound.
- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine (we use `Setup.SelectionSphere` as a simpler equivalent for Stage A).
- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine. We approximate with a screen-space rect hit-test that uses the exact rect the target indicator already draws (guarantees click area = visible bracket area, zero corner dead zones).
- `0x00518b80` `CPartArray::GetSelectionSphere` — scale formula.
- `0x00452e20` `SmartBox::GetObjectBoundingBox` — projects `CSetup.SelectionSphere` to a screen-aligned rect. The indicator AND the new picker call into the same projection helper.
---
@ -40,10 +41,11 @@
| File | Responsibility |
|---|---|
| `src/AcDream.App/Input/PlayerMovementController.cs` | Replace `_heartbeatAccum` with diff-driven `HeartbeatDue` (position/cell change + 1s heartbeat + `IsGrounded` gate). Add `NotePositionSent(pos, cellId, now)` + `SimTimeSeconds` accessor. Delete `TinyMargin` from `ApplyAutoWalkOverlay`. Add `ApproxPositionEqual` helper. |
| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to use `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(...)` overload taking `Func<WorldEntity, (Vector3, float)?> sphereForEntity`. Keep existing per-radius-callback overload for now (no callers after B8). |
| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. |
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new sphere-resolver overload. |
| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to the new screen-rect overload using `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
| `src/AcDream.Core/Selection/ScreenProjection.cs` (new) | Shared math: `TryProjectSphereToScreenRect(worldCenter, worldRadius, view, projection, viewport, out rectMin, out rectMax, out depth, minSidePixels)`. Factored out of `TargetIndicatorPanel.TryComputeScreenRectFromSphere` so the picker AND the indicator project identically. |
| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(mouseX, mouseY, view, projection, viewport, candidates, skipGuid, sphereForEntity, inflatePixels=8f)` screen-rect-hit-test overload. Keep existing ray-sphere overload for now (no callers after B8). |
| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. Replace private `TryComputeScreenRectFromSphere` with a call into `ScreenProjection.TryProjectSphereToScreenRect`. |
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new rect-hit-test overload + tests for `ScreenProjection.TryProjectSphereToScreenRect`. |
| `docs/ISSUES.md` | File 3 deferred follow-ups (Triangle apex/size UX; Stage B polygon refine; cdb-probe to verify `omega.z = π/2`). |
---
@ -620,10 +622,14 @@ Replace with:
// - When interval NOT elapsed: send only if position or cell
// differs from last_sent (Frame::is_equal check at
// acclient_2013_pseudo_c.txt:700248-700265).
// - SendPositionEvent gates on transient_state &
// (CONTACT_TS | ON_WALKABLE_TS) — i.e., grounded on a
// walkable surface. Airborne suppresses AP entirely.
// MoveToState carries jump/fall snapshots while airborne.
// - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
// gates on `((state & 1) != 0 && (state & 2) != 0)`
// Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
// bit 1) both set. Two independent `& != 0` tests joined by
// `&&`, NOT a single bitwise-OR mask test. Airborne (neither
// bit) and wall-contact-without-walkable (Contact only)
// both suppress AP. MoveToState carries jump/fall snapshots
// while airborne.
//
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
@ -641,20 +647,17 @@ Replace with:
|| _lastSentCellId != CellId
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
// Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
// (acclient.h:3688). Our equivalent: PhysicsBody.IsGrounded.
bool groundedOnWalkable = _body.IsGrounded;
// Grounded-on-walkable. Retail's `Contact AND OnWalkable`
// (acclient_2013_pseudo_c.txt:700327). PhysicsBody exposes the
// two transient-state bits as InContact + OnWalkable; combine
// with `&&` to match retail's two-`& != 0`-tests-joined-by-`&&`
// pattern (NOT a single bitwise-OR mask test).
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
```
If `PhysicsBody.IsGrounded` doesn't exist, search the codebase for the equivalent predicate:
```
grep -n "IsGrounded\|OnGround\|HasContact\|GroundContact" src/AcDream.Core/Physics/PhysicsBody.cs
```
Use whichever exists. If neither, derive from `_body.ContactPlane.Normal.Z > 0.5f` (humanoid walkable-normal threshold per retail FloorZ ≈ 0.66). Document the choice in a comment at the call site.
`PhysicsBody.InContact` and `PhysicsBody.OnWalkable` are existing convenience properties that wrap `TransientState.HasFlag(TransientStateFlags.Contact)` / `.OnWalkable`. The same `Contact && OnWalkable` "grounded" pattern is used elsewhere in our physics layer (e.g., `MotionInterpreter.cs:808-809` for jump-start gating), so this matches the codebase convention.
- [ ] **Step 3: Add `ApproxPositionEqual` helper**
@ -1010,15 +1013,204 @@ Expected: 0 errors. If there are remaining "isRetryAfterArrival" references, fix
---
### Task B7: Add `WorldPicker.Pick` overload taking sphere-resolver
### Task B7: Factor screen-rect projection into shared helper + add screen-rect picker overload
**Goal:** Retail's picker uses two-tier sphere-then-polygon selection (`Render::GfxObjUnderSelectionRay` 0x0054c740). Our Stage A approximates Stage 1 (sphere reject) — but the indicator draws a SCREEN-SPACE rect, not a 3D sphere. User feedback 2026-05-16: "the range we can click + the area of the object that's clickable should be faithful to retail" — meaning the click hit-area must match what the indicator visibly bounds. A pure world-space ray-sphere picker can't make that guarantee (rect corners are sphere dead zones).
**Approach:** Picker hit-tests against the SAME screen-space rect the indicator draws. The projection math is factored into a shared `ScreenProjection` helper so the indicator and the picker can't drift apart.
**Files:**
- Create: `src/AcDream.Core/Selection/ScreenProjection.cs`
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
- Modify: `src/AcDream.App/UI/TargetIndicatorPanel.cs` (use shared helper; private copy deleted)
- Test: `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs` (new file)
- Test: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (modify or create)
- [ ] **Step 1: Write the failing test**
- [ ] **Step 1: Write failing tests for `ScreenProjection`**
Find or create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`. Add:
Create `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs`:
```csharp
using System.Numerics;
using AcDream.Core.Selection;
namespace AcDream.Core.Tests.Selection;
public sealed class ScreenProjectionTests
{
// Standard right-handed perspective + identity view. Sphere centered
// at z=+10 in front of camera, radius 1, viewport 800x600.
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f /*fovY 90°*/, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800, 600);
return (view, proj, viewport);
}
[Fact]
public void TryProject_SphereInFront_ReturnsSquareRect()
{
// System.Numerics CreatePerspectiveFieldOfView is right-handed
// (looks down -Z). Place sphere at -10 along Z so it sits in
// front of the camera.
var (view, proj, viewport) = StdCam();
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
new Vector3(0, 0, -10), worldRadius: 1f,
view, proj, viewport,
out var rMin, out var rMax, out var depth,
minSidePixels: 0f);
Assert.True(ok);
// 90° FOV at depth 10 -> screen half-extent = 1 unit projects to
// viewport.Y/2 pixels per world-unit at near plane = 1/tan(45°).
// The rect should be a square (width == height).
Assert.Equal(rMax.X - rMin.X, rMax.Y - rMin.Y, precision: 3);
// Rect should be centered (approximately) on the screen center.
Assert.InRange((rMin.X + rMax.X) * 0.5f, 399f, 401f);
Assert.InRange((rMin.Y + rMax.Y) * 0.5f, 299f, 301f);
Assert.True(depth > 0f);
}
[Fact]
public void TryProject_SphereBehindCamera_ReturnsFalse()
{
var (view, proj, viewport) = StdCam();
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
new Vector3(0, 0, +10) /* behind RH camera at origin */,
worldRadius: 1f,
view, proj, viewport,
out _, out _, out _,
minSidePixels: 0f);
Assert.False(ok);
}
[Fact]
public void TryProject_FarSphereClampsToMinSide()
{
var (view, proj, viewport) = StdCam();
bool ok = ScreenProjection.TryProjectSphereToScreenRect(
new Vector3(0, 0, -90) /* very far */, worldRadius: 0.01f /* tiny */,
view, proj, viewport,
out var rMin, out var rMax, out _,
minSidePixels: 12f);
Assert.True(ok);
Assert.True(rMax.X - rMin.X >= 12f);
Assert.True(rMax.Y - rMin.Y >= 12f);
}
}
```
- [ ] **Step 2: Build the test project — confirm test failure**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
```
Expected: build failure (`ScreenProjection` doesn't exist yet).
- [ ] **Step 3: Create `src/AcDream.Core/Selection/ScreenProjection.cs`**
```csharp
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Shared screen-space projection math for the target indicator and the
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/>
/// so the click hit-area is guaranteed to match the visible indicator
/// rect — "what you see is what you click".
///
/// <para>
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
/// <c>0x00452e20</c>, which uses
/// <c>Render::GetViewerBBox(selection_sphere, &amp;corner1, &amp;corner2)</c>
/// to compute a camera-aligned bbox of the sphere and projects the two
/// corner points. We use the mathematical equivalent (project center,
/// compute screen radius analytically) — both produce identical pixel
/// rects for a standard right-handed perspective.
/// </para>
/// </summary>
public static class ScreenProjection
{
/// <summary>
/// Project a world-space sphere to a screen-space axis-aligned square
/// rectangle.
/// </summary>
/// <param name="worldCenter">Sphere center in world space.</param>
/// <param name="worldRadius">Sphere radius in world space.</param>
/// <param name="view">View matrix (System.Numerics row-vector convention).</param>
/// <param name="projection">Projection matrix. <c>M22 = cot(fovY/2)</c>
/// for a standard right-handed perspective.</param>
/// <param name="viewport">Viewport size in pixels (X = width, Y = height).</param>
/// <param name="rectMin">Out: top-left corner of the rect in viewport pixels.</param>
/// <param name="rectMax">Out: bottom-right corner of the rect in viewport pixels.</param>
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
/// center — use this for nearest-first sorting when multiple rects overlap.</param>
/// <param name="minSidePixels">Minimum side length of the rect. Distant
/// entities clamp to this so they remain pickable / visible. 12 px
/// matches the indicator's clamp floor.</param>
/// <returns>
/// <c>true</c> if the sphere is in front of the camera and the rect was
/// produced; <c>false</c> if the center is behind the camera
/// (<c>clip.W &lt;= 0</c>) or the rect is more than a screen offset
/// from the viewport (obviously off-screen).
/// </returns>
public static bool TryProjectSphereToScreenRect(
Vector3 worldCenter, float worldRadius,
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
out Vector2 rectMin, out Vector2 rectMax, out float depth,
float minSidePixels = 12f)
{
rectMin = default;
rectMax = default;
depth = 0f;
var viewProj = view * projection;
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
if (clip.W <= 0.001f) return false;
depth = clip.W;
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
// the camera-space distance.
float scaleY = projection.M22;
if (scaleY <= 0f) return false;
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
// Cull obviously-off-screen entities (more than a screen away).
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
// Floor at minSidePixels so distant entities still get a visible /
// clickable rect. The picker must apply the same floor as the
// indicator or distant clicks won't match the visible bracket.
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
return true;
}
}
```
- [ ] **Step 4: Run `ScreenProjection` tests — confirm pass**
```
dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
```
Expected: 3/3 pass.
- [ ] **Step 5: Write failing tests for the new `WorldPicker.Pick` rect overload**
In `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (create if missing):
```csharp
using System.Numerics;
@ -1027,166 +1219,280 @@ using AcDream.Core.World;
namespace AcDream.Core.Tests.Selection;
public sealed class WorldPickerSphereOverloadTests
public sealed class WorldPickerRectOverloadTests
{
[Fact]
public void Pick_SphereResolver_ReturnsNearestHit()
// Same right-handed perspective as ScreenProjectionTests.
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
{
// Two entities along the +Y axis. Sphere-resolver gives each a
// tight world-space sphere centered on the entity. A ray from
// origin pointing along +Y should hit the closer entity first.
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 15, 0), Scale = 1f };
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800, 600);
return (view, proj, viewport);
}
var origin = new Vector3(0, 0, 0);
var dir = new Vector3(0, 1, 0);
Vector3 SphereCenter(WorldEntity e) => e.Position + new Vector3(0, 0, 0.9f);
[Fact]
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
{
var (view, proj, viewport) = StdCam();
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
// Mouse at screen center; entity at world (0,0,-10) projects to
// screen center. Rect contains screen center → hit.
uint? picked = WorldPicker.Pick(
origin, dir, new[] { e1, e2 },
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: e => ((Vector3, float)?)(SphereCenter(e), 1.0f));
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
public void Pick_SphereResolver_NullSkipsCandidates()
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
{
// Resolver returning null should make the picker skip the
// candidate (matches retail "no Setup → not pickable" behaviour).
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
var (view, proj, viewport) = StdCam();
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
var origin = new Vector3(0, 0, 0);
var dir = new Vector3(0, 1, 0);
// Mouse far from entity rect → no hit.
uint? picked = WorldPicker.Pick(
mouseX: 50f, mouseY: 50f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Null(picked);
}
[Fact]
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
{
var (view, proj, viewport) = StdCam();
// Two entities both at screen center but different depths.
var near = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -8), Scale = 1f };
var far = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
uint? picked = WorldPicker.Pick(
origin, dir, new[] { e1, e2 },
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { far, near } /* deliberately reversed */,
skipServerGuid: 0u,
sphereForEntity: e => e.ServerGuid == 0x10001u
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
public void Pick_RectHitTest_NullResolverSkipsCandidates()
{
var (view, proj, viewport) = StdCam();
var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -20), Scale = 1f };
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { e1, e2 },
skipServerGuid: 0u,
sphereForEntity: x => x.ServerGuid == 0x10001u
? ((Vector3, float)?)null
: ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
: ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
public void Pick_SphereResolver_RespectsSkipServerGuid()
public void Pick_RectHitTest_RespectsSkipServerGuid()
{
var e1 = new WorldEntity { ServerGuid = 0x50000001u, Position = new Vector3(0, 5, 0), Scale = 1f };
var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
var origin = new Vector3(0, 0, 0);
var dir = new Vector3(0, 1, 0);
var (view, proj, viewport) = StdCam();
var player = new WorldEntity { ServerGuid = 0x5000000Au, Position = new Vector3(0, 0, -10), Scale = 1f };
var npc = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
uint? picked = WorldPicker.Pick(
origin, dir, new[] { e1, e2 },
skipServerGuid: 0x50000001u, // skip player
sphereForEntity: e => ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { player, npc },
skipServerGuid: 0x5000000Au,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
public void Pick_RectHitTest_InflateExpandsClickableArea()
{
var (view, proj, viewport) = StdCam();
var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
// First: with inflate=0, a mouse 30 px outside the rect misses.
uint? withoutInflate = WorldPicker.Pick(
mouseX: 400f + 200f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Null(withoutInflate);
// Then: same mouse position, with a 250 px inflate, now hits.
uint? withInflate = WorldPicker.Pick(
mouseX: 400f + 200f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 250f);
Assert.Equal(0x10001u, withInflate);
}
}
```
NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test constructor calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / object-initializer pattern. The tests assume property initializers are allowed; if not, switch to whatever constructor the type exposes.
NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test object-initializer calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / property pattern.
- [ ] **Step 2: Run tests to verify they fail**
- [ ] **Step 6: Run `WorldPicker` rect tests — confirm failure**
Run:
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
```
Expected: build failure ("sphereForEntity parameter not found") or runtime failure.
Expected: build failure (the new `Pick` overload doesn't exist yet).
- [ ] **Step 3: Add the new `Pick` overload to `WorldPicker.cs`**
- [ ] **Step 7: Add the new `Pick` overload to `WorldPicker.cs`**
Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing `Pick` overload — it stays for back-compat; its consumers are removed in Task B8):
Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing ray-sphere overload — its caller is migrated in B8, but the API stays for back-compat):
```csharp
/// <summary>
/// 2026-05-16. Retail-faithful picker overload. Caller supplies a
/// per-entity world-space sphere via <paramref name="sphereForEntity"/>
/// — typically <see cref="Setup.SelectionSphere"/> scaled by entity
/// scale and rotated into world space (mirroring retail
/// CPartArray::GetSelectionSphere at 0x00518b80). Resolver returning
/// null skips the candidate (matches retail "no Setup → not pickable").
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
/// projects to a screen-space rectangle through
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
/// rect is inflated by <paramref name="inflatePixels"/> on every side
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
/// hit-tested against the mouse pixel. Among rects that contain the
/// mouse, the entity with the nearest camera-space depth wins.
///
/// <para>
/// Replaces the older <see cref="Pick(Vector3,Vector3,IEnumerable{WorldEntity},uint,float,Func{uint,float}?,Func{uint,float}?)"/>
/// overload with the per-type-radius / vertical-offset heuristics.
/// Those heuristics existed because we didn't have the dat-supplied
/// SelectionSphere plumbed through. With this overload, the click
/// geometry matches what the target indicator draws — what you see
/// is what you click.
/// Why screen-space instead of world-space ray-sphere: the indicator
/// draws a screen-space RECT. A world-space sphere projects to a
/// screen CIRCLE inscribed in that rect — leaving the four rect
/// corners as click dead zones. Per user feedback 2026-05-16, the
/// click area must match the visible indicator extent exactly. By
/// sharing the <see cref="ScreenProjection"/> helper with
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
/// cannot drift.
/// </para>
///
/// <para>
/// Stage A of the picker port. Retail also does a polygon-accurate
/// refine via <c>CPolygon::polygon_hits_ray</c> when the sphere
/// hits (decomp 0x0054c889) — that's Stage B, deferred until visual
/// testing surfaces a sphere-only miss (issue #71).
/// Resolver returning <c>null</c> skips the candidate (matches retail
/// "no Setup → not pickable" behavior). Entities with
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
/// guid are also skipped.
/// </para>
///
/// <para>
/// Stage A of the picker port. Stage B (polygon refine via
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
/// per issue #71 — only needed if visual testing surfaces a Stage A
/// over-pick on entities whose visible mesh is well inside the
/// indicator rect.
/// </para>
/// </summary>
/// <param name="inflatePixels">Pixel inflate on each side of the
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
/// so the click area extends to where the visible bracket corners
/// sit — the user perceives the inflated rect as the clickable area.</param>
public static uint? Pick(
System.Numerics.Vector3 origin, System.Numerics.Vector3 direction,
float mouseX, float mouseY,
System.Numerics.Matrix4x4 view,
System.Numerics.Matrix4x4 projection,
System.Numerics.Vector2 viewport,
IEnumerable<AcDream.Core.World.WorldEntity> candidates,
uint skipServerGuid,
Func<AcDream.Core.World.WorldEntity, (System.Numerics.Vector3 CenterWorld, float Radius)?> sphereForEntity,
float maxDistance = 50f)
float inflatePixels = 8f)
{
if (direction.LengthSquared() < 1e-10f) return null;
uint? bestGuid = null;
float bestDepth = float.PositiveInfinity;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
var sphere = sphereForEntity(entity);
if (sphere is null) continue;
var (center, radius) = sphere.Value;
if (radius <= 0f) continue;
// Geometric ray-sphere (same math as the older overload).
var oc = origin - center;
float b = System.Numerics.Vector3.Dot(oc, direction);
float c = System.Numerics.Vector3.Dot(oc, oc) - radius * radius;
float d = b * b - c;
if (d < 0f) continue;
float sqrtD = MathF.Sqrt(d);
float t = -b - sqrtD;
if (t < 0f) t = -b + sqrtD; // ray origin inside sphere use far exit
if (t < 0f) continue; // both roots behind ray
if (t >= maxDistance) continue;
if (t < bestT)
if (!ScreenProjection.TryProjectSphereToScreenRect(
center, radius, view, projection, viewport,
out var rMin, out var rMax, out var depth))
continue;
// Inflate by inflatePixels on each side — extend hit area to
// where the indicator brackets sit.
float minX = rMin.X - inflatePixels;
float minY = rMin.Y - inflatePixels;
float maxX = rMax.X + inflatePixels;
float maxY = rMax.Y + inflatePixels;
if (mouseX < minX || mouseX > maxX) continue;
if (mouseY < minY || mouseY > maxY) continue;
if (depth < bestDepth)
{
bestT = t;
bestGuid = entity.ServerGuid;
bestDepth = depth;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
```
- [ ] **Step 4: Run tests to verify they pass**
- [ ] **Step 8: Refactor `TargetIndicatorPanel` to use the shared helper**
Run:
In `src/AcDream.App/UI/TargetIndicatorPanel.cs`:
1. Delete the private `TryComputeScreenRectFromSphere` method (lines 321-356 inclusive — keep `TryProjectToScreen` since the fallback branch still uses it).
2. Change the call site at line 217 from `TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport, out var rMin, out var rMax)` to:
```csharp
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
sphereCenter, sphereRadius, view, projection, viewport,
out var rMin, out var rMax, out _,
minSidePixels: 12f))
```
dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
(The `out _` discards the depth — the indicator doesn't need it. `minSidePixels: 12f` preserves the existing clamp.)
- [ ] **Step 9: Build all touched projects**
```
Expected: 3/3 pass.
dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
```
Expected: 0 errors on both.
- [ ] **Step 10: Run all picker + projection tests**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests|FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
```
Expected: 9/9 pass (3 ScreenProjection + 6 WorldPicker rect-overload).
---
### Task B8: Switch `GameWindow` picker call to the sphere-resolver overload
### Task B8: Switch `GameWindow` picker call to the screen-rect overload
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — find the `WorldPicker.Pick` call.
- Modify: `src/AcDream.App/Rendering/GameWindow.cs``PickAndStoreSelection` method around line 9006.
- [ ] **Step 1: Locate the existing call**
@ -1194,34 +1500,93 @@ Run:
```
grep -n "WorldPicker.Pick(" src/AcDream.App/Rendering/GameWindow.cs
```
Expected: one match at ~line 9018.
- [ ] **Step 2: Replace the call with the sphere-resolver overload**
- [ ] **Step 2: Replace the ray-build + ray-pick chain with the screen-rect picker**
Replace the existing `WorldPicker.Pick(...)` invocation (which uses `radiusForGuid` + `verticalOffsetForGuid` callbacks) with:
The existing block reads (paraphrased; lines 9011-9072):
```csharp
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,
sphereForEntity: e =>
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
? ((System.Numerics.Vector3, float)?)(c, r)
: ((System.Numerics.Vector3, float)?)null,
maxDistance: 50f);
maxDistance: 50f,
radiusForGuid: g => { /* per-type heuristics ... */ },
verticalOffsetForGuid: g => { /* per-type heuristics ... */ });
```
Replace the entire block with:
```csharp
// 2026-05-16 — retail-faithful screen-rect picker. The hit area
// is the same screen-space rect the target indicator draws
// (computed via the shared AcDream.Core.Selection.ScreenProjection
// helper). Per user feedback 2026-05-16: clicking the indicator
// brackets — including the rect corners — must select the entity.
// The per-type radius/offset heuristics retired here (1.0/1.6/2.0
// m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
// existed to make a 3D ray-sphere picker approximate the visible
// rect; the new picker doesn't need them.
var camera = _cameraController.Active;
var viewport = new System.Numerics.Vector2(_window.Size.X, _window.Size.Y);
var picked = AcDream.Core.Selection.WorldPicker.Pick(
mouseX: _lastMouseX, mouseY: _lastMouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
sphereForEntity: e =>
{
// Authoritative: Setup's SelectionSphere (matches the
// indicator's input).
if (TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r))
return ((System.Numerics.Vector3, float)?)(c, r);
// Fallback for entities whose Setup didn't bake a
// SelectionSphere (rare). Synthesize a 1.5 m × scale
// sphere centered on body-mid — same intent as B9's
// simplified EntityHeightFor fallback, so the picker
// and indicator agree even on the fallback path.
float scale = 1f;
if (_lastSpawnByGuid.TryGetValue(e.ServerGuid, out var s) && s.ObjScale is float es && es > 0f)
scale = es;
float half = 0.75f * scale;
var center = e.Position + new System.Numerics.Vector3(0, 0, half);
return ((System.Numerics.Vector3, float)?)(center, half);
},
// Match the indicator's TriangleSize (8 px) so the click area
// extends out to the bracket corners — what the user perceives
// as "selectable extent."
inflatePixels: 8f);
```
The local `origin`/`direction` variables and the `if (direction.LengthSquared() < 1e-6f) return;` guard are no longer needed — delete them along with the `BuildRay` call.
- [ ] **Step 3: Delete the per-type `radiusForGuid` and `verticalOffsetForGuid` lambda blocks**
Both lambdas (around line ~9037 and ~9054) become dead with this change. Delete them.
Both lambdas (the entire `radiusForGuid: g => { ... }` and `verticalOffsetForGuid: g => { ... }` blocks at ~9037 and ~9054) are gone with the rewrite in Step 2. Confirm there are no dangling references via:
```
grep -n "radiusForGuid\|verticalOffsetForGuid" src/AcDream.App/Rendering/GameWindow.cs
```
Expected: 0 matches in `GameWindow.cs` after the edit.
- [ ] **Step 4: Build**
Run:
```
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
```
Expected: 0 errors. The old `WorldPicker.Pick` overload with `radiusForGuid` callback still exists in `WorldPicker.cs` but has no callers — leave it for now; not blocking.
Expected: 0 errors. The old `WorldPicker.Pick(origin, direction, ...)` ray-sphere overload still exists in `WorldPicker.cs` but has no callers — leave it for now; cleaning it up is a follow-up.
---
@ -1332,6 +1697,8 @@ User runs the client and confirms:
4. **Pickup item from across the room.** Walks, picks up. ONE `[B.5] pickup` line.
5. **Click the Holtburg sign.** Indicator triangles match the sign size (unchanged from previous ship). Press R. Silent no-op (`SendUse ignored — not useable`).
6. **Click rapidly between NPC and item.** No spurious "I clicked X earlier and now it's firing on Y" cross-contamination. The `_pendingPostArrivalAction` simplification should make this clean.
7. **Click in the indicator's bracket corners.** Hover the mouse so it sits in the rect-corner region of the indicator brackets (where the four triangle marks sit) — NOT on the entity body. Click. The entity selects. Old behaviour: corners were sphere dead zones and the click missed; new behaviour: click area = visible bracket bounding rect, corner clicks land.
8. **Click adjacent to an entity but outside the indicator rect.** Mouse just outside the bracket extent. No selection. Old behaviour: sphere over-pick let cursor land far from the visible rect and still select; new behaviour: rect edges are tight.
**If any of (1)-(6) regresses, the cadence fix in Task B2 likely needs tuning. Common cause: `IsGrounded` is too restrictive (suppressing AP on slopes); relax to `ContactPlane.Normal.Z > 0.3f` or similar.**
@ -1344,11 +1711,13 @@ When user approves:
```bash
git add src/AcDream.App/Input/PlayerMovementController.cs \
src/AcDream.App/Rendering/GameWindow.cs \
src/AcDream.Core/Selection/ScreenProjection.cs \
src/AcDream.Core/Selection/WorldPicker.cs \
src/AcDream.App/UI/TargetIndicatorPanel.cs \
tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs \
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
fix(retail): per-tick AP cadence + sphere picker retires 4 workarounds
fix(retail): per-tick AP cadence + screen-rect picker retires 4 workarounds
Single coherent commit. Audit findings from 2026-05-16:
@ -1390,20 +1759,29 @@ Single coherent commit. Audit findings from 2026-05-16:
Renamed to OnAutoWalkArrivedSendDeferredAction to clarify
it's a FIRST send, not a retry.
3. WorldPicker switched to a Setup.SelectionSphere overload.
Retail's picker uses CGfxObj.drawing_sphere + polygon refine
(acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay),
which we approximate via Setup.SelectionSphere (same data
path as the target indicator since f4f4143). Effect: click
geometry matches the visible indicator — what you see is what
you click. Retires the per-type radius (1.0/1.5/2.0 m) and
vertical-offset (0.9/1.0/1.5 m) heuristic callbacks.
3. WorldPicker switched to a screen-rect hit-test against the same
rect the target indicator draws. Retail's picker uses
CGfxObj.drawing_sphere + polygon refine
(acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay
feeding SmartBox::GetObjectBoundingBox at 0x00452e20). Stage A
approximates the sphere-reject step by projecting Setup.SelectionSphere
to screen and hit-testing the mouse pixel against that rect
inflated by 8 px (the indicator's TriangleSize). The new
AcDream.Core.Selection.ScreenProjection helper is shared between
the picker and the indicator so their rects cannot drift.
Effect: click area matches the visible indicator extent
bit-for-bit — including the rect corners, which were dead
zones under the old ray-sphere picker. Retires the per-type
radius (1.0/1.6/2.0 m) and vertical-offset (0.2/0.9/1.0/1.5 m)
heuristic callbacks. Old ray-sphere overload remains in
WorldPicker for back-compat; not load-bearing.
4. EntityHeightFor fallback trimmed to a single 1.5 m default.
IsTallSceneryGuid deleted entirely — both became dead code
when the picker switched to SelectionSphere.
when the picker switched to screen-rect against SelectionSphere.
Test suite: 290+ Core.Net unchanged, +3 WorldPickerSphereOverloadTests.
Test suite: 290+ Core.Net unchanged, +3 ScreenProjectionTests, +6
WorldPickerRectOverloadTests.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
@ -1532,7 +1910,7 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
- Fix #5 (useability fallback probe): Task A4 (flag), A5 step 2 (log lines). ✅
- Fix #2 (per-tick diff-driven AP): Tasks B1 + B2 + B3. ✅
- Fix #6 (delete 4 workarounds): Task B4 (TinyMargin), B5 (handler + SendAutonomousPositionNow), B6 (isRetryAfterArrival). ✅
- Fix #3 Stage A (sphere picker): Tasks B7 + B8. ✅
- Fix #3 Stage A (screen-rect picker against indicator rect): Tasks B7 + B8. ✅
- Fix #7 (trim EntityHeightFor): Task B9. ✅
- Fix #8 (delete IsTallSceneryGuid): Task B10. ✅
- Deferred issues (triangle/Stage B/cdb): Tasks DF1, DF2, DF3. ✅
@ -1544,7 +1922,8 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
- `NotePositionSent(Vector3, uint, float)` defined B1, called B3. ✅
- `SimTimeSeconds` accessor defined B1, read B3. ✅
- `OnAutoWalkArrivedSendDeferredAction()` defined Task B6 Step 3, subscribed in Task B6 Step 4. ✅
- `WorldPicker.Pick(...sphereForEntity...)` defined Task B7, called Task B8. ✅
- `ScreenProjection.TryProjectSphereToScreenRect(...)` defined Task B7 Step 3, called Task B7 Step 7 (WorldPicker.Pick) and Step 8 (TargetIndicatorPanel). ✅
- `WorldPicker.Pick(mouseX, mouseY, view, projection, viewport, candidates, skipServerGuid, sphereForEntity, inflatePixels)` defined Task B7 Step 7, called Task B8 Step 2. ✅
- `TryGetEntitySelectionSphere` referenced in Task B8 — already exists in `GameWindow.cs` at line ~9605 per audit. ✅
- `ApproxPositionEqual` defined Task B2 Step 3, called Task B2 Step 2. ✅