From 146a963aeb5948cde07d3439d51f0c7ee53bf746 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 21:32:51 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 37 ++- .../retail-divergence-register.md | 2 - docs/architecture/worldbuilder-inventory.md | 5 + docs/plans/2026-04-11-roadmap.md | 1 + ...07-17-retail-world-selection-pseudocode.md | 145 +++++++++ src/AcDream.App/Rendering/GameWindow.cs | 173 ++++------- .../Selection/IRetailSelectionRenderSink.cs | 17 + .../Selection/RetailSelectionGeometryCache.cs | 76 +++++ .../Selection/RetailSelectionScene.cs | 113 +++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 61 +++- .../Layout/VividTargetIndicatorController.cs | 204 ++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 15 + src/AcDream.App/UI/TargetIndicatorPanel.cs | 265 ---------------- .../Selection/CellBspRayOccluder.cs | 114 ------- .../Selection/RetailSelectionMesh.cs | 32 ++ .../Selection/RetailWorldPicker.cs | 181 +++++++++++ src/AcDream.Core/Selection/WorldPicker.cs | 290 ++---------------- .../Panels/Settings/GameplaySettings.cs | 2 +- .../Rendering/RetailSelectionAssetTests.cs | 117 +++++++ .../Layout/VividTargetIndicatorLayoutTests.cs | 40 +++ .../Selection/CellBspRayOccluderTests.cs | 86 ------ .../Selection/RetailWorldPickerTests.cs | 173 +++++++++++ .../WorldPickerCellOcclusionTests.cs | 189 ------------ .../Selection/WorldPickerRectOverloadTests.cs | 133 -------- .../Selection/WorldPickerTests.cs | 169 ---------- .../Panels/Settings/GameplaySettingsTests.cs | 2 +- 26 files changed, 1302 insertions(+), 1340 deletions(-) create mode 100644 docs/research/2026-07-17-retail-world-selection-pseudocode.md create mode 100644 src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs create mode 100644 src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs create mode 100644 src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs create mode 100644 src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs delete mode 100644 src/AcDream.App/UI/TargetIndicatorPanel.cs delete mode 100644 src/AcDream.Core/Selection/CellBspRayOccluder.cs create mode 100644 src/AcDream.Core/Selection/RetailSelectionMesh.cs create mode 100644 src/AcDream.Core/Selection/RetailWorldPicker.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs delete mode 100644 tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs create mode 100644 tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs delete mode 100644 tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs delete mode 100644 tests/AcDream.Core.Tests/Selection/WorldPickerRectOverloadTests.cs delete mode 100644 tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4a33fc86..f676c7b0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ff5f7838..6310bf92 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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.7–1.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) | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 9a035bc3..a7da8937 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -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. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 8e29b9f2..ea0485bf 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -524,6 +524,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` 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. diff --git a/docs/research/2026-07-17-retail-world-selection-pseudocode.md b/docs/research/2026-07-17-retail-world-selection-pseudocode.md new file mode 100644 index 00000000..65a69de3 --- /dev/null +++ b/docs/research/2026-07-17-retail-world-selection-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6682fd92..6c8cc24f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. @@ -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 // ============================================================ /// - /// 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). : - /// 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. allows item target-use to + /// pick the local player while plain selection excludes it. /// 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(); - 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); + } + /// /// 2026-05-16 — retail-faithful port of diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs new file mode 100644 index 00000000..68bfacf2 --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs @@ -0,0 +1,17 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Selection; + +/// +/// Narrow seam from the normal world draw traversal to retail mouse selection. +/// Only entities which survive that traversal are published. +/// +internal interface IRetailSelectionRenderSink +{ + void AddVisiblePart( + WorldEntity entity, + int partIndex, + uint gfxObjId, + Matrix4x4 partWorld); +} diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs new file mode 100644 index 00000000..041b57c2 --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs @@ -0,0 +1,76 @@ +using System.Numerics; +using AcDream.Core.Selection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering.Selection; + +/// +/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad +/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order. +/// +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 _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(gfxObjId); + + var root = gfx?.DrawingBSP?.Root; + if (gfx is null || root is null || root.BoundingSphere.Radius <= 0f) + return null; + + var polygons = new List(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); + } +} diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs new file mode 100644 index 00000000..4593f53c --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -0,0 +1,113 @@ +using System.Numerics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Selection; + +/// +/// 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. +/// +internal sealed class RetailSelectionScene : IRetailSelectionRenderSink +{ + private readonly RetailSelectionGeometryCache _geometry; + private List _building = new(); + private List _published = new(); + private readonly HashSet _buildingKeys = new(); + private HashSet _buildingGuids = new(); + private HashSet _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; + } + + /// + /// 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. + /// + 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; +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 563b81ea..aca54e95 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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, diff --git a/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs new file mode 100644 index 00000000..f1dcf438 --- /dev/null +++ b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs @@ -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 PlayerGuid, + Func Enabled, + Func ResolveTarget, + Func WasDrawn, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera); + +/// +/// Retained-UI port of VividTargetIndicator @ 0x004F5CE0..0x004F6DF6. +/// 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. +/// +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 CornerPositions); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e5830082..8d3eb60c 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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); diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs deleted file mode 100644 index 441b3caf..00000000 --- a/src/AcDream.App/UI/TargetIndicatorPanel.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Ui; -using ImGuiNET; - -namespace AcDream.App.UI; - -/// -/// 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 VividTargetIndicator -/// (named decomp at 0x004d6165 / 0x004f5ce0). -/// -/// -/// 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. -/// -/// -/// -/// The panel pulls its inputs through delegates supplied by the host -/// () so it doesn't have to depend -/// on internal state types: -/// -/// -/// selectedGuidProvider — Core SelectionState's current guid. -/// entityResolver — returns -/// for a given guid, or null if -/// the entity is no longer in the world (despawned). -/// cameraProvider — host's active camera + viewport -/// dimensions; called once per frame. -/// -/// -public sealed class TargetIndicatorPanel -{ - /// - /// What the panel needs to know about the selected entity per frame. - /// ItemType + ObjectDescriptionFlags feed - /// for colour selection. - /// Scale multiplies the per-type base height in - /// — a scaled-up sign or oversized NPC - /// gets a proportionally bigger box. Useability (acclient.h:6478 - /// ITEM_USEABLE 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). - /// - 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 _selectedGuidProvider; - private readonly Func _entityResolver; - private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider; - - /// - /// Pixel size of each corner triangle's right-angle legs. - /// Retail uses UIRegion::GetWidth(m_rgOnScreenCorners.m_data[1]) - /// of the triangle sprite (decomp 0x004f69c8). The retail - /// sprite is small — ~8 px legs. 14 was too chunky per user - /// feedback on 2026-05-16; 8 matches the retail screenshot. - /// - public float TriangleSize { get; set; } = 8f; - - /// - /// World-space height of the indicator box for entities that don't - /// have a more specific type tag. Items use a smaller value (see - /// ). 1.8 m matches a standing humanoid; - /// short items still get a small box because the projection - /// preserves apparent size. - /// - public float EntityHeight { get; set; } = 1.8f; - - /// - /// 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. - /// - public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null) - { - if (scale <= 0f) scale = 1f; - return 1.5f * scale; - } - - /// - /// Box width = projected height × - /// . 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. - /// - public float WidthHeightRatio { get; set; } = 1.0f; - - /// - /// Floor for the projected screen height (pixels). Prevents the - /// indicator from collapsing to a point on far-away entities. - /// - public float MinScreenHeight { get; set; } = 16f; - - public TargetIndicatorPanel( - Func selectedGuidProvider, - Func entityResolver, - Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider) - { - _selectedGuidProvider = selectedGuidProvider; - _entityResolver = entityResolver; - _cameraProvider = cameraProvider; - } - - /// - /// 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. - /// - 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 0x004f6a0b–0x004f6a99): - // 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); - } - - /// - /// Project a world-space point to screen-space pixels. Returns - /// false 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). - /// - 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; - } -} diff --git a/src/AcDream.Core/Selection/CellBspRayOccluder.cs b/src/AcDream.Core/Selection/CellBspRayOccluder.cs deleted file mode 100644 index 49d5283a..00000000 --- a/src/AcDream.Core/Selection/CellBspRayOccluder.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; - -namespace AcDream.Core.Selection; - -/// -/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon -/// occlusion test. Given a ray and a set of -/// (currently-loaded EnvCells with resolved polygon planes), returns -/// the nearest world-space t along the ray that hits any cell -/// polygon — or if the ray clears -/// all cells. -/// -/// -/// Used by 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 -/// so the ray runs in cell-local space and the resolved-polygon -/// vertices don't need re-transformation per query. -/// -/// -/// -/// 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 Pick call is well under 1 ms. -/// -/// -public static class CellBspRayOccluder -{ - /// - /// Returns the nearest positive t such that - /// origin + t * direction intersects a polygon in any cell. - /// Returns if no cell polygon - /// is intersected. - /// - /// Need not be normalized; returned t - /// scales with direction length the same as a parametric ray. - public static float NearestWallT( - Vector3 origin, - Vector3 direction, - IEnumerable 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; - } - - /// - /// Möller-Trumbore ray-triangle intersection. Returns true with - /// t in if the ray hits the triangle - /// at a positive distance. - /// - 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; - } -} diff --git a/src/AcDream.Core/Selection/RetailSelectionMesh.cs b/src/AcDream.Core/Selection/RetailSelectionMesh.cs new file mode 100644 index 00000000..cccebf0c --- /dev/null +++ b/src/AcDream.Core/Selection/RetailSelectionMesh.cs @@ -0,0 +1,32 @@ +using System.Numerics; + +namespace AcDream.Core.Selection; + +/// +/// 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. +/// +public sealed record RetailSelectionMesh( + Vector3 SphereCenter, + float SphereRadius, + IReadOnlyList Polygons); + +/// One visual polygon. Vertex order and one/two-sidedness are DAT-authored. +public sealed record RetailSelectionPolygon( + IReadOnlyList Vertices, + bool SingleSided); + +/// One part which survived the normal world-render visibility traversal. +public readonly record struct RetailSelectionPart( + uint ServerGuid, + int PartIndex, + Matrix4x4 LocalToWorld, + RetailSelectionMesh Mesh); + +/// Retail picker result, including which physics part supplied the hit. +public readonly record struct RetailSelectionHit( + uint ServerGuid, + int PartIndex, + double Distance, + bool PolygonHit); diff --git a/src/AcDream.Core/Selection/RetailWorldPicker.cs b/src/AcDream.Core/Selection/RetailWorldPicker.cs new file mode 100644 index 00000000..794efa89 --- /dev/null +++ b/src/AcDream.Core/Selection/RetailWorldPicker.cs @@ -0,0 +1,181 @@ +using System.Numerics; + +namespace AcDream.Core.Selection; + +/// +/// Pure port of retail's render-coupled mouse selection accumulator. +/// Render::GfxObjUnderSelectionRay @ 0x0054C740 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. +/// +public static class RetailWorldPicker +{ + private const double RetailRayEpsilon = 0.0002; + + public static RetailSelectionHit? Pick( + Vector3 worldOrigin, + Vector3 worldDirection, + IEnumerable 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 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 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; + } +} diff --git a/src/AcDream.Core/Selection/WorldPicker.cs b/src/AcDream.Core/Selection/WorldPicker.cs index 2b6fc672..dfe4ea46 100644 --- a/src/AcDream.Core/Selection/WorldPicker.cs +++ b/src/AcDream.Core/Selection/WorldPicker.cs @@ -1,286 +1,54 @@ -using System; -using System.Collections.Generic; using System.Numerics; -using AcDream.Core.World; namespace AcDream.Core.Selection; /// -/// Mouse-to-entity picker. Pure static functions; no state, no DI. -/// -/// turns a pixel + view/projection into a world-space ray. -/// ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid. -/// -/// Used by GameWindow.OnInputAction to wire SelectLeft / SelectDblLeft / UseSelected to InteractRequests.BuildUse. +/// Pure mouse-pixel to world-ray conversion shared by retail selection paths. /// public static class WorldPicker { /// - /// 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 (view * projection). /// /// - /// (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. /// 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)); - } - /// - /// Ray-sphere intersection against each candidate's - /// using a fixed 0.7 m sphere radius. Returns the - /// of the closest hit within , or null on miss. - /// - /// - /// World-space ray direction. Must be normalized — the geometric - /// ray-sphere formula simplifies a = dot(direction, direction) to - /// 1; non-unit input produces an undocumented t-scale that - /// makes maxDistance compare against ray-parameter units instead - /// of world meters. - /// - /// - /// - /// Entities with ServerGuid == 0 (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 - /// . - /// - /// - /// Radius history (Issue #59). 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). - /// - /// - public static uint? Pick( - Vector3 origin, Vector3 direction, - IEnumerable candidates, - uint skipServerGuid, - float maxDistance = 50f, - Func? radiusForGuid = null, - Func? verticalOffsetForGuid = null, - Func? 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). - // - // - // Vertical offset (2026-05-15). WorldEntity.Position - // is at the entity's feet (Z=ground for a humanoid). User - // clicks usually land on chest/head (Z ≈ 1–1.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. - // - // - // - // Radius (2026-05-15). 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). - // - 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; - } - - /// - /// 2026-05-16. Screen-space rect-hit-test picker overload. Each - /// candidate's world-space sphere (via ) - /// projects to a screen-space rectangle through - /// . The - /// rect is inflated by on every side - /// (matches the indicator's TriangleSize outer brackets) and - /// hit-tested against the mouse pixel. Among rects that contain the - /// mouse, the entity with the nearest camera-space depth wins. - /// - /// - /// 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 helper with - /// TargetIndicatorPanel, the click rect and the drawn rect - /// cannot drift. - /// - /// - /// - /// Resolver returning null skips the candidate (matches retail - /// "no Setup → not pickable" behavior). Entities with - /// ServerGuid == 0 (atlas-tier scenery) and the player's own - /// guid are also skipped. - /// - /// - /// - /// Stage A of the picker port. Stage B (polygon refine via - /// CPolygon::polygon_hits_ray 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. - /// - /// - /// Pixel inflate on each side of the - /// projected rect. Pass the indicator's TriangleSize (8 px) - /// so the click area extends to where the visible bracket corners - /// sit — the user perceives the inflated rect as the clickable area. - public static uint? Pick( - float mouseX, float mouseY, - Matrix4x4 view, - Matrix4x4 projection, - Vector2 viewport, - IEnumerable candidates, - uint skipServerGuid, - Func sphereForEntity, - float inflatePixels = 8f, - Func? 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)); } } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs index 51a1b777..86949e5f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -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). diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs new file mode 100644 index 00000000..c3744769 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs @@ -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( + dats.Get((uint)dats.Portal.Header.MasterMapId)); + uint subMapDid = master.ClientEnumToID[0x10000009u]; + var subMap = Assert.IsType(dats.Get(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(did, out var surface) + || dats.HighRes.TryGet(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(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(dats.Get(doorGfxObjId)); + var cache = new RetailSelectionGeometryCache(dats, new object()); + + var selection = Assert.IsType( + cache.Resolve(doorGfxObjId)); + + var root = Assert.IsType(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."); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs b/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs new file mode 100644 index 00000000..383bc7f0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs b/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs deleted file mode 100644 index 38466631..00000000 --- a/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs +++ /dev/null @@ -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()); - 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}"); - } -} diff --git a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs new file mode 100644 index 00000000..9528c2cb --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs @@ -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 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); +} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs deleted file mode 100644 index 91151db8..00000000 --- a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs +++ /dev/null @@ -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(), - }; - - /// - /// 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. - /// - 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 - // ────────────────────────────────────────────── - - /// - /// 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). - /// - [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); - } - - /// - /// 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). - /// - [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); - } -} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerRectOverloadTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerRectOverloadTests.cs deleted file mode 100644 index 176f68e8..00000000 --- a/tests/AcDream.Core.Tests/Selection/WorldPickerRectOverloadTests.cs +++ /dev/null @@ -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(), - }; - - [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); - } -} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs deleted file mode 100644 index ac0bc5ad..00000000 --- a/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs +++ /dev/null @@ -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(), - }; - - [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); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs index 6c1ec212..9cf2a3e5 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs @@ -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);