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.

View file

@ -42,6 +42,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
@ -828,11 +829,6 @@ public sealed class GameWindow : IDisposable
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
// B.7 (2026-05-15): Vivid Target Indicator — four corner triangles
// around the selected entity, colour-coded by ItemType + PWD bits.
// Lives alongside the debug panels; cheap to construct + ignore
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
@ -1637,58 +1633,6 @@ public sealed class GameWindow : IDisposable
_imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
_panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// B.7 Vivid Target Indicator — corner-triangle highlight
// around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame:
// - selected guid → shared SelectionState
// - entity resolver → position from the visible world view +
// itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0.
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid =>
{
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity))
return null;
uint rawItemType = (uint)LiveItemType(guid);
uint pwdBits = 0;
uint? useability = null;
if (LastSpawns.TryGetValue(guid, out var spawn))
{
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
useability = spawn.Useability;
}
// 2026-05-16 — retail-faithful path. Pass the
// entity's Setup.SelectionSphere (scaled by entity
// scale, rotated into world coords) through so
// the panel projects the sphere as a screen
// circle. Matches SmartBox::GetObjectBoundingBox
// (decomp 0x00452e20). If the Setup didn't bake
// a selection sphere (rare, zero-radius), the
// panel falls back to per-type height heuristic.
System.Numerics.Vector3? sphereCenter = null;
float? sphereRadius = null;
if (TryGetEntitySelectionSphere(guid, out var sCenter, out var sRadius))
{
sphereCenter = sCenter;
sphereRadius = sRadius;
}
return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
entity.Position, rawItemType, pwdBits, entity.Scale, useability,
sphereCenter, sphereRadius);
},
cameraProvider: () =>
{
if (_cameraController is null || _window is null)
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
var cam = _cameraController.Active;
return (cam.View, cam.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
});
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
@ -2345,6 +2289,13 @@ public sealed class GameWindow : IDisposable
() => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps
?? _persistedDisplay.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection,
() => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
guid => _retailSelectionScene?.WasVisible(guid) == true,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook,
Objects,
@ -2674,7 +2625,10 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades);
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)));
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -9676,12 +9630,14 @@ public sealed class GameWindow : IDisposable
int visibleLandblocks = 0;
int totalLandblocks = 0;
_retailSelectionScene?.BeginFrame();
if (_cameraController is not null && !portalViewportVisible)
{
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
_retailSelectionScene?.SetViewFrustum(frustum);
// Extract camera world position from the inverse of the view
// matrix — needed by the scene-lighting UBO (for fog distance)
@ -10592,6 +10548,7 @@ public sealed class GameWindow : IDisposable
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
_particleVisibility.CompleteFrame();
}
_retailSelectionScene?.CompleteFrame();
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. The world block above is skipped
@ -10705,11 +10662,6 @@ public sealed class GameWindow : IDisposable
}
_panelHost.RenderAll(ctx);
// B.7 Vivid Target Indicator: draws corner triangles to the
// ImGui background draw list so it appears behind any docked
// panels but still over the 3D scene. Cheap when no
// selection — internal early-return on null guid.
_targetIndicator?.Render();
using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
{
_imguiBootstrap.Render();
@ -13143,63 +13095,27 @@ public sealed class GameWindow : IDisposable
// ============================================================
/// <summary>
/// Shared world pick at the current cursor — the 2026-05-16
/// retail-faithful screen-rect picker (hit area = the target
/// indicator's rect via the shared ScreenProjection helper; the old
/// per-type radius/offset heuristics are retired). Used by the click
/// select path AND the target-mode cursor hover (retail
/// SmartBox::get_found_object_id analogue). <paramref name="includeSelf"/>:
/// item target-use may pick the LOCAL PLAYER (retail lets you kit-heal
/// yourself by clicking your own toon); plain selection never does.
/// Shared world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
/// retail's drawing-sphere broadphase followed by flat visual-polygon
/// intersection. <paramref name="includeSelf"/> allows item target-use to
/// pick the local player while plain selection excludes it.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
{
if (_cameraController is null || _window is null) return null;
var camera = _cameraController.Active;
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
// cached EnvCell physics so the picker can occlude entities behind
// walls. Snapshot is per-pick (one click / one hover frame),
// iteration is bounded by the streaming radius (~80 cells at radius 4).
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
foreach (var cellId in _physicsDataCache.CellStructIds)
{
var cp = _physicsDataCache.GetCellStruct(cellId);
if (cp is not null) loadedCellPhysics.Add(cp);
}
return AcDream.Core.Selection.WorldPicker.Pick(
mouseX: mouseX, mouseY: mouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _visibleEntitiesByServerGuid.Values,
skipServerGuid: includeSelf ? 0u : _playerServerGuid,
// Resolver: Setup's SelectionSphere is the ONLY input. If the
// entity's Setup didn't bake a SelectionSphere, return null —
// the picker skips it, which matches retail behaviour
// (Render::GfxObjUnderSelectionRay at 0x0054c740 skips
// candidates with no drawing_sphere data). Earlier defensive
// 1.5 m × scale synth was removed 2026-05-16 — it made
// dat-incomplete entities click as phantom hitboxes the size
// of an NPC, diverging from retail and masking real Setup-
// loading bugs.
sphereForEntity: e =>
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
? ((System.Numerics.Vector3, float)?)(c, r)
: null,
// 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,
cellOccluder: loadedCellPhysics.Count > 0
? (origin, direction) =>
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
: null);
if (_retailSelectionScene is null || _window is null)
return null;
var camera = GetSelectionCamera();
return _retailSelectionScene.Pick(
mouseX,
mouseY,
camera.Viewport,
camera.View,
camera.Projection,
includeSelf ? 0u : _playerServerGuid);
}
private void PickAndStoreSelection(bool useImmediately)
@ -13729,6 +13645,35 @@ public sealed class GameWindow : IDisposable
Objects.Get(_playerServerGuid),
Objects.Get(guid));
private (System.Numerics.Matrix4x4 View,
System.Numerics.Matrix4x4 Projection,
System.Numerics.Vector2 Viewport) GetSelectionCamera()
{
if (_cameraController is null || _window is null)
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
var camera = _teleportViewPlane.ApplyTo(_cameraController.Active);
return (camera.View, camera.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
}
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
{
if (!_visibleEntitiesByServerGuid.ContainsKey(guid)
|| !TryGetEntitySelectionSphere(guid, out var center, out float radius))
return null;
uint pwdBits = LastSpawns.TryGetValue(guid, out var spawn)
? spawn.ObjectDescriptionFlags ?? 0u
: 0u;
return new AcDream.App.UI.Layout.VividTargetInfo(
center,
radius,
(uint)LiveItemType(guid),
pwdBits);
}
/// <summary>
/// 2026-05-16 — retail-faithful port of

View file

@ -0,0 +1,17 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Narrow seam from the normal world draw traversal to retail mouse selection.
/// Only entities which survive that traversal are published.
/// </summary>
internal interface IRetailSelectionRenderSink
{
void AddVisiblePart(
WorldEntity entity,
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld);
}

View file

@ -0,0 +1,76 @@
using System.Numerics;
using AcDream.Core.Selection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
/// </summary>
internal sealed class RetailSelectionGeometryCache
{
private readonly DatCollection _dats;
private readonly object _datLock;
// Render-thread owned. Dictionary permits a cached null for a GfxObj which
// legitimately has no drawing BSP; ConcurrentDictionary does not.
private readonly Dictionary<uint, RetailSelectionMesh?> _cache = new();
public RetailSelectionGeometryCache(DatCollection dats, object datLock)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
}
public RetailSelectionMesh? Resolve(uint gfxObjId)
{
if (_cache.TryGetValue(gfxObjId, out var cached))
return cached;
RetailSelectionMesh? loaded = Load(gfxObjId);
_cache[gfxObjId] = loaded;
return loaded;
}
private RetailSelectionMesh? Load(uint gfxObjId)
{
GfxObj? gfx;
lock (_datLock)
gfx = _dats.Get<GfxObj>(gfxObjId);
var root = gfx?.DrawingBSP?.Root;
if (gfx is null || root is null || root.BoundingSphere.Radius <= 0f)
return null;
var polygons = new List<RetailSelectionPolygon>(gfx.Polygons.Count);
foreach (var entry in gfx.Polygons)
{
var source = entry.Value;
if (source.VertexIds.Count < 3)
continue;
var vertices = new Vector3[source.VertexIds.Count];
bool valid = true;
for (int i = 0; i < source.VertexIds.Count; i++)
{
if (!gfx.VertexArray.Vertices.TryGetValue((ushort)source.VertexIds[i], out var vertex))
{
valid = false;
break;
}
vertices[i] = vertex.Origin;
}
if (!valid)
continue;
polygons.Add(new RetailSelectionPolygon(
vertices,
SingleSided: (int)source.SidesType == 0));
}
return new RetailSelectionMesh(
root.BoundingSphere.Origin,
root.BoundingSphere.Radius,
polygons);
}
}

View file

@ -0,0 +1,113 @@
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Render-thread owner of the last complete set of visible selectable parts.
/// The renderer builds one frame while input queries the previously completed
/// frame, avoiding partial visibility state during multi-slice portal drawing.
/// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
{
private readonly RetailSelectionGeometryCache _geometry;
private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new();
private readonly HashSet<PartKey> _buildingKeys = new();
private HashSet<uint> _buildingGuids = new();
private HashSet<uint> _publishedGuids = new();
private FrustumPlanes? _viewFrustum;
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
public RetailSelectionScene(RetailSelectionGeometryCache geometry)
=> _geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
public void BeginFrame()
{
_building.Clear();
_buildingKeys.Clear();
_buildingGuids.Clear();
_viewFrustum = null;
}
/// <summary>
/// Supplies retail DrawMesh's current view-cone gate. Animated entities
/// deliberately bypass acdream's coarse entity-AABB CPU cull, so this
/// per-part drawing sphere check is the load-bearing retail equivalent.
/// </summary>
public void SetViewFrustum(FrustumPlanes viewFrustum)
=> _viewFrustum = viewFrustum;
public void AddVisiblePart(
WorldEntity entity,
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld)
{
if (entity.ServerGuid == 0u)
return;
if (!_buildingKeys.Add(new PartKey(entity.Id, partIndex, gfxObjId)))
return;
RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId);
if (mesh is null)
return;
if (_viewFrustum is not { } frustum
|| !DrawingSphereIntersectsFrustum(mesh, partWorld, frustum))
return;
_building.Add(new RetailSelectionPart(
entity.ServerGuid,
partIndex,
partWorld,
mesh));
_buildingGuids.Add(entity.ServerGuid);
}
public void CompleteFrame()
{
(_published, _building) = (_building, _published);
(_publishedGuids, _buildingGuids) = (_buildingGuids, _publishedGuids);
}
public uint? Pick(
float mouseX,
float mouseY,
Vector2 viewport,
Matrix4x4 view,
Matrix4x4 projection,
uint skipServerGuid)
{
if (viewport.X <= 0f || viewport.Y <= 0f)
return null;
var ray = WorldPicker.BuildRay(
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
return RetailWorldPicker.Pick(
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
}
public bool WasVisible(uint serverGuid) => _publishedGuids.Contains(serverGuid);
internal static bool DrawingSphereIntersectsFrustum(
RetailSelectionMesh mesh,
Matrix4x4 localToWorld,
FrustumPlanes frustum)
{
Vector3 center = Vector3.Transform(mesh.SphereCenter, localToWorld);
float scaleX = new Vector3(localToWorld.M11, localToWorld.M12, localToWorld.M13).Length();
float scaleY = new Vector3(localToWorld.M21, localToWorld.M22, localToWorld.M23).Length();
float scaleZ = new Vector3(localToWorld.M31, localToWorld.M32, localToWorld.M33).Length();
float radius = mesh.SphereRadius * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ));
return TestPlane(frustum.Left, center, radius)
&& TestPlane(frustum.Right, center, radius)
&& TestPlane(frustum.Bottom, center, radius)
&& TestPlane(frustum.Top, center, radius)
&& TestPlane(frustum.Near, center, radius)
&& TestPlane(frustum.Far, center, radius);
}
private static bool TestPlane(Vector4 plane, Vector3 center, float radius)
=> plane.X * center.X + plane.Y * center.Y + plane.Z * center.Z + plane.W >= -radius;
}

View file

@ -7,6 +7,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using AcDream.App.Rendering.Selection;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
@ -85,6 +86,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly TextureCache _textures;
private readonly WbMeshAdapter _meshAdapter;
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly BindlessSupport _bindless;
@ -354,7 +356,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
EntitySpawnAdapter entitySpawnAdapter,
BindlessSupport bindless,
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
@ -371,6 +374,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_entitySpawnAdapter = entitySpawnAdapter;
_cache = classificationCache;
_translucencyFades = translucencyFades;
_selectionSink = selectionSink;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@ -1187,6 +1191,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
ApplyCacheHit(cachedEntry!, entityWorld, AppendInstanceToGroup);
// The cache is populated only after every MeshRef rendered
// successfully. Publish the same parts for retail picking now;
// CPhysicsPart::Draw only participates after the visible draw
// path has accepted a real part.
if (_selectionSink is not null)
PublishCachedSelectionParts(entity, entityWorld);
// anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated
// anyVao yet. Look up THIS entity's first MeshRef once via
@ -1374,6 +1385,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
_selectionSink?.AddVisiblePart(
entity,
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
model);
drewAny = true;
}
}
@ -1394,6 +1410,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
_selectionSink?.AddVisiblePart(
entity,
partIdx,
(uint)gfxObjId,
model);
drewAny = true;
}
}
@ -1813,6 +1834,44 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
set: set);
}
private void PublishCachedSelectionParts(WorldEntity entity, Matrix4x4 entityWorld)
{
for (int outerPartIndex = 0; outerPartIndex < entity.MeshRefs.Count; outerPartIndex++)
{
var meshRef = entity.MeshRefs[outerPartIndex];
var renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
continue;
if (!renderData.IsSetup || renderData.SetupParts.Count == 0)
{
_selectionSink!.AddVisiblePart(
entity,
outerPartIndex,
meshRef.GfxObjId,
meshRef.PartTransform * entityWorld);
continue;
}
for (int setupPartIndex = 0;
setupPartIndex < renderData.SetupParts.Count;
setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
if (_meshAdapter.TryGetRenderData(partGfxObjId) is null)
continue;
_selectionSink!.AddVisiblePart(
entity,
unchecked((outerPartIndex << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
ComposePartWorldMatrix(
entityWorld,
meshRef.PartTransform,
partTransform));
}
}
}
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
IndexCount: g.IndexCount,
FirstIndex: g.FirstIndex,

View file

@ -0,0 +1,204 @@
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
namespace AcDream.App.UI.Layout;
public readonly record struct VividTargetInfo(
Vector3 SelectionSphereCenter,
float SelectionSphereRadius,
uint ItemType,
uint ObjectDescriptionFlags);
public sealed record VividTargetRuntimeBindings(
SelectionState Selection,
Func<uint> PlayerGuid,
Func<bool> Enabled,
Func<uint, VividTargetInfo?> ResolveTarget,
Func<uint, bool> WasDrawn,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera);
/// <summary>
/// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>.
/// The four corner surfaces are client-enum category 0x10000009 values 1..4, colorized
/// with the selected object's retail radar-blip color and placed around the
/// Setup selection sphere's SmartBox screen rectangle.
/// </summary>
public sealed class VividTargetIndicatorController
{
private const uint ClientEnumCategory = 0x10000009u;
private const uint FirstSourceImageEnum = 1u;
private const float ViewportMargin = 8f;
private readonly UiPanel _root;
private readonly UiTextureElement[] _corners;
private readonly VividTargetRuntimeBindings _bindings;
private readonly (float Width, float Height)[] _sizes;
private VividTargetIndicatorController(
UiPanel root,
UiTextureElement[] corners,
(float Width, float Height)[] sizes,
VividTargetRuntimeBindings bindings)
{
_root = root;
_corners = corners;
_sizes = sizes;
_bindings = bindings;
}
public static VividTargetIndicatorController? Mount(
UiRoot host,
RetailUiAssets assets,
VividTargetRuntimeBindings bindings)
{
var root = new UiPanel
{
Name = "VividTargetIndicator",
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
ClickThrough = true,
Visible = false,
ZOrder = -10_000,
Anchors = AnchorEdges.None,
};
var corners = new UiTextureElement[4];
var sizes = new (float Width, float Height)[4];
for (uint i = 0; i < 4; i++)
{
uint did;
lock (assets.DatLock)
did = RetailDataIdResolver.Resolve(
assets.Dats,
enumValue: FirstSourceImageEnum + i,
enumCategory: ClientEnumCategory);
if (did == 0u)
return null;
var resolved = assets.ResolveSprite(did);
if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0)
return null;
sizes[i] = (resolved.Width, resolved.Height);
corners[i] = new UiTextureElement
{
Name = $"VividTargetCorner{i + 1}",
Texture = resolved.Texture,
Width = resolved.Width,
Height = resolved.Height,
ClickThrough = true,
Anchors = AnchorEdges.None,
};
root.AddChild(corners[i]);
}
host.AddChild(root);
return new VividTargetIndicatorController(root, corners, sizes, bindings);
}
public void Tick()
{
if (!_bindings.Enabled()
|| _bindings.Selection.SelectedObjectId is not uint guid
|| guid == 0u
|| guid == _bindings.PlayerGuid()
|| !_bindings.WasDrawn(guid)
|| _bindings.ResolveTarget(guid) is not VividTargetInfo target)
{
_root.Visible = false;
return;
}
var camera = _bindings.Camera();
if (!ScreenProjection.TryProjectSphereToScreenRect(
target.SelectionSphereCenter,
target.SelectionSphereRadius,
camera.View,
camera.Projection,
camera.Viewport,
out Vector2 rectMin,
out Vector2 rectMax,
out _,
minSidePixels: 0f))
{
_root.Visible = false;
return;
}
VividTargetLayout layout = ComputeLayout(
rectMin, rectMax, camera.Viewport, _sizes);
_root.Left = layout.RootPosition.X;
_root.Top = layout.RootPosition.Y;
_root.Width = layout.RootSize.X;
_root.Height = layout.RootSize.Y;
for (int i = 0; i < _corners.Length; i++)
SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y);
RadarBlipColors.Rgba color = RadarBlipColors.For(
target.ItemType, target.ObjectDescriptionFlags);
var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha);
for (int i = 0; i < _corners.Length; i++)
_corners[i].Tint = tint;
_root.Visible = true;
}
private void SetCorner(int index, float left, float top)
{
_corners[index].Left = left;
_corners[index].Top = top;
}
internal static VividTargetLayout ComputeLayout(
Vector2 rectMin,
Vector2 rectMax,
Vector2 viewport,
IReadOnlyList<(float Width, float Height)> sizes)
{
if (sizes.Count != 4)
throw new ArgumentException("Retail VividTargetIndicator has exactly four corners.", nameof(sizes));
// VividTargetIndicator::OnDraw @ 0x004F69C8..0x004F6A99 reads
// corner[1]'s dimensions, expands the SmartBox rectangle by one
// corner on the top/left, then clamps each rectangle edge separately.
// Partially off-screen targets therefore shrink the assembled region;
// retail does not translate the whole un-clipped box back on-screen.
float cornerWidth = sizes[0].Width;
float cornerHeight = sizes[0].Height;
float outerLeft = rectMin.X - cornerWidth;
float outerTop = rectMin.Y - cornerHeight;
float outerRight = rectMax.X;
float outerBottom = rectMax.Y;
if (outerLeft > outerRight) outerLeft = outerRight - 1f;
if (outerTop > outerBottom) outerTop = outerBottom - 1f;
outerLeft = MathF.Max(outerLeft, ViewportMargin);
outerTop = MathF.Max(outerTop, ViewportMargin);
outerRight = MathF.Max(outerRight, cornerWidth + ViewportMargin);
outerBottom = MathF.Max(outerBottom, cornerHeight + ViewportMargin);
float maximumRight = viewport.X - cornerWidth - ViewportMargin;
float maximumBottom = viewport.Y - cornerHeight - ViewportMargin;
outerLeft = MathF.Min(outerLeft, maximumRight - cornerWidth);
outerTop = MathF.Min(outerTop, maximumBottom - cornerHeight);
outerRight = MathF.Min(outerRight, maximumRight);
outerBottom = MathF.Min(outerBottom, maximumBottom);
Vector2 rootSize = new(
MathF.Max(1f, outerRight - outerLeft + cornerWidth),
MathF.Max(1f, outerBottom - outerTop + cornerHeight));
return new VividTargetLayout(
new Vector2(outerLeft, outerTop),
rootSize,
[
Vector2.Zero,
new Vector2(rootSize.X - sizes[1].Width, 0f),
new Vector2(rootSize.X - sizes[2].Width, rootSize.Y - sizes[2].Height),
new Vector2(0f, rootSize.Y - sizes[3].Height),
]);
}
}
internal readonly record struct VividTargetLayout(
Vector2 RootPosition,
Vector2 RootSize,
IReadOnlyList<Vector2> CornerPositions);

View file

@ -164,6 +164,7 @@ public sealed record RetailUiRuntimeBindings(
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
VividTargetRuntimeBindings VividTarget,
IndicatorRuntimeBindings Indicators,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
@ -192,6 +193,7 @@ public sealed class RetailUiRuntime : IDisposable
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private VividTargetIndicatorController? _vividTargetIndicator;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@ -202,6 +204,7 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
MountFpsDisplay();
MountVividTargetIndicator();
MountVitals();
MountRadar();
MountChat();
@ -295,6 +298,7 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
FpsController?.Tick();
_vividTargetIndicator?.Tick();
SpellbookWindowController?.Tick();
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
@ -460,6 +464,17 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.2b] retail FPS display from SmartBox LayoutDesc 0x2100000F.");
}
private void MountVividTargetIndicator()
{
_vividTargetIndicator = VividTargetIndicatorController.Mount(
Host.Root,
_bindings.Assets,
_bindings.VividTarget);
Console.WriteLine(_vividTargetIndicator is null
? "[D.2b] vivid target indicator DAT surfaces unavailable."
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
}
private void MountVitals()
{
ImportedLayout? layout = Import(0x2100006Cu);

View file

@ -1,265 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Ui;
using ImGuiNET;
namespace AcDream.App.UI;
/// <summary>
/// B.7 (2026-05-15) — Vivid Target Indicator. Draws four small
/// corner triangles around the currently-selected entity, colour-coded
/// by entity type (NPCs yellow, items white-ish, PKs red, etc.).
/// Retail-faithful equivalent of <c>VividTargetIndicator</c>
/// (named decomp at <c>0x004d6165</c> / <c>0x004f5ce0</c>).
///
/// <para>
/// MVP scope: on-screen indicator only, drawn via ImGui's background
/// draw list. Deferred to follow-ups: off-screen edge arrow, DAT-loaded
/// triangle sprite, mesh-tint highlight, player-option toggle.
/// </para>
///
/// <para>
/// The panel pulls its inputs through delegates supplied by the host
/// (<see cref="Rendering.GameWindow"/>) so it doesn't have to depend
/// on internal state types:
/// </para>
/// <list type="bullet">
/// <item><c>selectedGuidProvider</c> — Core <c>SelectionState</c>'s current guid.</item>
/// <item><c>entityResolver</c> — returns
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
/// the entity is no longer in the world (despawned).</item>
/// <item><c>cameraProvider</c> — host's active camera + viewport
/// dimensions; called once per frame.</item>
/// </list>
/// </summary>
public sealed class TargetIndicatorPanel
{
/// <summary>
/// What the panel needs to know about the selected entity per frame.
/// <c>ItemType</c> + <c>ObjectDescriptionFlags</c> feed
/// <see cref="RadarBlipColors.For"/> for colour selection.
/// <c>Scale</c> multiplies the per-type base height in
/// <see cref="EntityHeightFor"/> — a scaled-up sign or oversized NPC
/// gets a proportionally bigger box. <c>Useability</c> (acclient.h:6478
/// <c>ITEM_USEABLE</c> enum) discriminates real pickup items
/// (USEABLE_REMOTE bit set, 0.8 m boxes) from same-ItemType-but-non-
/// useable scenery like signs (USEABLE_UNDEF, 3 m boxes).
/// </summary>
public readonly record struct TargetInfo(
Vector3 WorldPosition,
uint ItemType,
uint ObjectDescriptionFlags,
float Scale,
uint? Useability = null,
// 2026-05-16: world-space SelectionSphere center + radius.
// Comes from the Setup's baked selection_sphere (acclient.h
// CSetup::selection_sphere) scaled by entity scale. When
// populated, the panel projects the sphere as a screen circle
// and uses that as the indicator rect — matches retail
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20). When
// null, the panel falls back to the per-type height heuristic.
Vector3? WorldSphereCenter = null,
float? WorldSphereRadius = null);
private readonly Func<uint?> _selectedGuidProvider;
private readonly Func<uint, TargetInfo?> _entityResolver;
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider;
/// <summary>
/// Pixel size of each corner triangle's right-angle legs.
/// Retail uses <c>UIRegion::GetWidth(m_rgOnScreenCorners.m_data[1])</c>
/// of the triangle sprite (decomp <c>0x004f69c8</c>). The retail
/// sprite is small — ~8 px legs. 14 was too chunky per user
/// feedback on 2026-05-16; 8 matches the retail screenshot.
/// </summary>
public float TriangleSize { get; set; } = 8f;
/// <summary>
/// World-space height of the indicator box for entities that don't
/// have a more specific type tag. Items use a smaller value (see
/// <see cref="EntityHeightFor"/>). 1.8 m matches a standing humanoid;
/// short items still get a small box because the projection
/// preserves apparent size.
/// </summary>
public float EntityHeight { get; set; } = 1.8f;
/// <summary>
/// Defensive fallback height when the entity has no usable
/// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection
/// path active (since commit f4f4143), this fallback only fires
/// for entities whose Setup didn't bake a selection sphere —
/// rare in practice. The single 1.5 m × scale default is a sane
/// midpoint; per-type branches were retired in the 2026-05-16
/// Commit B because the sphere path is authoritative.
/// </summary>
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null)
{
if (scale <= 0f) scale = 1f;
return 1.5f * scale;
}
/// <summary>
/// Box width = <see cref="EntityHeight"/> projected height ×
/// <see cref="WidthHeightRatio"/>. Retail's Vivid Target Indicator
/// draws a square box — four corner triangles arranged in a square —
/// so 1.0 = width matches height. The earlier 0.5 (humanoid-ish
/// aspect) made the box uncomfortably narrow for non-humanoids.
/// </summary>
public float WidthHeightRatio { get; set; } = 1.0f;
/// <summary>
/// Floor for the projected screen height (pixels). Prevents the
/// indicator from collapsing to a point on far-away entities.
/// </summary>
public float MinScreenHeight { get; set; } = 16f;
public TargetIndicatorPanel(
Func<uint?> selectedGuidProvider,
Func<uint, TargetInfo?> entityResolver,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider)
{
_selectedGuidProvider = selectedGuidProvider;
_entityResolver = entityResolver;
_cameraProvider = cameraProvider;
}
/// <summary>
/// Per-frame render call. No-op if nothing is selected, the selected
/// entity is gone, or the entity is off-screen / behind the camera.
/// Draws to the ImGui background draw list so it appears behind
/// other panels.
/// </summary>
public void Render()
{
if (_selectedGuidProvider() is not uint guid) return;
if (_entityResolver(guid) is not TargetInfo info) return;
var (view, projection, viewport) = _cameraProvider();
if (viewport.X <= 0 || viewport.Y <= 0) return;
var viewProj = view * projection;
Vector2 tl, tr, br, bl;
if (info.WorldSphereCenter is Vector3 sphereCenter
&& info.WorldSphereRadius is float sphereRadius
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
sphereCenter, sphereRadius, view, projection, viewport,
out var rMin, out var rMax, out _,
minSidePixels: 12f))
{
// 2026-05-16 — retail-faithful path per
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20).
// Retail uses CPhysicsObj::GetSelectionSphere (the Setup's
// baked selection_sphere) and produces the screen rect
// from that sphere's projection — NOT from a per-mesh AABB.
//
// Retail INFLATES the rect by one triangle width/height on
// every side before drawing (decomp 0x004f6a0b0x004f6a99):
// edi_3 = arg4->left - eax_21 (shift left by triangleW)
// ebp_3 = arg4->top - eax_23 (shift up by triangleH)
// width = sphere_width + 2 * triangleW
// height = sphere_height + 2 * triangleH
// So the four corner triangles sit OUTSIDE the projected
// sphere by one triangle leg.
float ts = TriangleSize;
tl = new Vector2(rMin.X - ts, rMin.Y - ts);
tr = new Vector2(rMax.X + ts, rMin.Y - ts);
br = new Vector2(rMax.X + ts, rMax.Y + ts);
bl = new Vector2(rMin.X - ts, rMax.Y + ts);
}
else
{
// Fallback when the AABB isn't available (no setup cached
// yet, missing GfxObj bounds, behind the camera). Square
// box centred at the entity origin, height from the
// per-type heuristic.
if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen))
return;
float entityHeight = EntityHeightFor(info.ItemType, info.ObjectDescriptionFlags, info.Scale, info.Useability);
var headWorld = new Vector3(
info.WorldPosition.X,
info.WorldPosition.Y,
info.WorldPosition.Z + entityHeight);
if (!TryProjectToScreen(headWorld, viewProj, viewport, out var headScreen))
return;
float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y);
if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight;
float screenWidth = screenHeight * WidthHeightRatio;
Vector2 center = (feetScreen + headScreen) * 0.5f;
float halfW = screenWidth * 0.5f;
float halfH = screenHeight * 0.5f;
tl = new Vector2(center.X - halfW, center.Y - halfH);
tr = new Vector2(center.X + halfW, center.Y - halfH);
br = new Vector2(center.X + halfW, center.Y + halfH);
bl = new Vector2(center.X - halfW, center.Y + halfH);
}
var rgba = RadarBlipColors.For(info.ItemType, info.ObjectDescriptionFlags);
uint col = MakeImGuiColor(rgba);
var drawList = ImGui.GetBackgroundDrawList();
float t = TriangleSize;
// 2026-05-16 — flipped per user feedback. Each corner triangle's
// RIGHT-ANGLE apex now points INWARD toward the target (was at
// the outer corner pointing outward). Combined with the
// TriangleSize inflate on the rect, the apex of each triangle
// lands at the projected mesh boundary while the hypotenuse
// runs across the outer (inflated) corner — giving the retail
// "corner-tick pointing at the entity" look.
//
// Geometry per corner:
// apex = corner + (±t, ±t) ← inward, right-angle here
// leg_a end = corner + (±t, 0) ← along horizontal edge
// leg_b end = corner + (0, ±t) ← along vertical edge
// Hypotenuse runs from leg_a end to leg_b end (the outer
// diagonal of the corner).
drawList.AddTriangleFilled(tl + new Vector2( t, t), tl + new Vector2( t, 0), tl + new Vector2(0, t), col);
drawList.AddTriangleFilled(tr + new Vector2(-t, t), tr + new Vector2(-t, 0), tr + new Vector2(0, t), col);
drawList.AddTriangleFilled(br + new Vector2(-t, -t), br + new Vector2(-t, 0), br + new Vector2(0, -t), col);
drawList.AddTriangleFilled(bl + new Vector2( t, -t), bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
}
/// <summary>
/// Project a world-space point to screen-space pixels. Returns
/// <c>false</c> if the point is behind the camera or outside the
/// expanded viewport (±20 % margin so a tall entity whose feet are
/// just off the bottom of the screen still gets its head projected).
/// </summary>
private static bool TryProjectToScreen(
Vector3 world,
Matrix4x4 viewProj,
Vector2 viewport,
out Vector2 screen)
{
var clip = Vector4.Transform(new Vector4(world, 1f), viewProj);
if (clip.W <= 0.001f)
{
screen = Vector2.Zero;
return false;
}
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
const float margin = 1.2f;
if (ndcX < -margin || ndcX > margin || ndcY < -margin || ndcY > margin)
{
screen = Vector2.Zero;
return false;
}
screen = new Vector2(
(ndcX * 0.5f + 0.5f) * viewport.X,
(1f - (ndcY * 0.5f + 0.5f)) * viewport.Y);
return true;
}
private static uint MakeImGuiColor(RadarBlipColors.Rgba c)
{
// ImGui packed colour is 0xAABBGGRR (little-endian RGBA).
return ((uint)c.A << 24) | ((uint)c.B << 16) | ((uint)c.G << 8) | c.R;
}
}

View file

@ -1,114 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Selection;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
/// (currently-loaded EnvCells with resolved polygon planes), returns
/// the nearest world-space <c>t</c> along the ray that hits any cell
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
/// all cells.
///
/// <para>
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
/// ray-triangle intersection; one test per triangle. Cells are
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
/// so the ray runs in cell-local space and the resolved-polygon
/// vertices don't need re-transformation per query.
/// </para>
///
/// <para>
/// No BSP traversal — iterates every polygon in every cell. Cell count
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
/// </para>
/// </summary>
public static class CellBspRayOccluder
{
/// <summary>
/// Returns the nearest positive <c>t</c> such that
/// <c>origin + t * direction</c> intersects a polygon in any cell.
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
/// is intersected.
/// </summary>
/// <param name="direction">Need not be normalized; returned <c>t</c>
/// scales with direction length the same as a parametric ray.</param>
public static float NearestWallT(
Vector3 origin,
Vector3 direction,
IEnumerable<CellPhysics> loadedCells)
{
if (loadedCells is null) return float.PositiveInfinity;
float bestT = float.PositiveInfinity;
foreach (var cell in loadedCells)
{
if (cell?.Resolved is null) continue;
// Bring the ray into cell-local space ONCE per cell.
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
foreach (var (_, poly) in cell.Resolved)
{
// Triangulate the (possibly polygonal) face into a fan.
int n = poly.NumPoints;
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
continue;
for (int i = 1; i < n - 1; i++)
{
if (TryRayTriangle(
localOrigin, localDirection,
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
out var t)
&& t < bestT)
{
bestT = t;
}
}
}
}
return bestT;
}
/// <summary>
/// Möller-Trumbore ray-triangle intersection. Returns true with
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
/// at a positive distance.
/// </summary>
private static bool TryRayTriangle(
Vector3 origin, Vector3 direction,
Vector3 v0, Vector3 v1, Vector3 v2,
out float t)
{
const float Epsilon = 1e-7f;
var edge1 = v1 - v0;
var edge2 = v2 - v0;
var pvec = Vector3.Cross(direction, edge2);
float det = Vector3.Dot(edge1, pvec);
// No two-sided handling here — picker should be permissive so
// a wall blocks regardless of which side the camera is on.
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
float invDet = 1f / det;
var tvec = origin - v0;
float u = Vector3.Dot(tvec, pvec) * invDet;
if (u < 0f || u > 1f) { t = 0f; return false; }
var qvec = Vector3.Cross(tvec, edge1);
float v = Vector3.Dot(direction, qvec) * invDet;
if (v < 0f || u + v > 1f) { t = 0f; return false; }
t = Vector3.Dot(edge2, qvec) * invDet;
return t > Epsilon;
}
}

View file

@ -0,0 +1,32 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Immutable CPU geometry used by retail's world-selection pass. One instance
/// represents one GfxObj part: its exact drawing-BSP root sphere and its visual
/// polygons in DAT order.
/// </summary>
public sealed record RetailSelectionMesh(
Vector3 SphereCenter,
float SphereRadius,
IReadOnlyList<RetailSelectionPolygon> Polygons);
/// <summary>One visual polygon. Vertex order and one/two-sidedness are DAT-authored.</summary>
public sealed record RetailSelectionPolygon(
IReadOnlyList<Vector3> Vertices,
bool SingleSided);
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
public readonly record struct RetailSelectionPart(
uint ServerGuid,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
public readonly record struct RetailSelectionHit(
uint ServerGuid,
int PartIndex,
double Distance,
bool PolygonHit);

View file

@ -0,0 +1,181 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Pure port of retail's render-coupled mouse selection accumulator.
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> broad-phases each
/// visible part against its drawing sphere, then scans visual polygons in DAT
/// order. Any polygon hit globally outranks every sphere-only fallback.
/// </summary>
public static class RetailWorldPicker
{
private const double RetailRayEpsilon = 0.0002;
public static RetailSelectionHit? Pick(
Vector3 worldOrigin,
Vector3 worldDirection,
IEnumerable<RetailSelectionPart> visibleParts,
uint skipServerGuid = 0u)
{
if (worldDirection.LengthSquared() < 1e-10f)
return null;
RetailSelectionHit? closestSphere = null;
RetailSelectionHit? closestPolygon = null;
foreach (var part in visibleParts)
{
if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid)
continue;
if (part.Mesh.SphereRadius <= 0f
|| !Matrix4x4.Invert(part.LocalToWorld, out var worldToLocal))
continue;
// Keep direction unnormalised after the affine inverse. With row-vector
// transforms this preserves the same ray parameter t in world metres even
// when the part carries scale (retail divides by gfxobj_scale likewise).
Vector3 localOrigin = Vector3.Transform(worldOrigin, worldToLocal);
Vector3 localDirection = Vector3.TransformNormal(worldDirection, worldToLocal);
if (!TryIntersectSphere(
localOrigin,
localDirection,
part.Mesh.SphereCenter,
part.Mesh.SphereRadius,
out double sphereT))
continue;
// Retail skips a part whose broad sphere starts beyond an already-found
// polygon, because that part cannot improve the global polygon winner.
if (closestPolygon is { } polygonWinner && sphereT > polygonWinner.Distance)
continue;
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
closestSphere = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
// Retail stops at the FIRST hit polygon in this part's stored flat order.
foreach (var polygon in part.Mesh.Polygons)
{
if (!TryIntersectPolygon(localOrigin, localDirection, polygon, out double polygonT))
continue;
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
closestPolygon = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
break;
}
}
return closestPolygon ?? closestSphere;
}
internal static bool TryIntersectSphere(
Vector3 origin,
Vector3 direction,
Vector3 center,
float radius,
out double distance)
{
// CSphere::sphere_intersects_ray @ 0x005377A0. Retail intentionally
// declines a broad-phase hit when the ray begins in or on the sphere.
// The render view-cone normally keeps selectable objects in front of
// the camera, so the routine does not separately reject a negative t.
distance = 0d;
Vector3 offset = origin - center;
double c = Vector3.Dot(offset, offset) - (double)radius * radius;
if (c <= 0d)
return false;
double a = Vector3.Dot(direction, direction);
if (a < RetailRayEpsilon)
return false;
double b = -Vector3.Dot(offset, direction);
double discriminant = b * b - c * a;
if (discriminant < 0d)
return false;
double root = Math.Sqrt(discriminant);
distance = b > root ? (b - root) / a : (b + root) / a;
return true;
}
internal static bool TryIntersectPolygon(
Vector3 origin,
Vector3 direction,
RetailSelectionPolygon polygon,
out double distance)
{
distance = 0d;
if (polygon.Vertices.Count < 3
|| !TryPlane(polygon.Vertices, out Vector3 normal, out float planeD))
return false;
double denominator = Vector3.Dot(direction, normal);
// CPolygon::polygon_hits_ray @ 0x005395E0: raw sides_type 0 is
// single-sided and rejects a ray travelling with the positive normal.
if (polygon.SingleSided && denominator > 0d)
return false;
if (Math.Abs(denominator) < RetailRayEpsilon)
return false;
distance = -(Vector3.Dot(origin, normal) + planeD) / denominator;
if (distance < 0d)
return false;
Vector3 point = origin + direction * (float)distance;
return PointInPolygon(point, polygon.Vertices, normal);
}
private static bool TryPlane(
IReadOnlyList<Vector3> vertices,
out Vector3 normal,
out float planeD)
{
// CPolygon::make_plane @ 0x005383D0 builds a triangle fan from
// vertex zero, sums the fan normals, normalizes once, then chooses d
// from the average signed distance of every vertex. DatReaderWriter
// exposes vertices rather than retail's derived Plane, so reconstruct
// that load-time result here.
Vector3 first = vertices[0];
Vector3 normalSum = Vector3.Zero;
for (int i = 1; i + 1 < vertices.Count; i++)
normalSum += Vector3.Cross(vertices[i] - first, vertices[i + 1] - first);
if (normalSum.LengthSquared() > 1e-12f)
{
normal = Vector3.Normalize(normalSum);
double averageDot = 0d;
foreach (Vector3 vertex in vertices)
averageDot += Vector3.Dot(normal, vertex);
planeD = (float)-(averageDot / vertices.Count);
return true;
}
normal = default;
planeD = 0f;
return false;
}
private static bool PointInPolygon(
Vector3 point,
IReadOnlyList<Vector3> vertices,
Vector3 normal)
{
// CPolygon::point_in_polygon @ 0x00538D90. Retail visual polygons are
// convex: the point must remain on the inward side of every ordered
// edge. Zero is accepted, so a click exactly on an edge still hits.
Vector3 previous = vertices[^1];
for (int i = 0; i < vertices.Count; i++)
{
Vector3 current = vertices[i];
Vector3 inward = Vector3.Cross(normal, current - previous);
if (Vector3.Dot(point - previous, inward) < 0f)
return false;
previous = current;
}
return true;
}
}

View file

@ -1,286 +1,54 @@
using System;
using System.Collections.Generic;
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>.
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
/// </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).
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
/// row-vector convention (<c>view * projection</c>).
/// </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.
/// The camera viewpoint and normalized direction, or two zero vectors
/// when the camera transform is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
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))
Matrix4x4 vp = view * projection;
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
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)
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
Vector4 near = Vector4.Transform(nearClip, invVp);
Vector4 far = Vector4.Transform(farClip, invVp);
if (near.W == 0f || far.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)
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
Vector3 direction = farWorld - nearWorld;
if (direction.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <param name="direction">
/// World-space ray direction. <b>Must be normalized</b> — the geometric
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
/// makes <c>maxDistance</c> compare against ray-parameter units instead
/// of world meters.
/// </param>
/// <remarks>
/// <para>
/// 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"/>.
/// </para>
/// <para>
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
/// in practice this over-picked massively — any cursor anywhere near an
/// NPC selected the NPC instead of a nearby item, and "click empty
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
/// most items. A future refinement is per-itemType radius (smaller for
/// tapers, bigger for shop chests) or priority sorting (items beat
/// NPCs at equal hit-distance).
/// </para>
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f,
Func<uint, float>? radiusForGuid = null,
Func<uint, float>? verticalOffsetForGuid = null,
Func<Vector3, Vector3, float>? cellOccluder = null)
{
const float DefaultRadius = 1.0f;
const float DefaultVerticalOffset = 0.9f;
if (direction.LengthSquared() < 1e-10f) return null;
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
// a cell-BSP occluder, query the nearest wall hit along the ray
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
// and are skipped.
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Per-entity radius + vertical offset (caller-supplied).
//
// <para>
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
// is at the entity's feet (Z=ground for a humanoid). User
// clicks usually land on chest/head (Z ≈ 11.8 m). With the
// sphere centred at feet, a chest click is 1.2 m of vertical
// distance from sphere centre — bigger than any reasonable
// body radius — so the ray misses. Lifting the sphere
// centre to mid-body fixes this: 0.9 m default for
// humanoids, smaller for items, larger for tall objects.
// </para>
//
// <para>
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
// accommodate the new vertical-offset sphere placement
// (chest-height sphere centre + 1.0 m radius covers from
// shin to top-of-head for a 1.8 m humanoid).
// </para>
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
float r2 = r * r;
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
var sphereCenter = new Vector3(
entity.Position.X,
entity.Position.Y,
entity.Position.Z + vz);
// 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 - sphereCenter;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - r2;
float d = b * b - c;
if (d < 0f) continue;
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
// to t_far so the entity is still pickable at point-blank range.
float sqrtD = MathF.Sqrt(d);
float t = -b - sqrtD;
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
if (t >= maxDistance) continue;
if (t >= wallT) continue; // wall is between camera and entity (#86)
if (t < bestT)
{
bestT = t;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
/// <summary>
/// 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>
/// 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>
/// 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(
float mouseX, float mouseY,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
float inflatePixels = 8f,
Func<Vector3, Vector3, float>? cellOccluder = null)
{
uint? bestGuid = null;
float bestDepth = float.PositiveInfinity;
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
// Build the click ray, query the nearest wall along it, convert
// to the same camera-space depth metric (clip.W) that
// ScreenProjection.TryProjectSphereToScreenRect returns per
// candidate. Candidates with depth > wallDepth sit behind a wall.
float wallDepth = float.PositiveInfinity;
if (cellOccluder is not null)
{
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
if (rayDir.LengthSquared() > 0f)
{
float wallT = cellOccluder(rayOrigin, rayDir);
if (!float.IsPositiveInfinity(wallT))
{
var wallPoint = rayOrigin + rayDir * wallT;
// ScreenProjection uses clip.W as its depth metric —
// "camera-space depth" in the row-vector convention is
// the W component of the homogeneous clip-space vector,
// which equals the eye-space Z distance to the point.
var viewProj = view * projection;
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
if (clip.W > 0f)
wallDepth = clip.W;
}
}
}
foreach (var entity in candidates)
{
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;
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 > wallDepth) continue; // wall is between camera and entity (#86)
if (depth < bestDepth)
{
bestDepth = depth;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
// not the near-plane point. Inverse-view origin is that viewpoint in
// System.Numerics' row-vector convention.
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
return (viewpoint, Vector3.Normalize(direction));
}
}

View file

@ -52,7 +52,7 @@ public sealed record GameplaySettings(
ToggleRun: true,
AdvancedCombatUI: false,
ShowTooltips: true,
VividTargetingIndicator: false,
VividTargetingIndicator: true,
SideBySideVitals: false,
// Retail default character-options mask 0x50C4A54A includes
// CoordinatesOnRadar (0x00400000).

View file

@ -0,0 +1,117 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.UI;
using AcDream.Core.Textures;
using AcDream.Core.Selection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit.Sdk;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.Rendering;
public sealed class RetailSelectionAssetTests
{
[Fact]
public void InstalledDat_ResolvesAllFourVividTargetCorners()
{
string datDir = ResolveDatDir();
using var dats = new DatCollection(datDir, DatAccessType.Read);
var master = Assert.IsType<EnumIDMap>(
dats.Get<EnumIDMap>((uint)dats.Portal.Header.MasterMapId));
uint subMapDid = master.ClientEnumToID[0x10000009u];
var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid));
for (uint index = 0; index < 4; index++)
{
uint enumValue = 1u + index;
uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u);
Assert.True(did != 0u,
$"enum {enumValue}; submap 0x{subMapDid:X8} keys: {string.Join(',', subMap.ClientEnumToID.Keys.Select(k => $"0x{k:X8}"))}");
Assert.True(dats.Portal.TryGet<RenderSurface>(did, out var surface)
|| dats.HighRes.TryGet<RenderSurface>(did, out surface),
$"enum 0x{enumValue:X8} resolved 0x{did:X8}");
Assert.NotNull(surface);
Assert.Equal(0x06000000u, did & 0xFF000000u);
Assert.Equal((12, 12), (surface.Width, surface.Height));
Palette? palette = surface.DefaultPaletteId != 0
? dats.Get<Palette>(surface.DefaultPaletteId)
: null;
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(surface, palette);
int coloredPixels = 0;
for (int pixel = 0; pixel < decoded.Rgba8.Length; pixel += 4)
{
if (decoded.Rgba8[pixel + 3] == 0)
continue;
if (decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 1]
|| decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 2])
coloredPixels++;
}
Assert.True(coloredPixels == 0,
$"corner {index + 1} has {coloredPixels} non-grayscale source pixels");
}
}
[Fact]
public void DoorSelectionGeometry_UsesDrawingBspRootSphereAndDatPolygonOrder()
{
const uint doorGfxObjId = 0x010044B5u;
string datDir = ResolveDatDir();
using var dats = new DatCollection(datDir, DatAccessType.Read);
var gfx = Assert.IsType<GfxObj>(dats.Get<GfxObj>(doorGfxObjId));
var cache = new RetailSelectionGeometryCache(dats, new object());
var selection = Assert.IsType<AcDream.Core.Selection.RetailSelectionMesh>(
cache.Resolve(doorGfxObjId));
var root = Assert.IsType<DatReaderWriter.Types.DrawingBSPNode>(gfx.DrawingBSP.Root);
Assert.Equal(root.BoundingSphere.Origin, selection.SphereCenter);
Assert.Equal(root.BoundingSphere.Radius, selection.SphereRadius);
Assert.Equal(gfx.Polygons.Count, selection.Polygons.Count);
var firstSource = gfx.Polygons.First().Value;
var firstSelection = selection.Polygons[0];
Assert.Equal((int)firstSource.SidesType == 0, firstSelection.SingleSided);
Assert.Equal(firstSource.VertexIds.Count, firstSelection.Vertices.Count);
}
[Fact]
public void DrawingSphereViewconeRejectsPartBehindCamera()
{
Matrix4x4 view = Matrix4x4.CreateLookAt(
Vector3.Zero, -Vector3.UnitZ, Vector3.UnitY);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 4f / 3f, 0.1f, 100f);
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(view * projection);
var mesh = new RetailSelectionMesh(Vector3.Zero, 1f, []);
Assert.True(RetailSelectionScene.DrawingSphereIntersectsFrustum(
mesh, Matrix4x4.CreateTranslation(0f, 0f, -5f), frustum));
Assert.False(RetailSelectionScene.DrawingSphereIntersectsFrustum(
mesh, Matrix4x4.CreateTranslation(0f, 0f, 5f), frustum));
}
private static string ResolveDatDir()
{
string? configured = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured)
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
return configured;
const string installed = @"C:\Turbine\Asheron's Call";
if (File.Exists(Path.Combine(installed, "client_portal.dat")))
return installed;
string conventional = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (File.Exists(Path.Combine(conventional, "client_portal.dat")))
return conventional;
throw SkipException.ForSkip("Installed client_portal.dat is required.");
}
}

View file

@ -0,0 +1,40 @@
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class VividTargetIndicatorLayoutTests
{
private static readonly (float Width, float Height)[] RetailCorners =
[(12f, 12f), (12f, 12f), (12f, 12f), (12f, 12f)];
[Fact]
public void CornersSitOutsideSelectionSphereRectangle()
{
VividTargetLayout layout = VividTargetIndicatorController.ComputeLayout(
new Vector2(100f, 80f),
new Vector2(180f, 200f),
new Vector2(800f, 600f),
RetailCorners);
Assert.Equal(new Vector2(88f, 68f), layout.RootPosition);
Assert.Equal(new Vector2(104f, 144f), layout.RootSize);
Assert.Equal(Vector2.Zero, layout.CornerPositions[0]);
Assert.Equal(new Vector2(92f, 0f), layout.CornerPositions[1]);
Assert.Equal(new Vector2(92f, 132f), layout.CornerPositions[2]);
Assert.Equal(new Vector2(0f, 132f), layout.CornerPositions[3]);
}
[Fact]
public void WholeIndicatorClampsToRetailEightPixelViewportMargin()
{
VividTargetLayout layout = VividTargetIndicatorController.ComputeLayout(
new Vector2(2f, 3f),
new Vector2(62f, 83f),
new Vector2(200f, 150f),
RetailCorners);
Assert.Equal(new Vector2(8f, 8f), layout.RootPosition);
Assert.Equal(new Vector2(66f, 87f), layout.RootSize);
}
}

View file

@ -1,86 +0,0 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class CellBspRayOccluderTests
{
// Build a CellPhysics with a single triangular poly at world-Y=10.
// Triangle vertices in local space, world transform = identity.
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
private static CellPhysics MakeWallCell()
{
var verts = new[]
{
new Vector3(-5, 10, 0),
new Vector3( 5, 10, 0),
new Vector3( 0, 10, 5),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
NumPoints = 3,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
[Fact]
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
}
[Fact]
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = -Vector3.UnitY; // travels AWAY from the wall
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
}
[Fact]
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
{
var origin = Vector3.Zero;
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
Assert.True(float.IsPositiveInfinity(t));
}
[Fact]
public void NearestWallT_TwoCells_ReturnsNearer()
{
var nearCell = MakeWallCell(); // wall at Y=10
var farCell = MakeWallCell();
// Move farCell's transform to push it to Y=20.
farCell = new CellPhysics
{
BSP = null,
Resolved = nearCell.Resolved,
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
};
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
}
}

View file

@ -0,0 +1,173 @@
using System.Numerics;
using AcDream.Core.Selection;
namespace AcDream.Core.Tests.Selection;
public sealed class RetailWorldPickerTests
{
[Fact]
public void BuildRayStartsAtCameraViewpointRatherThanNearPlane()
{
var eye = new Vector3(10f, 20f, 30f);
Matrix4x4 view = Matrix4x4.CreateLookAt(eye, eye - Vector3.UnitZ, Vector3.UnitY);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
var ray = WorldPicker.BuildRay(400f, 300f, 800f, 600f, view, projection);
Assert.True(Vector3.Distance(eye, ray.Origin) < 0.0001f);
Assert.True(Vector3.Distance(-Vector3.UnitZ, ray.Direction) < 0.0001f);
}
[Fact]
public void PolygonHitGloballyBeatsNearerSphereFallback()
{
var sphereOnly = Part(1u, z: -3f, polygons: []);
var polygon = Part(2u, z: -8f, polygons: [SquareAtLocalZ(0f)]);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [sphereOnly, polygon]);
Assert.NotNull(hit);
Assert.Equal(2u, hit.Value.ServerGuid);
Assert.True(hit.Value.PolygonHit);
}
[Fact]
public void SphereIsUsedOnlyWhenNoVisiblePolygonHits()
{
var part = Part(0xABCDu, z: -5f, polygons: []);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [part]);
Assert.NotNull(hit);
Assert.Equal(0xABCDu, hit.Value.ServerGuid);
Assert.False(hit.Value.PolygonHit);
Assert.Equal(3d, hit.Value.Distance, 4);
}
[Fact]
public void StopsAtFirstHitPolygonInDatOrderForEachPart()
{
var datOrdered = Part(
10u,
z: 0f,
polygons: [SquareAtLocalZ(-10f), SquareAtLocalZ(-5f)],
sphereCenter: new Vector3(0f, 0f, -7.5f),
sphereRadius: 5f);
var competitor = Part(20u, z: -7f, polygons: [SquareAtLocalZ(0f)]);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [datOrdered, competitor]);
// Scanning all polygons would incorrectly give guid 10 at 5 m. Retail
// accepts its first DAT-order hit at 10 m, so guid 20 wins at 7 m.
Assert.NotNull(hit);
Assert.Equal(20u, hit.Value.ServerGuid);
}
[Fact]
public void SingleSidedPolygonRejectsItsBackFace()
{
var front = new RetailSelectionPart(
1u,
0,
Matrix4x4.Identity,
new RetailSelectionMesh(
new Vector3(0f, 0f, -5f), 2f,
[SquareAtLocalZ(-5f, singleSided: true)]));
RetailSelectionHit? backHit = RetailWorldPicker.Pick(
new Vector3(0f, 0f, -10f), Vector3.UnitZ, [front]);
Assert.NotNull(backHit); // drawing sphere remains the retail fallback
Assert.False(backHit.Value.PolygonHit);
}
[Fact]
public void InverseScaledPartPreservesWorldDistance()
{
var transform = Matrix4x4.CreateScale(2f)
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
var part = new RetailSelectionPart(
3u,
0,
transform,
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [part]);
Assert.NotNull(hit);
Assert.True(hit.Value.PolygonHit);
Assert.Equal(10d, hit.Value.Distance, 4);
}
[Fact]
public void SkipGuidAndNonServerPartsNeverCompete()
{
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero,
-Vector3.UnitZ,
[Part(0u, -2f, [SquareAtLocalZ(0f)]), Part(7u, -4f, [SquareAtLocalZ(0f)])],
skipServerGuid: 7u);
Assert.Null(hit);
}
[Fact]
public void DrawingSphereRejectsRayWhichBeginsInsideLikeRetail()
{
bool hit = RetailWorldPicker.TryIntersectSphere(
Vector3.Zero, Vector3.UnitX, Vector3.Zero, 2f, out _);
Assert.False(hit);
}
[Fact]
public void ConvexEdgeTestRejectsAConcaveNotchRatherThanUsingEvenOddFill()
{
var concave = new RetailSelectionPolygon(
[
new(-2f, -2f, -5f),
new( 2f, -2f, -5f),
new( 2f, 2f, -5f),
new( 0f, 0f, -5f),
new(-2f, 2f, -5f),
],
SingleSided: false);
bool hit = RetailWorldPicker.TryIntersectPolygon(
new Vector3(0f, 1f, 0f), -Vector3.UnitZ, concave, out _);
Assert.False(hit);
}
private static RetailSelectionPart Part(
uint guid,
float z,
IReadOnlyList<RetailSelectionPolygon> polygons,
Vector3? sphereCenter = null,
float sphereRadius = 2f)
=> new(
guid,
0,
Matrix4x4.CreateTranslation(0f, 0f, z),
new RetailSelectionMesh(
sphereCenter ?? Vector3.Zero,
sphereRadius,
polygons));
private static RetailSelectionPolygon SquareAtLocalZ(
float z,
bool singleSided = false)
=> new(
[
new Vector3(-1f, -1f, z),
new Vector3( 1f, -1f, z),
new Vector3( 1f, 1f, z),
new Vector3(-1f, 1f, z),
],
singleSided);
}

View file

@ -1,189 +0,0 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerCellOcclusionTests
{
private static CellPhysics MakeWallAtY10()
{
// A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space
// because WorldTransform = Identity). The occluder triangulates it as a fan:
// tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10.
var verts = new[]
{
new Vector3(-5, 10, -5),
new Vector3( 5, 10, -5),
new Vector3( 5, 10, 5),
new Vector3(-5, 10, 5),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
NumPoints = 4,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null,
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
{
Id = guid,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
/// <summary>
/// Builds a quad wall at Z=-10 in front of the camera (identity view,
/// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 —
/// large enough to cover the center-pixel ray. An entity at Z=-20 sits
/// behind it.
///
/// Wall normal direction doesn't affect Möller-Trumbore (the occluder
/// is two-sided), but the Plane is stored for completeness. For a plane
/// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0
/// → D = 10.
/// </summary>
private static CellPhysics MakeWallAtZNeg10()
{
var verts = new[]
{
new Vector3(-5, -5, -10),
new Vector3( 5, -5, -10),
new Vector3( 5, 5, -10),
new Vector3(-5, 5, -10),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f),
NumPoints = 4,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null,
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
// ──────────────────────────────────────────────
// Screen-rect overload + cell-BSP occlusion
// ──────────────────────────────────────────────
/// <summary>
/// Production path exercised by GameWindow.PickAndStoreSelection.
/// Camera at origin looking down -Z (identity view). Entity at Z=-20
/// projects to the center of the viewport. A wall at Z=-10 sits between
/// camera and entity; with cellOccluder wired up the entity must be
/// occluded → null result.
///
/// This test specifically covers the clip.W depth-conversion math in
/// WorldPicker.Pick's screen-rect overload (issue #86).
/// </summary>
[Fact]
public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp()
{
// Use the same camera convention as WorldPickerRectOverloadTests.StdCam():
// identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300).
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800f, 600f);
var wall = MakeWallAtZNeg10();
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
// Entity is dead-ahead: center of viewport.
var result = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
candidates: new[] { entity },
skipServerGuid: 0u,
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
inflatePixels: 8f,
cellOccluder: (origin, direction) =>
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
Assert.Null(result);
}
/// <summary>
/// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp,
/// but with a null cellOccluder. Verifies that the no-occluder path still
/// resolves the entity to a hit (the new parameter is a pure no-op when null).
/// </summary>
[Fact]
public void Pick_ScreenRect_NoWall_HitsEntity()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800f, 600f);
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
candidates: new[] { entity },
skipServerGuid: 0u,
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
inflatePixels: 8f,
cellOccluder: null);
Assert.Equal(0xABCDu, result);
}
// ──────────────────────────────────────────────
// Ray-sphere overload (legacy path)
// ──────────────────────────────────────────────
[Fact]
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
{
var wall = MakeWallAtY10();
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitY,
candidates: new[] { entity },
skipServerGuid: 0u,
cellOccluder: (origin, direction) =>
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
Assert.Null(result);
}
[Fact]
public void Pick_RaySphere_NoWall_HitsEntity()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitY,
candidates: new[] { entity },
skipServerGuid: 0u,
cellOccluder: null); // null occluder = no occlusion
Assert.Equal(0xABCDu, result);
}
}

View file

@ -1,133 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.Core.Tests.Selection;
public sealed class WorldPickerRectOverloadTests
{
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
{
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);
}
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_RectHitTest_ReturnsHitWhenMouseInsideRect()
{
var (view, proj, viewport) = StdCam();
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
{
var (view, proj, viewport) = StdCam();
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
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();
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { far, near } /* deliberately reversed */,
skipServerGuid: 0u,
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 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
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)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
public void Pick_RectHitTest_RespectsSkipServerGuid()
{
var (view, proj, viewport) = StdCam();
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
uint? picked = WorldPicker.Pick(
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 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
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);
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);
}
}

View file

@ -1,169 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
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)");
}
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);
}
[Fact]
public void Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid()
{
// Player ~3m from a door -> camera near-plane sits INSIDE the door's
// 5m bounding sphere. Naive t_near < 0 guard would skip; correct
// behavior is to fall through to t_far (the sphere exit point).
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -3));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
}

View file

@ -21,7 +21,7 @@ public sealed class GameplaySettingsTests
Assert.True(d.ToggleRun);
Assert.False(d.AdvancedCombatUI);
Assert.True(d.ShowTooltips);
Assert.False(d.VividTargetingIndicator);
Assert.True(d.VividTargetingIndicator); // retail default mask includes 0x00008000
Assert.False(d.SideBySideVitals);
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
Assert.True(d.SpellDuration);