feat(selection): port retail polygon picking and vivid marker

Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks.

Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp.

Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 21:32:51 +02:00
parent 0f82a08f0a
commit 146a963aeb
26 changed files with 1302 additions and 1340 deletions

View file

@ -4077,11 +4077,23 @@ or +small fix if different. Not blocking M1.
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
**Status:** OPEN
**Status:** DONE 2026-07-17
**Severity:** MEDIUM (Stage A now causes real play mis-picks through open doors/windows)
**Filed:** 2026-05-16
**Component:** selection / picker
**Resolution:** Replaced the projected `Setup.SelectionSphere` rectangle and
independent collision-polygon wall ray with the retail render-coupled path.
`WbDrawDispatcher` now publishes only server-object parts which survive the
normal visible draw and each part's drawing-sphere view-cone check.
`RetailWorldPicker` transforms the ray into each part,
uses `GfxObj.DrawingBSP.Root.BoundingSphere` as broadphase, scans visual
polygons in DAT order, keeps only the first polygon hit per part, and gives
every polygon hit global priority over sphere-only fallbacks. The obsolete
picker overloads and cell occluder were deleted. Conformance tests pin
single-sided rejection, first-polygon ordering, affine scale, fallback, and
global arbitration.
**Description:** Retail's mouse picker does two-tier sphere-then-polygon
selection (`acclient_2013_pseudo_c.txt:0x0054c740`
`Render::GfxObjUnderSelectionRay`):
@ -4124,11 +4136,21 @@ play, so this should be scheduled soon after A8 rather than left as polish.
## #70 — Triangle apex/size — final retail-feel UX pass
**Status:** OPEN
**Status:** DONE 2026-07-17
**Severity:** LOW (cosmetic — indicator already retail-anchored, this is final-feel polish)
**Filed:** 2026-05-16
**Component:** ui / target indicator
**Resolution:** Deleted the procedural ImGui triangles and mounted a retained
gameplay-UI `VividTargetIndicatorController`. It resolves retail client-enum
category `0x10000009`, values `1..4`, to the four installed 12×12 RenderSurface
corners; colorizes their grayscale masks with `gmRadarUI::GetBlipColor`
equivalent colors; places them outside the exact Setup selection-sphere screen
rectangle; and ports retail's separate-edge 8-pixel viewport clamp. The marker
is available without devtools and follows only targets reported by the normal
world draw. Installed-DAT and layout conformance tests pin the assets and
placement.
**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
@ -4141,8 +4163,8 @@ shape and size come from the dat, not constants.
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.
**Files:** [VividTargetIndicatorController.cs](src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs)
exact retained-UI DAT surface resolution, colorization, and placement.
**Estimated scope:** Small (~1-2 hours, mostly dat exploration).
Not blocking M1.
@ -7798,6 +7820,13 @@ cell structs. Entities behind walls from the camera's perspective are no
longer selectable. Screen-rect occlusion tests verify the filter across
several hit/miss scenarios.
**Superseded 2026-07-17:** The full retail render-coupled polygon picker
(#71) removed this independent collision-BSP occluder. Retail inherits
occlusion from the normal portal/viewcone draw traversal; maintaining a second
ray against physics polygons could both hide drawn targets and admit undrawn
ones. The original commits remain useful history, but this is no longer the
runtime mechanism.
---
## #77 — [DONE 2026-05-18 · 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back

View file

@ -137,7 +137,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
@ -230,7 +229,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
| TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.71.0 m) + screen rect matched to the indicator; retail's per-polygon refine deferred (**#71**); rect-over-circle is a user-approved UX divergence | `src/AcDream.Core/Selection/WorldPicker.cs:199` | Stage B only needed if visual testing surfaces Stage-A over-picks; sphere/rect + cell-BSP occlusion adequate so far | Clicks near (not on) an entity still select it; fixed radii can mis-prioritize overlapping candidates vs retail's polygon-accurate test | `CPolygon::polygon_hits_ray` 0x0054c889 |
| TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 |
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |

View file

@ -400,6 +400,11 @@ WorldBuilder is a dat editor; it does not have:
- **Audio** — OpenAL pipeline, sound triggers (we have this)
- **TurbineChat** + **slash commands** (we have this)
- **Login + character selection flow** (we have this)
- **World-object mouse selection** — WorldBuilder supplies mesh/DAT access but
no retail client picker. Our narrow `RetailSelectionGeometryCache` reuses
`DatCollection` to expose each GfxObj drawing-BSP root sphere and visual
polygons; `WbDrawDispatcher` supplies the normal draw's current part
transforms to the named-retail selection accumulator.
---

View file

@ -524,6 +524,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15``UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B``UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory`**STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state.
- **✓ SHIPPED — retail world selection + vivid target indicator (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11.
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11.
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.

View file

@ -0,0 +1,145 @@
# Retail world selection and vivid target indicator
Oracle: September 2013 EoR named retail client. Cross-checks: the extracted
WorldBuilder mesh path (DAT geometry and current part transforms) and ACE object
identity/qualities. Neither reference implementation supplies the client picker;
the named retail functions below are authoritative.
## Mouse selection
`SmartBox::find_object @ 0x00451C60`
```text
remember the clicked viewport pixel
clear the previous per-frame selection accumulator
arm mouse selection for the next world draw
```
`CPhysicsPart::Draw @ 0x0050D7A0`, `RenderDeviceD3D::DrawMesh @ 0x005A0860`,
and `Render::GfxObjUnderSelectionRay @ 0x0054C740`
```text
for each physics part which reaches the normal visible-mesh draw path:
reject parts which do not belong to a server object
transform the one world selection ray into this part's local coordinates
divide by the part's GfxObj scale (do not change the ray parameter units)
sphere = gfxObj.drawing_sphere
if sphere does not intersect the local ray:
continue
if a polygon has already won globally and sphereDistance is farther:
continue
if sphereDistance is nearer than the current sphere fallback:
remember this object's id, part index, and sphereDistance
for polygons in the GfxObj's stored flat DAT order:
if polygon_hits_ray(polygon, localRay, out t):
remember only this FIRST polygon hit for this part
if t is nearer than the current global polygon winner:
remember this object's id, part index, and t
break
```
`CPolygon::polygon_hits_ray @ 0x005395E0`
```text
if polygon.sides_type == ST_SINGLE (raw value 0)
and dot(ray.direction, polygon.plane.normal) > 0:
miss // back face
denominator = dot(ray.direction, polygon.plane.normal)
if abs(denominator) < 0.0002:
miss // parallel to the plane
t = plane/ray intersection time
if t < 0:
miss // behind the viewer
point = ray.origin + t * ray.direction
normal = normalize(sum(cross(vertex[i] - vertex[0],
vertex[i+1] - vertex[0])))
for each ordered edge (previous -> current):
inward = cross(normal, current - previous)
if dot(point - previous, inward) < 0:
miss
return hit
```
The broad-phase helper is retail `CSphere::sphere_intersects_ray @
0x005377A0`: it rejects rays beginning in/on the sphere, requires squared ray
direction length at least `0.0002`, solves the quadratic in double precision,
and chooses the near root when non-negative or the far root otherwise.
The ray origin is `Render::viewpoint` (the camera eye), not the unprojected
near-plane point; `Render::pick_ray @ 0x0054B610` supplies only its direction.
`Render::GetMouseSelectionObjectID @ 0x0054C950`
```text
if any polygon hit exists anywhere in the visible draw:
return the closest polygon winner
if any drawing-sphere hit exists:
return the closest sphere fallback
return no object
```
Important consequences:
- The broad phase is each visible **GfxObj part's drawing sphere**, not a Setup
selection sphere, physics collision shape, fixed creature hit box, or expanded
screen rectangle.
- A polygon hit on any visible part beats every sphere-only fallback.
- Retail does not ray-test world-cell walls as a second, independent occluder.
Occlusion is inherited from the same portal/viewcone draw traversal that decides
which parts may call `GfxObjUnderSelectionRay`.
- `BSPTREE::GetSphere @ 0x005397E0` returns the drawing BSP root sphere exactly;
the DAT source is `GfxObj.DrawingBSP.Root.BoundingSphere`.
## Vivid target indicator
`VividTargetIndicator::SetSelected @ 0x004F5CE0`
```text
if selected id is the player: clear it
if object is player-owned or IN_CONTAINER: clear it
publish the remaining id to SmartBox
if enabled and an id remains:
color = gmRadarUI::GetBlipColor(selected id)
else:
hide both indicator roots
```
`VividTargetIndicator::Initialized @ 0x004F6C60`
```text
onscreenRoot = child 0x10000038
corners = children 0x10000039, 0x1000003A, 0x1000003B, 0x1000003C
offscreenRoot = child 0x10000045
```
The constructor resolves twelve source surfaces by calling `DBObj::GetByEnum`
with RenderSurface DBO type `0x0C`, client-enum category `0x10000009`, and
enum values `1..12`. Values `1..4` are the four on-screen corners.
`CopyImage @ 0x004F5DD0`
uses the active retail blit mode (`s_BlitMethod == 4`) to colorize the source
art with the same color as the object's radar blip.
`VividTargetIndicator::OnDraw @ 0x004F62B0` (on-screen branch)
```text
if disabled, no selected id, or selected part was not reported by this world draw:
hide indicator
else:
colorize the four DAT corner images with the selected radar-blip color
obtain the selected object's SmartBox screen rectangle
use the actual DAT corner width and height
place the corner images just outside the rectangle
clamp the assembled indicator to an 8-pixel viewport margin
show the on-screen root and hide the off-screen root
```
There is no selected-world-mesh tint or luminosity mutation in
`CPhysicsPart::Draw`, `ACCWeenieObject::SetSelected`, or the world mesh draw
path. The visible notification is the colorized four-corner DAT indicator. A
mesh shader tint would therefore be a new effect, not a retail port.