feat(selection): port retail polygon picking and vivid marker
Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks. Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp. Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0f82a08f0a
commit
146a963aeb
26 changed files with 1302 additions and 1340 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
||||
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
||||
- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state.
|
||||
- **✓ SHIPPED — retail world selection + vivid target indicator (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
|
||||
- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||
|
|
|
|||
145
docs/research/2026-07-17-retail-world-selection-pseudocode.md
Normal file
145
docs/research/2026-07-17-retail-world-selection-pseudocode.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Retail world selection and vivid target indicator
|
||||
|
||||
Oracle: September 2013 EoR named retail client. Cross-checks: the extracted
|
||||
WorldBuilder mesh path (DAT geometry and current part transforms) and ACE object
|
||||
identity/qualities. Neither reference implementation supplies the client picker;
|
||||
the named retail functions below are authoritative.
|
||||
|
||||
## Mouse selection
|
||||
|
||||
`SmartBox::find_object @ 0x00451C60`
|
||||
|
||||
```text
|
||||
remember the clicked viewport pixel
|
||||
clear the previous per-frame selection accumulator
|
||||
arm mouse selection for the next world draw
|
||||
```
|
||||
|
||||
`CPhysicsPart::Draw @ 0x0050D7A0`, `RenderDeviceD3D::DrawMesh @ 0x005A0860`,
|
||||
and `Render::GfxObjUnderSelectionRay @ 0x0054C740`
|
||||
|
||||
```text
|
||||
for each physics part which reaches the normal visible-mesh draw path:
|
||||
reject parts which do not belong to a server object
|
||||
transform the one world selection ray into this part's local coordinates
|
||||
divide by the part's GfxObj scale (do not change the ray parameter units)
|
||||
|
||||
sphere = gfxObj.drawing_sphere
|
||||
if sphere does not intersect the local ray:
|
||||
continue
|
||||
|
||||
if a polygon has already won globally and sphereDistance is farther:
|
||||
continue
|
||||
|
||||
if sphereDistance is nearer than the current sphere fallback:
|
||||
remember this object's id, part index, and sphereDistance
|
||||
|
||||
for polygons in the GfxObj's stored flat DAT order:
|
||||
if polygon_hits_ray(polygon, localRay, out t):
|
||||
remember only this FIRST polygon hit for this part
|
||||
if t is nearer than the current global polygon winner:
|
||||
remember this object's id, part index, and t
|
||||
break
|
||||
```
|
||||
|
||||
`CPolygon::polygon_hits_ray @ 0x005395E0`
|
||||
|
||||
```text
|
||||
if polygon.sides_type == ST_SINGLE (raw value 0)
|
||||
and dot(ray.direction, polygon.plane.normal) > 0:
|
||||
miss // back face
|
||||
|
||||
denominator = dot(ray.direction, polygon.plane.normal)
|
||||
if abs(denominator) < 0.0002:
|
||||
miss // parallel to the plane
|
||||
|
||||
t = plane/ray intersection time
|
||||
if t < 0:
|
||||
miss // behind the viewer
|
||||
|
||||
point = ray.origin + t * ray.direction
|
||||
normal = normalize(sum(cross(vertex[i] - vertex[0],
|
||||
vertex[i+1] - vertex[0])))
|
||||
for each ordered edge (previous -> current):
|
||||
inward = cross(normal, current - previous)
|
||||
if dot(point - previous, inward) < 0:
|
||||
miss
|
||||
return hit
|
||||
```
|
||||
|
||||
The broad-phase helper is retail `CSphere::sphere_intersects_ray @
|
||||
0x005377A0`: it rejects rays beginning in/on the sphere, requires squared ray
|
||||
direction length at least `0.0002`, solves the quadratic in double precision,
|
||||
and chooses the near root when non-negative or the far root otherwise.
|
||||
The ray origin is `Render::viewpoint` (the camera eye), not the unprojected
|
||||
near-plane point; `Render::pick_ray @ 0x0054B610` supplies only its direction.
|
||||
|
||||
`Render::GetMouseSelectionObjectID @ 0x0054C950`
|
||||
|
||||
```text
|
||||
if any polygon hit exists anywhere in the visible draw:
|
||||
return the closest polygon winner
|
||||
if any drawing-sphere hit exists:
|
||||
return the closest sphere fallback
|
||||
return no object
|
||||
```
|
||||
|
||||
Important consequences:
|
||||
|
||||
- The broad phase is each visible **GfxObj part's drawing sphere**, not a Setup
|
||||
selection sphere, physics collision shape, fixed creature hit box, or expanded
|
||||
screen rectangle.
|
||||
- A polygon hit on any visible part beats every sphere-only fallback.
|
||||
- Retail does not ray-test world-cell walls as a second, independent occluder.
|
||||
Occlusion is inherited from the same portal/viewcone draw traversal that decides
|
||||
which parts may call `GfxObjUnderSelectionRay`.
|
||||
- `BSPTREE::GetSphere @ 0x005397E0` returns the drawing BSP root sphere exactly;
|
||||
the DAT source is `GfxObj.DrawingBSP.Root.BoundingSphere`.
|
||||
|
||||
## Vivid target indicator
|
||||
|
||||
`VividTargetIndicator::SetSelected @ 0x004F5CE0`
|
||||
|
||||
```text
|
||||
if selected id is the player: clear it
|
||||
if object is player-owned or IN_CONTAINER: clear it
|
||||
publish the remaining id to SmartBox
|
||||
if enabled and an id remains:
|
||||
color = gmRadarUI::GetBlipColor(selected id)
|
||||
else:
|
||||
hide both indicator roots
|
||||
```
|
||||
|
||||
`VividTargetIndicator::Initialized @ 0x004F6C60`
|
||||
|
||||
```text
|
||||
onscreenRoot = child 0x10000038
|
||||
corners = children 0x10000039, 0x1000003A, 0x1000003B, 0x1000003C
|
||||
offscreenRoot = child 0x10000045
|
||||
```
|
||||
|
||||
The constructor resolves twelve source surfaces by calling `DBObj::GetByEnum`
|
||||
with RenderSurface DBO type `0x0C`, client-enum category `0x10000009`, and
|
||||
enum values `1..12`. Values `1..4` are the four on-screen corners.
|
||||
`CopyImage @ 0x004F5DD0`
|
||||
uses the active retail blit mode (`s_BlitMethod == 4`) to colorize the source
|
||||
art with the same color as the object's radar blip.
|
||||
|
||||
`VividTargetIndicator::OnDraw @ 0x004F62B0` (on-screen branch)
|
||||
|
||||
```text
|
||||
if disabled, no selected id, or selected part was not reported by this world draw:
|
||||
hide indicator
|
||||
else:
|
||||
colorize the four DAT corner images with the selected radar-blip color
|
||||
obtain the selected object's SmartBox screen rectangle
|
||||
use the actual DAT corner width and height
|
||||
place the corner images just outside the rectangle
|
||||
clamp the assembled indicator to an 8-pixel viewport margin
|
||||
show the on-screen root and hide the off-screen root
|
||||
```
|
||||
|
||||
There is no selected-world-mesh tint or luminosity mutation in
|
||||
`CPhysicsPart::Draw`, `ACCWeenieObject::SetSelected`, or the world mesh draw
|
||||
path. The visible notification is the colorized four-corner DAT indicator. A
|
||||
mesh shader tint would therefore be a new effect, not a retail port.
|
||||
|
|
@ -42,6 +42,7 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||
/// support. Required at startup — missing bindless throws
|
||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||
|
|
@ -828,11 +829,6 @@ public sealed class GameWindow : IDisposable
|
|||
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
|
||||
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
|
||||
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
|
||||
// B.7 (2026-05-15): Vivid Target Indicator — four corner triangles
|
||||
// around the selected entity, colour-coded by ItemType + PWD bits.
|
||||
// Lives alongside the debug panels; cheap to construct + ignore
|
||||
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
|
||||
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
|
||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
|
||||
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||
|
|
@ -1637,58 +1633,6 @@ public sealed class GameWindow : IDisposable
|
|||
_imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
|
||||
_panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
|
||||
|
||||
// B.7 Vivid Target Indicator — corner-triangle highlight
|
||||
// around the currently-selected entity. Delegates pull
|
||||
// live state from this GameWindow instance every frame:
|
||||
// - selected guid → shared SelectionState
|
||||
// - entity resolver → position from the visible world view +
|
||||
// itemType from ClientObjectTable (Objects) + last spawn
|
||||
// - camera → _cameraController.Active or (zero) when not
|
||||
// yet ready, in which case the panel bails on viewport==0.
|
||||
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
|
||||
selectedGuidProvider: () => _selection.SelectedObjectId,
|
||||
entityResolver: guid =>
|
||||
{
|
||||
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity))
|
||||
return null;
|
||||
uint rawItemType = (uint)LiveItemType(guid);
|
||||
uint pwdBits = 0;
|
||||
uint? useability = null;
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||||
useability = spawn.Useability;
|
||||
}
|
||||
// 2026-05-16 — retail-faithful path. Pass the
|
||||
// entity's Setup.SelectionSphere (scaled by entity
|
||||
// scale, rotated into world coords) through so
|
||||
// the panel projects the sphere as a screen
|
||||
// circle. Matches SmartBox::GetObjectBoundingBox
|
||||
// (decomp 0x00452e20). If the Setup didn't bake
|
||||
// a selection sphere (rare, zero-radius), the
|
||||
// panel falls back to per-type height heuristic.
|
||||
System.Numerics.Vector3? sphereCenter = null;
|
||||
float? sphereRadius = null;
|
||||
if (TryGetEntitySelectionSphere(guid, out var sCenter, out var sRadius))
|
||||
{
|
||||
sphereCenter = sCenter;
|
||||
sphereRadius = sRadius;
|
||||
}
|
||||
return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
|
||||
entity.Position, rawItemType, pwdBits, entity.Scale, useability,
|
||||
sphereCenter, sphereRadius);
|
||||
},
|
||||
cameraProvider: () =>
|
||||
{
|
||||
if (_cameraController is null || _window is null)
|
||||
return (System.Numerics.Matrix4x4.Identity,
|
||||
System.Numerics.Matrix4x4.Identity,
|
||||
System.Numerics.Vector2.Zero);
|
||||
var cam = _cameraController.Active;
|
||||
return (cam.View, cam.Projection,
|
||||
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
|
||||
});
|
||||
|
||||
// VitalsVM: GUID=0 at construction; set later at EnterWorld
|
||||
// (see the _playerServerGuid assignment path). Pre-login the
|
||||
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
|
||||
|
|
@ -2345,6 +2289,13 @@ public sealed class GameWindow : IDisposable
|
|||
() => 1.0,
|
||||
() => _settingsVm?.DisplayDraft.ShowFps
|
||||
?? _persistedDisplay.ShowFps),
|
||||
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
|
||||
_selection,
|
||||
() => _playerServerGuid,
|
||||
() => _persistedGameplay.VividTargetingIndicator,
|
||||
ResolveVividTargetInfo,
|
||||
guid => _retailSelectionScene?.WasVisible(guid) == true,
|
||||
GetSelectionCamera),
|
||||
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
|
||||
SpellBook,
|
||||
Objects,
|
||||
|
|
@ -2674,7 +2625,10 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||
_classificationCache, _translucencyFades);
|
||||
_classificationCache, _translucencyFades,
|
||||
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
|
||||
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
|
||||
_dats!, _datLock)));
|
||||
// A.5 T22.5: apply A2C gate from quality preset.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
|
|
@ -9676,12 +9630,14 @@ public sealed class GameWindow : IDisposable
|
|||
int visibleLandblocks = 0;
|
||||
int totalLandblocks = 0;
|
||||
|
||||
_retailSelectionScene?.BeginFrame();
|
||||
if (_cameraController is not null && !portalViewportVisible)
|
||||
{
|
||||
var activeCamera = _cameraController.Active;
|
||||
var camera = _teleportViewPlane.ApplyTo(activeCamera);
|
||||
var worldProjection = camera.Projection;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
|
||||
_retailSelectionScene?.SetViewFrustum(frustum);
|
||||
|
||||
// Extract camera world position from the inverse of the view
|
||||
// matrix — needed by the scene-lighting UBO (for fog distance)
|
||||
|
|
@ -10592,6 +10548,7 @@ public sealed class GameWindow : IDisposable
|
|||
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
|
||||
_particleVisibility.CompleteFrame();
|
||||
}
|
||||
_retailSelectionScene?.CompleteFrame();
|
||||
|
||||
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
||||
// CreatureMode portal-space viewport. The world block above is skipped
|
||||
|
|
@ -10705,11 +10662,6 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
_panelHost.RenderAll(ctx);
|
||||
// B.7 Vivid Target Indicator: draws corner triangles to the
|
||||
// ImGui background draw list so it appears behind any docked
|
||||
// panels but still over the 3D scene. Cheap when no
|
||||
// selection — internal early-return on null guid.
|
||||
_targetIndicator?.Render();
|
||||
using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
|
||||
{
|
||||
_imguiBootstrap.Render();
|
||||
|
|
@ -13143,63 +13095,27 @@ public sealed class GameWindow : IDisposable
|
|||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Shared world pick at the current cursor — the 2026-05-16
|
||||
/// retail-faithful screen-rect picker (hit area = the target
|
||||
/// indicator's rect via the shared ScreenProjection helper; the old
|
||||
/// per-type radius/offset heuristics are retired). Used by the click
|
||||
/// select path AND the target-mode cursor hover (retail
|
||||
/// SmartBox::get_found_object_id analogue). <paramref name="includeSelf"/>:
|
||||
/// item target-use may pick the LOCAL PLAYER (retail lets you kit-heal
|
||||
/// yourself by clicking your own toon); plain selection never does.
|
||||
/// Shared world pick at the current cursor. The renderer supplies the
|
||||
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
|
||||
/// retail's drawing-sphere broadphase followed by flat visual-polygon
|
||||
/// intersection. <paramref name="includeSelf"/> allows item target-use to
|
||||
/// pick the local player while plain selection excludes it.
|
||||
/// </summary>
|
||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
|
||||
|
||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
||||
{
|
||||
if (_cameraController is null || _window is null) return null;
|
||||
|
||||
var camera = _cameraController.Active;
|
||||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
|
||||
// cached EnvCell physics so the picker can occlude entities behind
|
||||
// walls. Snapshot is per-pick (one click / one hover frame),
|
||||
// iteration is bounded by the streaming radius (~80 cells at radius 4).
|
||||
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
|
||||
foreach (var cellId in _physicsDataCache.CellStructIds)
|
||||
{
|
||||
var cp = _physicsDataCache.GetCellStruct(cellId);
|
||||
if (cp is not null) loadedCellPhysics.Add(cp);
|
||||
}
|
||||
|
||||
return AcDream.Core.Selection.WorldPicker.Pick(
|
||||
mouseX: mouseX, mouseY: mouseY,
|
||||
view: camera.View, projection: camera.Projection,
|
||||
viewport: viewport,
|
||||
candidates: _visibleEntitiesByServerGuid.Values,
|
||||
skipServerGuid: includeSelf ? 0u : _playerServerGuid,
|
||||
// Resolver: Setup's SelectionSphere is the ONLY input. If the
|
||||
// entity's Setup didn't bake a SelectionSphere, return null —
|
||||
// the picker skips it, which matches retail behaviour
|
||||
// (Render::GfxObjUnderSelectionRay at 0x0054c740 skips
|
||||
// candidates with no drawing_sphere data). Earlier defensive
|
||||
// 1.5 m × scale synth was removed 2026-05-16 — it made
|
||||
// dat-incomplete entities click as phantom hitboxes the size
|
||||
// of an NPC, diverging from retail and masking real Setup-
|
||||
// loading bugs.
|
||||
sphereForEntity: e =>
|
||||
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
|
||||
? ((System.Numerics.Vector3, float)?)(c, r)
|
||||
: null,
|
||||
// Match the indicator's TriangleSize (8 px) so the click area
|
||||
// extends out to the bracket corners — what the user perceives
|
||||
// as "selectable extent."
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: loadedCellPhysics.Count > 0
|
||||
? (origin, direction) =>
|
||||
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
|
||||
: null);
|
||||
if (_retailSelectionScene is null || _window is null)
|
||||
return null;
|
||||
var camera = GetSelectionCamera();
|
||||
return _retailSelectionScene.Pick(
|
||||
mouseX,
|
||||
mouseY,
|
||||
camera.Viewport,
|
||||
camera.View,
|
||||
camera.Projection,
|
||||
includeSelf ? 0u : _playerServerGuid);
|
||||
}
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
|
|
@ -13729,6 +13645,35 @@ public sealed class GameWindow : IDisposable
|
|||
Objects.Get(_playerServerGuid),
|
||||
Objects.Get(guid));
|
||||
|
||||
private (System.Numerics.Matrix4x4 View,
|
||||
System.Numerics.Matrix4x4 Projection,
|
||||
System.Numerics.Vector2 Viewport) GetSelectionCamera()
|
||||
{
|
||||
if (_cameraController is null || _window is null)
|
||||
return (System.Numerics.Matrix4x4.Identity,
|
||||
System.Numerics.Matrix4x4.Identity,
|
||||
System.Numerics.Vector2.Zero);
|
||||
var camera = _teleportViewPlane.ApplyTo(_cameraController.Active);
|
||||
return (camera.View, camera.Projection,
|
||||
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
|
||||
}
|
||||
|
||||
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
|
||||
{
|
||||
if (!_visibleEntitiesByServerGuid.ContainsKey(guid)
|
||||
|| !TryGetEntitySelectionSphere(guid, out var center, out float radius))
|
||||
return null;
|
||||
|
||||
uint pwdBits = LastSpawns.TryGetValue(guid, out var spawn)
|
||||
? spawn.ObjectDescriptionFlags ?? 0u
|
||||
: 0u;
|
||||
return new AcDream.App.UI.Layout.VividTargetInfo(
|
||||
center,
|
||||
radius,
|
||||
(uint)LiveItemType(guid),
|
||||
pwdBits);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16 — retail-faithful port of
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow seam from the normal world draw traversal to retail mouse selection.
|
||||
/// Only entities which survive that traversal are published.
|
||||
/// </summary>
|
||||
internal interface IRetailSelectionRenderSink
|
||||
{
|
||||
void AddVisiblePart(
|
||||
WorldEntity entity,
|
||||
int partIndex,
|
||||
uint gfxObjId,
|
||||
Matrix4x4 partWorld);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
|
||||
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
|
||||
/// </summary>
|
||||
internal sealed class RetailSelectionGeometryCache
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly object _datLock;
|
||||
// Render-thread owned. Dictionary permits a cached null for a GfxObj which
|
||||
// legitimately has no drawing BSP; ConcurrentDictionary does not.
|
||||
private readonly Dictionary<uint, RetailSelectionMesh?> _cache = new();
|
||||
|
||||
public RetailSelectionGeometryCache(DatCollection dats, object datLock)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
}
|
||||
|
||||
public RetailSelectionMesh? Resolve(uint gfxObjId)
|
||||
{
|
||||
if (_cache.TryGetValue(gfxObjId, out var cached))
|
||||
return cached;
|
||||
RetailSelectionMesh? loaded = Load(gfxObjId);
|
||||
_cache[gfxObjId] = loaded;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private RetailSelectionMesh? Load(uint gfxObjId)
|
||||
{
|
||||
GfxObj? gfx;
|
||||
lock (_datLock)
|
||||
gfx = _dats.Get<GfxObj>(gfxObjId);
|
||||
|
||||
var root = gfx?.DrawingBSP?.Root;
|
||||
if (gfx is null || root is null || root.BoundingSphere.Radius <= 0f)
|
||||
return null;
|
||||
|
||||
var polygons = new List<RetailSelectionPolygon>(gfx.Polygons.Count);
|
||||
foreach (var entry in gfx.Polygons)
|
||||
{
|
||||
var source = entry.Value;
|
||||
if (source.VertexIds.Count < 3)
|
||||
continue;
|
||||
|
||||
var vertices = new Vector3[source.VertexIds.Count];
|
||||
bool valid = true;
|
||||
for (int i = 0; i < source.VertexIds.Count; i++)
|
||||
{
|
||||
if (!gfx.VertexArray.Vertices.TryGetValue((ushort)source.VertexIds[i], out var vertex))
|
||||
{
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
vertices[i] = vertex.Origin;
|
||||
}
|
||||
if (!valid)
|
||||
continue;
|
||||
|
||||
polygons.Add(new RetailSelectionPolygon(
|
||||
vertices,
|
||||
SingleSided: (int)source.SidesType == 0));
|
||||
}
|
||||
|
||||
return new RetailSelectionMesh(
|
||||
root.BoundingSphere.Origin,
|
||||
root.BoundingSphere.Radius,
|
||||
polygons);
|
||||
}
|
||||
}
|
||||
113
src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
Normal file
113
src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread owner of the last complete set of visible selectable parts.
|
||||
/// The renderer builds one frame while input queries the previously completed
|
||||
/// frame, avoiding partial visibility state during multi-slice portal drawing.
|
||||
/// </summary>
|
||||
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
|
||||
{
|
||||
private readonly RetailSelectionGeometryCache _geometry;
|
||||
private List<RetailSelectionPart> _building = new();
|
||||
private List<RetailSelectionPart> _published = new();
|
||||
private readonly HashSet<PartKey> _buildingKeys = new();
|
||||
private HashSet<uint> _buildingGuids = new();
|
||||
private HashSet<uint> _publishedGuids = new();
|
||||
private FrustumPlanes? _viewFrustum;
|
||||
|
||||
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
|
||||
|
||||
public RetailSelectionScene(RetailSelectionGeometryCache geometry)
|
||||
=> _geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
|
||||
|
||||
public void BeginFrame()
|
||||
{
|
||||
_building.Clear();
|
||||
_buildingKeys.Clear();
|
||||
_buildingGuids.Clear();
|
||||
_viewFrustum = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies retail DrawMesh's current view-cone gate. Animated entities
|
||||
/// deliberately bypass acdream's coarse entity-AABB CPU cull, so this
|
||||
/// per-part drawing sphere check is the load-bearing retail equivalent.
|
||||
/// </summary>
|
||||
public void SetViewFrustum(FrustumPlanes viewFrustum)
|
||||
=> _viewFrustum = viewFrustum;
|
||||
|
||||
public void AddVisiblePart(
|
||||
WorldEntity entity,
|
||||
int partIndex,
|
||||
uint gfxObjId,
|
||||
Matrix4x4 partWorld)
|
||||
{
|
||||
if (entity.ServerGuid == 0u)
|
||||
return;
|
||||
|
||||
if (!_buildingKeys.Add(new PartKey(entity.Id, partIndex, gfxObjId)))
|
||||
return;
|
||||
RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId);
|
||||
if (mesh is null)
|
||||
return;
|
||||
if (_viewFrustum is not { } frustum
|
||||
|| !DrawingSphereIntersectsFrustum(mesh, partWorld, frustum))
|
||||
return;
|
||||
|
||||
_building.Add(new RetailSelectionPart(
|
||||
entity.ServerGuid,
|
||||
partIndex,
|
||||
partWorld,
|
||||
mesh));
|
||||
_buildingGuids.Add(entity.ServerGuid);
|
||||
}
|
||||
|
||||
public void CompleteFrame()
|
||||
{
|
||||
(_published, _building) = (_building, _published);
|
||||
(_publishedGuids, _buildingGuids) = (_buildingGuids, _publishedGuids);
|
||||
}
|
||||
|
||||
public uint? Pick(
|
||||
float mouseX,
|
||||
float mouseY,
|
||||
Vector2 viewport,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection,
|
||||
uint skipServerGuid)
|
||||
{
|
||||
if (viewport.X <= 0f || viewport.Y <= 0f)
|
||||
return null;
|
||||
var ray = WorldPicker.BuildRay(
|
||||
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||
return RetailWorldPicker.Pick(
|
||||
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
|
||||
}
|
||||
|
||||
public bool WasVisible(uint serverGuid) => _publishedGuids.Contains(serverGuid);
|
||||
|
||||
internal static bool DrawingSphereIntersectsFrustum(
|
||||
RetailSelectionMesh mesh,
|
||||
Matrix4x4 localToWorld,
|
||||
FrustumPlanes frustum)
|
||||
{
|
||||
Vector3 center = Vector3.Transform(mesh.SphereCenter, localToWorld);
|
||||
float scaleX = new Vector3(localToWorld.M11, localToWorld.M12, localToWorld.M13).Length();
|
||||
float scaleY = new Vector3(localToWorld.M21, localToWorld.M22, localToWorld.M23).Length();
|
||||
float scaleZ = new Vector3(localToWorld.M31, localToWorld.M32, localToWorld.M33).Length();
|
||||
float radius = mesh.SphereRadius * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ));
|
||||
return TestPlane(frustum.Left, center, radius)
|
||||
&& TestPlane(frustum.Right, center, radius)
|
||||
&& TestPlane(frustum.Bottom, center, radius)
|
||||
&& TestPlane(frustum.Top, center, radius)
|
||||
&& TestPlane(frustum.Near, center, radius)
|
||||
&& TestPlane(frustum.Far, center, radius);
|
||||
}
|
||||
|
||||
private static bool TestPlane(Vector4 plane, Vector3 center, float radius)
|
||||
=> plane.X * center.X + plane.Y * center.Y + plane.Z * center.Z + plane.W >= -radius;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
204
src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs
Normal file
204
src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
public readonly record struct VividTargetInfo(
|
||||
Vector3 SelectionSphereCenter,
|
||||
float SelectionSphereRadius,
|
||||
uint ItemType,
|
||||
uint ObjectDescriptionFlags);
|
||||
|
||||
public sealed record VividTargetRuntimeBindings(
|
||||
SelectionState Selection,
|
||||
Func<uint> PlayerGuid,
|
||||
Func<bool> Enabled,
|
||||
Func<uint, VividTargetInfo?> ResolveTarget,
|
||||
Func<uint, bool> WasDrawn,
|
||||
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera);
|
||||
|
||||
/// <summary>
|
||||
/// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>.
|
||||
/// The four corner surfaces are client-enum category 0x10000009 values 1..4, colorized
|
||||
/// with the selected object's retail radar-blip color and placed around the
|
||||
/// Setup selection sphere's SmartBox screen rectangle.
|
||||
/// </summary>
|
||||
public sealed class VividTargetIndicatorController
|
||||
{
|
||||
private const uint ClientEnumCategory = 0x10000009u;
|
||||
private const uint FirstSourceImageEnum = 1u;
|
||||
private const float ViewportMargin = 8f;
|
||||
|
||||
private readonly UiPanel _root;
|
||||
private readonly UiTextureElement[] _corners;
|
||||
private readonly VividTargetRuntimeBindings _bindings;
|
||||
private readonly (float Width, float Height)[] _sizes;
|
||||
|
||||
private VividTargetIndicatorController(
|
||||
UiPanel root,
|
||||
UiTextureElement[] corners,
|
||||
(float Width, float Height)[] sizes,
|
||||
VividTargetRuntimeBindings bindings)
|
||||
{
|
||||
_root = root;
|
||||
_corners = corners;
|
||||
_sizes = sizes;
|
||||
_bindings = bindings;
|
||||
}
|
||||
|
||||
public static VividTargetIndicatorController? Mount(
|
||||
UiRoot host,
|
||||
RetailUiAssets assets,
|
||||
VividTargetRuntimeBindings bindings)
|
||||
{
|
||||
var root = new UiPanel
|
||||
{
|
||||
Name = "VividTargetIndicator",
|
||||
BackgroundColor = Vector4.Zero,
|
||||
BorderColor = Vector4.Zero,
|
||||
ClickThrough = true,
|
||||
Visible = false,
|
||||
ZOrder = -10_000,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
|
||||
var corners = new UiTextureElement[4];
|
||||
var sizes = new (float Width, float Height)[4];
|
||||
for (uint i = 0; i < 4; i++)
|
||||
{
|
||||
uint did;
|
||||
lock (assets.DatLock)
|
||||
did = RetailDataIdResolver.Resolve(
|
||||
assets.Dats,
|
||||
enumValue: FirstSourceImageEnum + i,
|
||||
enumCategory: ClientEnumCategory);
|
||||
if (did == 0u)
|
||||
return null;
|
||||
|
||||
var resolved = assets.ResolveSprite(did);
|
||||
if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0)
|
||||
return null;
|
||||
|
||||
sizes[i] = (resolved.Width, resolved.Height);
|
||||
corners[i] = new UiTextureElement
|
||||
{
|
||||
Name = $"VividTargetCorner{i + 1}",
|
||||
Texture = resolved.Texture,
|
||||
Width = resolved.Width,
|
||||
Height = resolved.Height,
|
||||
ClickThrough = true,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
root.AddChild(corners[i]);
|
||||
}
|
||||
|
||||
host.AddChild(root);
|
||||
return new VividTargetIndicatorController(root, corners, sizes, bindings);
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (!_bindings.Enabled()
|
||||
|| _bindings.Selection.SelectedObjectId is not uint guid
|
||||
|| guid == 0u
|
||||
|| guid == _bindings.PlayerGuid()
|
||||
|| !_bindings.WasDrawn(guid)
|
||||
|| _bindings.ResolveTarget(guid) is not VividTargetInfo target)
|
||||
{
|
||||
_root.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var camera = _bindings.Camera();
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
target.SelectionSphereCenter,
|
||||
target.SelectionSphereRadius,
|
||||
camera.View,
|
||||
camera.Projection,
|
||||
camera.Viewport,
|
||||
out Vector2 rectMin,
|
||||
out Vector2 rectMax,
|
||||
out _,
|
||||
minSidePixels: 0f))
|
||||
{
|
||||
_root.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
VividTargetLayout layout = ComputeLayout(
|
||||
rectMin, rectMax, camera.Viewport, _sizes);
|
||||
_root.Left = layout.RootPosition.X;
|
||||
_root.Top = layout.RootPosition.Y;
|
||||
_root.Width = layout.RootSize.X;
|
||||
_root.Height = layout.RootSize.Y;
|
||||
for (int i = 0; i < _corners.Length; i++)
|
||||
SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y);
|
||||
|
||||
RadarBlipColors.Rgba color = RadarBlipColors.For(
|
||||
target.ItemType, target.ObjectDescriptionFlags);
|
||||
var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha);
|
||||
for (int i = 0; i < _corners.Length; i++)
|
||||
_corners[i].Tint = tint;
|
||||
_root.Visible = true;
|
||||
}
|
||||
|
||||
private void SetCorner(int index, float left, float top)
|
||||
{
|
||||
_corners[index].Left = left;
|
||||
_corners[index].Top = top;
|
||||
}
|
||||
|
||||
internal static VividTargetLayout ComputeLayout(
|
||||
Vector2 rectMin,
|
||||
Vector2 rectMax,
|
||||
Vector2 viewport,
|
||||
IReadOnlyList<(float Width, float Height)> sizes)
|
||||
{
|
||||
if (sizes.Count != 4)
|
||||
throw new ArgumentException("Retail VividTargetIndicator has exactly four corners.", nameof(sizes));
|
||||
|
||||
// VividTargetIndicator::OnDraw @ 0x004F69C8..0x004F6A99 reads
|
||||
// corner[1]'s dimensions, expands the SmartBox rectangle by one
|
||||
// corner on the top/left, then clamps each rectangle edge separately.
|
||||
// Partially off-screen targets therefore shrink the assembled region;
|
||||
// retail does not translate the whole un-clipped box back on-screen.
|
||||
float cornerWidth = sizes[0].Width;
|
||||
float cornerHeight = sizes[0].Height;
|
||||
float outerLeft = rectMin.X - cornerWidth;
|
||||
float outerTop = rectMin.Y - cornerHeight;
|
||||
float outerRight = rectMax.X;
|
||||
float outerBottom = rectMax.Y;
|
||||
|
||||
if (outerLeft > outerRight) outerLeft = outerRight - 1f;
|
||||
if (outerTop > outerBottom) outerTop = outerBottom - 1f;
|
||||
outerLeft = MathF.Max(outerLeft, ViewportMargin);
|
||||
outerTop = MathF.Max(outerTop, ViewportMargin);
|
||||
outerRight = MathF.Max(outerRight, cornerWidth + ViewportMargin);
|
||||
outerBottom = MathF.Max(outerBottom, cornerHeight + ViewportMargin);
|
||||
|
||||
float maximumRight = viewport.X - cornerWidth - ViewportMargin;
|
||||
float maximumBottom = viewport.Y - cornerHeight - ViewportMargin;
|
||||
outerLeft = MathF.Min(outerLeft, maximumRight - cornerWidth);
|
||||
outerTop = MathF.Min(outerTop, maximumBottom - cornerHeight);
|
||||
outerRight = MathF.Min(outerRight, maximumRight);
|
||||
outerBottom = MathF.Min(outerBottom, maximumBottom);
|
||||
Vector2 rootSize = new(
|
||||
MathF.Max(1f, outerRight - outerLeft + cornerWidth),
|
||||
MathF.Max(1f, outerBottom - outerTop + cornerHeight));
|
||||
return new VividTargetLayout(
|
||||
new Vector2(outerLeft, outerTop),
|
||||
rootSize,
|
||||
[
|
||||
Vector2.Zero,
|
||||
new Vector2(rootSize.X - sizes[1].Width, 0f),
|
||||
new Vector2(rootSize.X - sizes[2].Width, rootSize.Y - sizes[2].Height),
|
||||
new Vector2(0f, rootSize.Y - sizes[3].Height),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct VividTargetLayout(
|
||||
Vector2 RootPosition,
|
||||
Vector2 RootSize,
|
||||
IReadOnlyList<Vector2> CornerPositions);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Ui;
|
||||
using ImGuiNET;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// B.7 (2026-05-15) — Vivid Target Indicator. Draws four small
|
||||
/// corner triangles around the currently-selected entity, colour-coded
|
||||
/// by entity type (NPCs yellow, items white-ish, PKs red, etc.).
|
||||
/// Retail-faithful equivalent of <c>VividTargetIndicator</c>
|
||||
/// (named decomp at <c>0x004d6165</c> / <c>0x004f5ce0</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// MVP scope: on-screen indicator only, drawn via ImGui's background
|
||||
/// draw list. Deferred to follow-ups: off-screen edge arrow, DAT-loaded
|
||||
/// triangle sprite, mesh-tint highlight, player-option toggle.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The panel pulls its inputs through delegates supplied by the host
|
||||
/// (<see cref="Rendering.GameWindow"/>) so it doesn't have to depend
|
||||
/// on internal state types:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>selectedGuidProvider</c> — Core <c>SelectionState</c>'s current guid.</item>
|
||||
/// <item><c>entityResolver</c> — returns
|
||||
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
|
||||
/// the entity is no longer in the world (despawned).</item>
|
||||
/// <item><c>cameraProvider</c> — host's active camera + viewport
|
||||
/// dimensions; called once per frame.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class TargetIndicatorPanel
|
||||
{
|
||||
/// <summary>
|
||||
/// What the panel needs to know about the selected entity per frame.
|
||||
/// <c>ItemType</c> + <c>ObjectDescriptionFlags</c> feed
|
||||
/// <see cref="RadarBlipColors.For"/> for colour selection.
|
||||
/// <c>Scale</c> multiplies the per-type base height in
|
||||
/// <see cref="EntityHeightFor"/> — a scaled-up sign or oversized NPC
|
||||
/// gets a proportionally bigger box. <c>Useability</c> (acclient.h:6478
|
||||
/// <c>ITEM_USEABLE</c> enum) discriminates real pickup items
|
||||
/// (USEABLE_REMOTE bit set, 0.8 m boxes) from same-ItemType-but-non-
|
||||
/// useable scenery like signs (USEABLE_UNDEF, 3 m boxes).
|
||||
/// </summary>
|
||||
public readonly record struct TargetInfo(
|
||||
Vector3 WorldPosition,
|
||||
uint ItemType,
|
||||
uint ObjectDescriptionFlags,
|
||||
float Scale,
|
||||
uint? Useability = null,
|
||||
// 2026-05-16: world-space SelectionSphere center + radius.
|
||||
// Comes from the Setup's baked selection_sphere (acclient.h
|
||||
// CSetup::selection_sphere) scaled by entity scale. When
|
||||
// populated, the panel projects the sphere as a screen circle
|
||||
// and uses that as the indicator rect — matches retail
|
||||
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20). When
|
||||
// null, the panel falls back to the per-type height heuristic.
|
||||
Vector3? WorldSphereCenter = null,
|
||||
float? WorldSphereRadius = null);
|
||||
|
||||
private readonly Func<uint?> _selectedGuidProvider;
|
||||
private readonly Func<uint, TargetInfo?> _entityResolver;
|
||||
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel size of each corner triangle's right-angle legs.
|
||||
/// Retail uses <c>UIRegion::GetWidth(m_rgOnScreenCorners.m_data[1])</c>
|
||||
/// of the triangle sprite (decomp <c>0x004f69c8</c>). The retail
|
||||
/// sprite is small — ~8 px legs. 14 was too chunky per user
|
||||
/// feedback on 2026-05-16; 8 matches the retail screenshot.
|
||||
/// </summary>
|
||||
public float TriangleSize { get; set; } = 8f;
|
||||
|
||||
/// <summary>
|
||||
/// World-space height of the indicator box for entities that don't
|
||||
/// have a more specific type tag. Items use a smaller value (see
|
||||
/// <see cref="EntityHeightFor"/>). 1.8 m matches a standing humanoid;
|
||||
/// short items still get a small box because the projection
|
||||
/// preserves apparent size.
|
||||
/// </summary>
|
||||
public float EntityHeight { get; set; } = 1.8f;
|
||||
|
||||
/// <summary>
|
||||
/// Defensive fallback height when the entity has no usable
|
||||
/// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection
|
||||
/// path active (since commit f4f4143), this fallback only fires
|
||||
/// for entities whose Setup didn't bake a selection sphere —
|
||||
/// rare in practice. The single 1.5 m × scale default is a sane
|
||||
/// midpoint; per-type branches were retired in the 2026-05-16
|
||||
/// Commit B because the sphere path is authoritative.
|
||||
/// </summary>
|
||||
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null)
|
||||
{
|
||||
if (scale <= 0f) scale = 1f;
|
||||
return 1.5f * scale;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Box width = <see cref="EntityHeight"/> projected height ×
|
||||
/// <see cref="WidthHeightRatio"/>. Retail's Vivid Target Indicator
|
||||
/// draws a square box — four corner triangles arranged in a square —
|
||||
/// so 1.0 = width matches height. The earlier 0.5 (humanoid-ish
|
||||
/// aspect) made the box uncomfortably narrow for non-humanoids.
|
||||
/// </summary>
|
||||
public float WidthHeightRatio { get; set; } = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Floor for the projected screen height (pixels). Prevents the
|
||||
/// indicator from collapsing to a point on far-away entities.
|
||||
/// </summary>
|
||||
public float MinScreenHeight { get; set; } = 16f;
|
||||
|
||||
public TargetIndicatorPanel(
|
||||
Func<uint?> selectedGuidProvider,
|
||||
Func<uint, TargetInfo?> entityResolver,
|
||||
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider)
|
||||
{
|
||||
_selectedGuidProvider = selectedGuidProvider;
|
||||
_entityResolver = entityResolver;
|
||||
_cameraProvider = cameraProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame render call. No-op if nothing is selected, the selected
|
||||
/// entity is gone, or the entity is off-screen / behind the camera.
|
||||
/// Draws to the ImGui background draw list so it appears behind
|
||||
/// other panels.
|
||||
/// </summary>
|
||||
public void Render()
|
||||
{
|
||||
if (_selectedGuidProvider() is not uint guid) return;
|
||||
if (_entityResolver(guid) is not TargetInfo info) return;
|
||||
|
||||
var (view, projection, viewport) = _cameraProvider();
|
||||
if (viewport.X <= 0 || viewport.Y <= 0) return;
|
||||
|
||||
var viewProj = view * projection;
|
||||
|
||||
Vector2 tl, tr, br, bl;
|
||||
|
||||
if (info.WorldSphereCenter is Vector3 sphereCenter
|
||||
&& info.WorldSphereRadius is float sphereRadius
|
||||
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
|
||||
sphereCenter, sphereRadius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out _,
|
||||
minSidePixels: 12f))
|
||||
{
|
||||
// 2026-05-16 — retail-faithful path per
|
||||
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20).
|
||||
// Retail uses CPhysicsObj::GetSelectionSphere (the Setup's
|
||||
// baked selection_sphere) and produces the screen rect
|
||||
// from that sphere's projection — NOT from a per-mesh AABB.
|
||||
//
|
||||
// Retail INFLATES the rect by one triangle width/height on
|
||||
// every side before drawing (decomp 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a world-space point to screen-space pixels. Returns
|
||||
/// <c>false</c> if the point is behind the camera or outside the
|
||||
/// expanded viewport (±20 % margin so a tall entity whose feet are
|
||||
/// just off the bottom of the screen still gets its head projected).
|
||||
/// </summary>
|
||||
private static bool TryProjectToScreen(
|
||||
Vector3 world,
|
||||
Matrix4x4 viewProj,
|
||||
Vector2 viewport,
|
||||
out Vector2 screen)
|
||||
{
|
||||
var clip = Vector4.Transform(new Vector4(world, 1f), viewProj);
|
||||
if (clip.W <= 0.001f)
|
||||
{
|
||||
screen = Vector2.Zero;
|
||||
return false;
|
||||
}
|
||||
float ndcX = clip.X / clip.W;
|
||||
float ndcY = clip.Y / clip.W;
|
||||
const float margin = 1.2f;
|
||||
if (ndcX < -margin || ndcX > margin || ndcY < -margin || ndcY > margin)
|
||||
{
|
||||
screen = Vector2.Zero;
|
||||
return false;
|
||||
}
|
||||
screen = new Vector2(
|
||||
(ndcX * 0.5f + 0.5f) * viewport.X,
|
||||
(1f - (ndcY * 0.5f + 0.5f)) * viewport.Y);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint MakeImGuiColor(RadarBlipColors.Rgba c)
|
||||
{
|
||||
// ImGui packed colour is 0xAABBGGRR (little-endian RGBA).
|
||||
return ((uint)c.A << 24) | ((uint)c.B << 16) | ((uint)c.G << 8) | c.R;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
|
||||
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
|
||||
/// (currently-loaded EnvCells with resolved polygon planes), returns
|
||||
/// the nearest world-space <c>t</c> along the ray that hits any cell
|
||||
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
|
||||
/// all cells.
|
||||
///
|
||||
/// <para>
|
||||
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
|
||||
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
|
||||
/// ray-triangle intersection; one test per triangle. Cells are
|
||||
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
|
||||
/// so the ray runs in cell-local space and the resolved-polygon
|
||||
/// vertices don't need re-transformation per query.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// No BSP traversal — iterates every polygon in every cell. Cell count
|
||||
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
|
||||
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
|
||||
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CellBspRayOccluder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the nearest positive <c>t</c> such that
|
||||
/// <c>origin + t * direction</c> intersects a polygon in any cell.
|
||||
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
|
||||
/// is intersected.
|
||||
/// </summary>
|
||||
/// <param name="direction">Need not be normalized; returned <c>t</c>
|
||||
/// scales with direction length the same as a parametric ray.</param>
|
||||
public static float NearestWallT(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
IEnumerable<CellPhysics> loadedCells)
|
||||
{
|
||||
if (loadedCells is null) return float.PositiveInfinity;
|
||||
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var cell in loadedCells)
|
||||
{
|
||||
if (cell?.Resolved is null) continue;
|
||||
|
||||
// Bring the ray into cell-local space ONCE per cell.
|
||||
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
|
||||
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
|
||||
|
||||
foreach (var (_, poly) in cell.Resolved)
|
||||
{
|
||||
// Triangulate the (possibly polygonal) face into a fan.
|
||||
int n = poly.NumPoints;
|
||||
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
|
||||
continue;
|
||||
|
||||
for (int i = 1; i < n - 1; i++)
|
||||
{
|
||||
if (TryRayTriangle(
|
||||
localOrigin, localDirection,
|
||||
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
|
||||
out var t)
|
||||
&& t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestT;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Möller-Trumbore ray-triangle intersection. Returns true with
|
||||
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
|
||||
/// at a positive distance.
|
||||
/// </summary>
|
||||
private static bool TryRayTriangle(
|
||||
Vector3 origin, Vector3 direction,
|
||||
Vector3 v0, Vector3 v1, Vector3 v2,
|
||||
out float t)
|
||||
{
|
||||
const float Epsilon = 1e-7f;
|
||||
|
||||
var edge1 = v1 - v0;
|
||||
var edge2 = v2 - v0;
|
||||
var pvec = Vector3.Cross(direction, edge2);
|
||||
float det = Vector3.Dot(edge1, pvec);
|
||||
|
||||
// No two-sided handling here — picker should be permissive so
|
||||
// a wall blocks regardless of which side the camera is on.
|
||||
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
|
||||
float invDet = 1f / det;
|
||||
|
||||
var tvec = origin - v0;
|
||||
float u = Vector3.Dot(tvec, pvec) * invDet;
|
||||
if (u < 0f || u > 1f) { t = 0f; return false; }
|
||||
|
||||
var qvec = Vector3.Cross(tvec, edge1);
|
||||
float v = Vector3.Dot(direction, qvec) * invDet;
|
||||
if (v < 0f || u + v > 1f) { t = 0f; return false; }
|
||||
|
||||
t = Vector3.Dot(edge2, qvec) * invDet;
|
||||
return t > Epsilon;
|
||||
}
|
||||
}
|
||||
32
src/AcDream.Core/Selection/RetailSelectionMesh.cs
Normal file
32
src/AcDream.Core/Selection/RetailSelectionMesh.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable CPU geometry used by retail's world-selection pass. One instance
|
||||
/// represents one GfxObj part: its exact drawing-BSP root sphere and its visual
|
||||
/// polygons in DAT order.
|
||||
/// </summary>
|
||||
public sealed record RetailSelectionMesh(
|
||||
Vector3 SphereCenter,
|
||||
float SphereRadius,
|
||||
IReadOnlyList<RetailSelectionPolygon> Polygons);
|
||||
|
||||
/// <summary>One visual polygon. Vertex order and one/two-sidedness are DAT-authored.</summary>
|
||||
public sealed record RetailSelectionPolygon(
|
||||
IReadOnlyList<Vector3> Vertices,
|
||||
bool SingleSided);
|
||||
|
||||
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
|
||||
public readonly record struct RetailSelectionPart(
|
||||
uint ServerGuid,
|
||||
int PartIndex,
|
||||
Matrix4x4 LocalToWorld,
|
||||
RetailSelectionMesh Mesh);
|
||||
|
||||
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
|
||||
public readonly record struct RetailSelectionHit(
|
||||
uint ServerGuid,
|
||||
int PartIndex,
|
||||
double Distance,
|
||||
bool PolygonHit);
|
||||
181
src/AcDream.Core/Selection/RetailWorldPicker.cs
Normal file
181
src/AcDream.Core/Selection/RetailWorldPicker.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of retail's render-coupled mouse selection accumulator.
|
||||
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> broad-phases each
|
||||
/// visible part against its drawing sphere, then scans visual polygons in DAT
|
||||
/// order. Any polygon hit globally outranks every sphere-only fallback.
|
||||
/// </summary>
|
||||
public static class RetailWorldPicker
|
||||
{
|
||||
private const double RetailRayEpsilon = 0.0002;
|
||||
|
||||
public static RetailSelectionHit? Pick(
|
||||
Vector3 worldOrigin,
|
||||
Vector3 worldDirection,
|
||||
IEnumerable<RetailSelectionPart> visibleParts,
|
||||
uint skipServerGuid = 0u)
|
||||
{
|
||||
if (worldDirection.LengthSquared() < 1e-10f)
|
||||
return null;
|
||||
|
||||
RetailSelectionHit? closestSphere = null;
|
||||
RetailSelectionHit? closestPolygon = null;
|
||||
|
||||
foreach (var part in visibleParts)
|
||||
{
|
||||
if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid)
|
||||
continue;
|
||||
if (part.Mesh.SphereRadius <= 0f
|
||||
|| !Matrix4x4.Invert(part.LocalToWorld, out var worldToLocal))
|
||||
continue;
|
||||
|
||||
// Keep direction unnormalised after the affine inverse. With row-vector
|
||||
// transforms this preserves the same ray parameter t in world metres even
|
||||
// when the part carries scale (retail divides by gfxobj_scale likewise).
|
||||
Vector3 localOrigin = Vector3.Transform(worldOrigin, worldToLocal);
|
||||
Vector3 localDirection = Vector3.TransformNormal(worldDirection, worldToLocal);
|
||||
|
||||
if (!TryIntersectSphere(
|
||||
localOrigin,
|
||||
localDirection,
|
||||
part.Mesh.SphereCenter,
|
||||
part.Mesh.SphereRadius,
|
||||
out double sphereT))
|
||||
continue;
|
||||
|
||||
// Retail skips a part whose broad sphere starts beyond an already-found
|
||||
// polygon, because that part cannot improve the global polygon winner.
|
||||
if (closestPolygon is { } polygonWinner && sphereT > polygonWinner.Distance)
|
||||
continue;
|
||||
|
||||
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
|
||||
closestSphere = new RetailSelectionHit(
|
||||
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
|
||||
|
||||
// Retail stops at the FIRST hit polygon in this part's stored flat order.
|
||||
foreach (var polygon in part.Mesh.Polygons)
|
||||
{
|
||||
if (!TryIntersectPolygon(localOrigin, localDirection, polygon, out double polygonT))
|
||||
continue;
|
||||
|
||||
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
|
||||
closestPolygon = new RetailSelectionHit(
|
||||
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return closestPolygon ?? closestSphere;
|
||||
}
|
||||
|
||||
internal static bool TryIntersectSphere(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
Vector3 center,
|
||||
float radius,
|
||||
out double distance)
|
||||
{
|
||||
// CSphere::sphere_intersects_ray @ 0x005377A0. Retail intentionally
|
||||
// declines a broad-phase hit when the ray begins in or on the sphere.
|
||||
// The render view-cone normally keeps selectable objects in front of
|
||||
// the camera, so the routine does not separately reject a negative t.
|
||||
distance = 0d;
|
||||
Vector3 offset = origin - center;
|
||||
double c = Vector3.Dot(offset, offset) - (double)radius * radius;
|
||||
if (c <= 0d)
|
||||
return false;
|
||||
|
||||
double a = Vector3.Dot(direction, direction);
|
||||
if (a < RetailRayEpsilon)
|
||||
return false;
|
||||
|
||||
double b = -Vector3.Dot(offset, direction);
|
||||
double discriminant = b * b - c * a;
|
||||
if (discriminant < 0d)
|
||||
return false;
|
||||
|
||||
double root = Math.Sqrt(discriminant);
|
||||
distance = b > root ? (b - root) / a : (b + root) / a;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryIntersectPolygon(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
RetailSelectionPolygon polygon,
|
||||
out double distance)
|
||||
{
|
||||
distance = 0d;
|
||||
if (polygon.Vertices.Count < 3
|
||||
|| !TryPlane(polygon.Vertices, out Vector3 normal, out float planeD))
|
||||
return false;
|
||||
|
||||
double denominator = Vector3.Dot(direction, normal);
|
||||
// CPolygon::polygon_hits_ray @ 0x005395E0: raw sides_type 0 is
|
||||
// single-sided and rejects a ray travelling with the positive normal.
|
||||
if (polygon.SingleSided && denominator > 0d)
|
||||
return false;
|
||||
if (Math.Abs(denominator) < RetailRayEpsilon)
|
||||
return false;
|
||||
|
||||
distance = -(Vector3.Dot(origin, normal) + planeD) / denominator;
|
||||
if (distance < 0d)
|
||||
return false;
|
||||
|
||||
Vector3 point = origin + direction * (float)distance;
|
||||
return PointInPolygon(point, polygon.Vertices, normal);
|
||||
}
|
||||
|
||||
private static bool TryPlane(
|
||||
IReadOnlyList<Vector3> vertices,
|
||||
out Vector3 normal,
|
||||
out float planeD)
|
||||
{
|
||||
// CPolygon::make_plane @ 0x005383D0 builds a triangle fan from
|
||||
// vertex zero, sums the fan normals, normalizes once, then chooses d
|
||||
// from the average signed distance of every vertex. DatReaderWriter
|
||||
// exposes vertices rather than retail's derived Plane, so reconstruct
|
||||
// that load-time result here.
|
||||
Vector3 first = vertices[0];
|
||||
Vector3 normalSum = Vector3.Zero;
|
||||
for (int i = 1; i + 1 < vertices.Count; i++)
|
||||
normalSum += Vector3.Cross(vertices[i] - first, vertices[i + 1] - first);
|
||||
|
||||
if (normalSum.LengthSquared() > 1e-12f)
|
||||
{
|
||||
normal = Vector3.Normalize(normalSum);
|
||||
double averageDot = 0d;
|
||||
foreach (Vector3 vertex in vertices)
|
||||
averageDot += Vector3.Dot(normal, vertex);
|
||||
planeD = (float)-(averageDot / vertices.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
normal = default;
|
||||
planeD = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool PointInPolygon(
|
||||
Vector3 point,
|
||||
IReadOnlyList<Vector3> vertices,
|
||||
Vector3 normal)
|
||||
{
|
||||
// CPolygon::point_in_polygon @ 0x00538D90. Retail visual polygons are
|
||||
// convex: the point must remain on the inward side of every ordered
|
||||
// edge. Zero is accepted, so a click exactly on an edge still hits.
|
||||
Vector3 previous = vertices[^1];
|
||||
for (int i = 0; i < vertices.Count; i++)
|
||||
{
|
||||
Vector3 current = vertices[i];
|
||||
Vector3 inward = Vector3.Cross(normal, current - previous);
|
||||
if (Vector3.Dot(point - previous, inward) < 0f)
|
||||
return false;
|
||||
previous = current;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,286 +1,54 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
|
||||
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
|
||||
/// </list>
|
||||
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
|
||||
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
|
||||
/// </summary>
|
||||
public static class WorldPicker
|
||||
{
|
||||
/// <summary>
|
||||
/// Unprojects a pixel coordinate to a world-space ray using the supplied
|
||||
/// view + projection matrices (System.Numerics row-vector convention,
|
||||
/// composed as view * projection — same as the rest of acdream's camera
|
||||
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
|
||||
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
|
||||
/// row-vector convention (<c>view * projection</c>).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// (origin = world point on the near plane, direction = normalized
|
||||
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
|
||||
/// if the view-projection composition is singular.
|
||||
/// The camera viewpoint and normalized direction, or two zero vectors
|
||||
/// when the camera transform is singular.
|
||||
/// </returns>
|
||||
public static (Vector3 Origin, Vector3 Direction) BuildRay(
|
||||
float mouseX, float mouseY,
|
||||
float viewportW, float viewportH,
|
||||
Matrix4x4 view, Matrix4x4 projection)
|
||||
float mouseX,
|
||||
float mouseY,
|
||||
float viewportW,
|
||||
float viewportH,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection)
|
||||
{
|
||||
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
|
||||
float ndcX = (2f * mouseX) / viewportW - 1f;
|
||||
float ndcY = 1f - (2f * mouseY) / viewportH;
|
||||
|
||||
var vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out var invVp))
|
||||
Matrix4x4 vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|
||||
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
|
||||
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
|
||||
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
|
||||
var n4 = Vector4.Transform(nearClip, invVp);
|
||||
var f4 = Vector4.Transform(farClip, invVp);
|
||||
if (n4.W == 0f || f4.W == 0f)
|
||||
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
|
||||
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
|
||||
Vector4 near = Vector4.Transform(nearClip, invVp);
|
||||
Vector4 far = Vector4.Transform(farClip, invVp);
|
||||
if (near.W == 0f || far.W == 0f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
|
||||
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
|
||||
var dir = farWorld - nearWorld;
|
||||
if (dir.LengthSquared() < 1e-10f)
|
||||
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
|
||||
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
|
||||
Vector3 direction = farWorld - nearWorld;
|
||||
if (direction.LengthSquared() < 1e-10f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
return (nearWorld, Vector3.Normalize(dir));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
|
||||
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
|
||||
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
|
||||
/// </summary>
|
||||
/// <param name="direction">
|
||||
/// World-space ray direction. <b>Must be normalized</b> — the geometric
|
||||
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
|
||||
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
|
||||
/// makes <c>maxDistance</c> compare against ray-parameter units instead
|
||||
/// of world meters.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
|
||||
/// statics) are skipped — they have no server-side identity and can't be
|
||||
/// the target of a Use packet. The player's own guid is skipped via
|
||||
/// <paramref name="skipServerGuid"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
|
||||
/// in practice this over-picked massively — any cursor anywhere near an
|
||||
/// NPC selected the NPC instead of a nearby item, and "click empty
|
||||
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
|
||||
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
|
||||
/// most items. A future refinement is per-itemType radius (smaller for
|
||||
/// tapers, bigger for shop chests) or priority sorting (items beat
|
||||
/// NPCs at equal hit-distance).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static uint? Pick(
|
||||
Vector3 origin, Vector3 direction,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
float maxDistance = 50f,
|
||||
Func<uint, float>? radiusForGuid = null,
|
||||
Func<uint, float>? verticalOffsetForGuid = null,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
const float DefaultRadius = 1.0f;
|
||||
const float DefaultVerticalOffset = 0.9f;
|
||||
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
|
||||
// a cell-BSP occluder, query the nearest wall hit along the ray
|
||||
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
|
||||
// and are skipped.
|
||||
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
// Per-entity radius + vertical offset (caller-supplied).
|
||||
//
|
||||
// <para>
|
||||
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
|
||||
// is at the entity's feet (Z=ground for a humanoid). User
|
||||
// clicks usually land on chest/head (Z ≈ 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.
|
||||
// </para>
|
||||
//
|
||||
// <para>
|
||||
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
|
||||
// accommodate the new vertical-offset sphere placement
|
||||
// (chest-height sphere centre + 1.0 m radius covers from
|
||||
// shin to top-of-head for a 1.8 m humanoid).
|
||||
// </para>
|
||||
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
|
||||
float r2 = r * r;
|
||||
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
|
||||
var sphereCenter = new Vector3(
|
||||
entity.Position.X,
|
||||
entity.Position.Y,
|
||||
entity.Position.Z + vz);
|
||||
|
||||
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
|
||||
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
|
||||
// the ray misses the sphere. Otherwise nearest intersection is
|
||||
// t = -b - sqrt(discriminant).
|
||||
var oc = origin - sphereCenter;
|
||||
float b = Vector3.Dot(oc, direction);
|
||||
float c = Vector3.Dot(oc, oc) - r2;
|
||||
float d = b * b - c;
|
||||
if (d < 0f) continue;
|
||||
|
||||
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
|
||||
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
|
||||
// to t_far so the entity is still pickable at point-blank range.
|
||||
float sqrtD = MathF.Sqrt(d);
|
||||
float t = -b - sqrtD;
|
||||
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||
if (t >= maxDistance) continue;
|
||||
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||
if (t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
|
||||
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
|
||||
/// projects to a screen-space rectangle through
|
||||
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
|
||||
/// rect is inflated by <paramref name="inflatePixels"/> on every side
|
||||
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
|
||||
/// hit-tested against the mouse pixel. Among rects that contain the
|
||||
/// mouse, the entity with the nearest camera-space depth wins.
|
||||
///
|
||||
/// <para>
|
||||
/// Why screen-space instead of world-space ray-sphere: the indicator
|
||||
/// draws a screen-space RECT. A world-space sphere projects to a
|
||||
/// screen CIRCLE inscribed in that rect — leaving the four rect
|
||||
/// corners as click dead zones. Per user feedback 2026-05-16, the
|
||||
/// click area must match the visible indicator extent exactly. By
|
||||
/// sharing the <see cref="ScreenProjection"/> helper with
|
||||
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
|
||||
/// cannot drift.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Resolver returning <c>null</c> skips the candidate (matches retail
|
||||
/// "no Setup → not pickable" behavior). Entities with
|
||||
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
|
||||
/// guid are also skipped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Stage B (polygon refine via
|
||||
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
|
||||
/// per issue #71 — only needed if visual testing surfaces a Stage A
|
||||
/// over-pick on entities whose visible mesh is well inside the
|
||||
/// indicator rect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="inflatePixels">Pixel inflate on each side of the
|
||||
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
|
||||
/// so the click area extends to where the visible bracket corners
|
||||
/// sit — the user perceives the inflated rect as the clickable area.</param>
|
||||
public static uint? Pick(
|
||||
float mouseX, float mouseY,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection,
|
||||
Vector2 viewport,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
float inflatePixels = 8f,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
uint? bestGuid = null;
|
||||
float bestDepth = float.PositiveInfinity;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
|
||||
// Build the click ray, query the nearest wall along it, convert
|
||||
// to the same camera-space depth metric (clip.W) that
|
||||
// ScreenProjection.TryProjectSphereToScreenRect returns per
|
||||
// candidate. Candidates with depth > wallDepth sit behind a wall.
|
||||
float wallDepth = float.PositiveInfinity;
|
||||
if (cellOccluder is not null)
|
||||
{
|
||||
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||
if (rayDir.LengthSquared() > 0f)
|
||||
{
|
||||
float wallT = cellOccluder(rayOrigin, rayDir);
|
||||
if (!float.IsPositiveInfinity(wallT))
|
||||
{
|
||||
var wallPoint = rayOrigin + rayDir * wallT;
|
||||
// ScreenProjection uses clip.W as its depth metric —
|
||||
// "camera-space depth" in the row-vector convention is
|
||||
// the W component of the homogeneous clip-space vector,
|
||||
// which equals the eye-space Z distance to the point.
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
|
||||
if (clip.W > 0f)
|
||||
wallDepth = clip.W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
var sphere = sphereForEntity(entity);
|
||||
if (sphere is null) continue;
|
||||
var (center, radius) = sphere.Value;
|
||||
if (radius <= 0f) continue;
|
||||
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
center, radius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out var depth))
|
||||
continue;
|
||||
|
||||
// Inflate by inflatePixels on each side — extend hit area to
|
||||
// where the indicator brackets sit.
|
||||
float minX = rMin.X - inflatePixels;
|
||||
float minY = rMin.Y - inflatePixels;
|
||||
float maxX = rMax.X + inflatePixels;
|
||||
float maxY = rMax.Y + inflatePixels;
|
||||
|
||||
if (mouseX < minX || mouseX > maxX) continue;
|
||||
if (mouseY < minY || mouseY > maxY) continue;
|
||||
|
||||
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||
|
||||
if (depth < bestDepth)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
|
||||
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
|
||||
// not the near-plane point. Inverse-view origin is that viewpoint in
|
||||
// System.Numerics' row-vector convention.
|
||||
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
|
||||
return (viewpoint, Vector3.Normalize(direction));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
117
tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs
Normal file
117
tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Textures;
|
||||
using AcDream.Core.Selection;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit.Sdk;
|
||||
using SysEnv = System.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailSelectionAssetTests
|
||||
{
|
||||
[Fact]
|
||||
public void InstalledDat_ResolvesAllFourVividTargetCorners()
|
||||
{
|
||||
string datDir = ResolveDatDir();
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var master = Assert.IsType<EnumIDMap>(
|
||||
dats.Get<EnumIDMap>((uint)dats.Portal.Header.MasterMapId));
|
||||
uint subMapDid = master.ClientEnumToID[0x10000009u];
|
||||
var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid));
|
||||
|
||||
for (uint index = 0; index < 4; index++)
|
||||
{
|
||||
uint enumValue = 1u + index;
|
||||
uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u);
|
||||
Assert.True(did != 0u,
|
||||
$"enum {enumValue}; submap 0x{subMapDid:X8} keys: {string.Join(',', subMap.ClientEnumToID.Keys.Select(k => $"0x{k:X8}"))}");
|
||||
Assert.True(dats.Portal.TryGet<RenderSurface>(did, out var surface)
|
||||
|| dats.HighRes.TryGet<RenderSurface>(did, out surface),
|
||||
$"enum 0x{enumValue:X8} resolved 0x{did:X8}");
|
||||
Assert.NotNull(surface);
|
||||
Assert.Equal(0x06000000u, did & 0xFF000000u);
|
||||
Assert.Equal((12, 12), (surface.Width, surface.Height));
|
||||
|
||||
Palette? palette = surface.DefaultPaletteId != 0
|
||||
? dats.Get<Palette>(surface.DefaultPaletteId)
|
||||
: null;
|
||||
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(surface, palette);
|
||||
int coloredPixels = 0;
|
||||
for (int pixel = 0; pixel < decoded.Rgba8.Length; pixel += 4)
|
||||
{
|
||||
if (decoded.Rgba8[pixel + 3] == 0)
|
||||
continue;
|
||||
if (decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 1]
|
||||
|| decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 2])
|
||||
coloredPixels++;
|
||||
}
|
||||
Assert.True(coloredPixels == 0,
|
||||
$"corner {index + 1} has {coloredPixels} non-grayscale source pixels");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoorSelectionGeometry_UsesDrawingBspRootSphereAndDatPolygonOrder()
|
||||
{
|
||||
const uint doorGfxObjId = 0x010044B5u;
|
||||
string datDir = ResolveDatDir();
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var gfx = Assert.IsType<GfxObj>(dats.Get<GfxObj>(doorGfxObjId));
|
||||
var cache = new RetailSelectionGeometryCache(dats, new object());
|
||||
|
||||
var selection = Assert.IsType<AcDream.Core.Selection.RetailSelectionMesh>(
|
||||
cache.Resolve(doorGfxObjId));
|
||||
|
||||
var root = Assert.IsType<DatReaderWriter.Types.DrawingBSPNode>(gfx.DrawingBSP.Root);
|
||||
Assert.Equal(root.BoundingSphere.Origin, selection.SphereCenter);
|
||||
Assert.Equal(root.BoundingSphere.Radius, selection.SphereRadius);
|
||||
Assert.Equal(gfx.Polygons.Count, selection.Polygons.Count);
|
||||
|
||||
var firstSource = gfx.Polygons.First().Value;
|
||||
var firstSelection = selection.Polygons[0];
|
||||
Assert.Equal((int)firstSource.SidesType == 0, firstSelection.SingleSided);
|
||||
Assert.Equal(firstSource.VertexIds.Count, firstSelection.Vertices.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawingSphereViewconeRejectsPartBehindCamera()
|
||||
{
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
||||
Vector3.Zero, -Vector3.UnitZ, Vector3.UnitY);
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f, 4f / 3f, 0.1f, 100f);
|
||||
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(view * projection);
|
||||
var mesh = new RetailSelectionMesh(Vector3.Zero, 1f, []);
|
||||
|
||||
Assert.True(RetailSelectionScene.DrawingSphereIntersectsFrustum(
|
||||
mesh, Matrix4x4.CreateTranslation(0f, 0f, -5f), frustum));
|
||||
Assert.False(RetailSelectionScene.DrawingSphereIntersectsFrustum(
|
||||
mesh, Matrix4x4.CreateTranslation(0f, 0f, 5f), frustum));
|
||||
}
|
||||
|
||||
private static string ResolveDatDir()
|
||||
{
|
||||
string? configured = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)
|
||||
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
|
||||
return configured;
|
||||
|
||||
const string installed = @"C:\Turbine\Asheron's Call";
|
||||
if (File.Exists(Path.Combine(installed, "client_portal.dat")))
|
||||
return installed;
|
||||
|
||||
string conventional = Path.Combine(
|
||||
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
if (File.Exists(Path.Combine(conventional, "client_portal.dat")))
|
||||
return conventional;
|
||||
|
||||
throw SkipException.ForSkip("Installed client_portal.dat is required.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using DatReaderWriter.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class CellBspRayOccluderTests
|
||||
{
|
||||
// Build a CellPhysics with a single triangular poly at world-Y=10.
|
||||
// Triangle vertices in local space, world transform = identity.
|
||||
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
|
||||
private static CellPhysics MakeWallCell()
|
||||
{
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, 10, 0),
|
||||
new Vector3( 5, 10, 0),
|
||||
new Vector3( 0, 10, 5),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||
NumPoints = 3,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
|
||||
{
|
||||
var cell = MakeWallCell();
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
|
||||
{
|
||||
var cell = MakeWallCell();
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = -Vector3.UnitY; // travels AWAY from the wall
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
|
||||
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
|
||||
{
|
||||
var origin = Vector3.Zero;
|
||||
var direction = Vector3.UnitY;
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
|
||||
Assert.True(float.IsPositiveInfinity(t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestWallT_TwoCells_ReturnsNearer()
|
||||
{
|
||||
var nearCell = MakeWallCell(); // wall at Y=10
|
||||
var farCell = MakeWallCell();
|
||||
// Move farCell's transform to push it to Y=20.
|
||||
farCell = new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = nearCell.Resolved,
|
||||
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
|
||||
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
|
||||
};
|
||||
|
||||
var origin = new Vector3(0, 0, 1);
|
||||
var direction = Vector3.UnitY;
|
||||
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
|
||||
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
|
||||
}
|
||||
}
|
||||
173
tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
Normal file
173
tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class RetailWorldPickerTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRayStartsAtCameraViewpointRatherThanNearPlane()
|
||||
{
|
||||
var eye = new Vector3(10f, 20f, 30f);
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(eye, eye - Vector3.UnitZ, Vector3.UnitY);
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
|
||||
|
||||
var ray = WorldPicker.BuildRay(400f, 300f, 800f, 600f, view, projection);
|
||||
|
||||
Assert.True(Vector3.Distance(eye, ray.Origin) < 0.0001f);
|
||||
Assert.True(Vector3.Distance(-Vector3.UnitZ, ray.Direction) < 0.0001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolygonHitGloballyBeatsNearerSphereFallback()
|
||||
{
|
||||
var sphereOnly = Part(1u, z: -3f, polygons: []);
|
||||
var polygon = Part(2u, z: -8f, polygons: [SquareAtLocalZ(0f)]);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [sphereOnly, polygon]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(2u, hit.Value.ServerGuid);
|
||||
Assert.True(hit.Value.PolygonHit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SphereIsUsedOnlyWhenNoVisiblePolygonHits()
|
||||
{
|
||||
var part = Part(0xABCDu, z: -5f, polygons: []);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [part]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(0xABCDu, hit.Value.ServerGuid);
|
||||
Assert.False(hit.Value.PolygonHit);
|
||||
Assert.Equal(3d, hit.Value.Distance, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopsAtFirstHitPolygonInDatOrderForEachPart()
|
||||
{
|
||||
var datOrdered = Part(
|
||||
10u,
|
||||
z: 0f,
|
||||
polygons: [SquareAtLocalZ(-10f), SquareAtLocalZ(-5f)],
|
||||
sphereCenter: new Vector3(0f, 0f, -7.5f),
|
||||
sphereRadius: 5f);
|
||||
var competitor = Part(20u, z: -7f, polygons: [SquareAtLocalZ(0f)]);
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [datOrdered, competitor]);
|
||||
|
||||
// Scanning all polygons would incorrectly give guid 10 at 5 m. Retail
|
||||
// accepts its first DAT-order hit at 10 m, so guid 20 wins at 7 m.
|
||||
Assert.NotNull(hit);
|
||||
Assert.Equal(20u, hit.Value.ServerGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleSidedPolygonRejectsItsBackFace()
|
||||
{
|
||||
var front = new RetailSelectionPart(
|
||||
1u,
|
||||
0,
|
||||
Matrix4x4.Identity,
|
||||
new RetailSelectionMesh(
|
||||
new Vector3(0f, 0f, -5f), 2f,
|
||||
[SquareAtLocalZ(-5f, singleSided: true)]));
|
||||
|
||||
RetailSelectionHit? backHit = RetailWorldPicker.Pick(
|
||||
new Vector3(0f, 0f, -10f), Vector3.UnitZ, [front]);
|
||||
|
||||
Assert.NotNull(backHit); // drawing sphere remains the retail fallback
|
||||
Assert.False(backHit.Value.PolygonHit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InverseScaledPartPreservesWorldDistance()
|
||||
{
|
||||
var transform = Matrix4x4.CreateScale(2f)
|
||||
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
|
||||
var part = new RetailSelectionPart(
|
||||
3u,
|
||||
0,
|
||||
transform,
|
||||
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
|
||||
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero, -Vector3.UnitZ, [part]);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.True(hit.Value.PolygonHit);
|
||||
Assert.Equal(10d, hit.Value.Distance, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipGuidAndNonServerPartsNeverCompete()
|
||||
{
|
||||
RetailSelectionHit? hit = RetailWorldPicker.Pick(
|
||||
Vector3.Zero,
|
||||
-Vector3.UnitZ,
|
||||
[Part(0u, -2f, [SquareAtLocalZ(0f)]), Part(7u, -4f, [SquareAtLocalZ(0f)])],
|
||||
skipServerGuid: 7u);
|
||||
|
||||
Assert.Null(hit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawingSphereRejectsRayWhichBeginsInsideLikeRetail()
|
||||
{
|
||||
bool hit = RetailWorldPicker.TryIntersectSphere(
|
||||
Vector3.Zero, Vector3.UnitX, Vector3.Zero, 2f, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvexEdgeTestRejectsAConcaveNotchRatherThanUsingEvenOddFill()
|
||||
{
|
||||
var concave = new RetailSelectionPolygon(
|
||||
[
|
||||
new(-2f, -2f, -5f),
|
||||
new( 2f, -2f, -5f),
|
||||
new( 2f, 2f, -5f),
|
||||
new( 0f, 0f, -5f),
|
||||
new(-2f, 2f, -5f),
|
||||
],
|
||||
SingleSided: false);
|
||||
|
||||
bool hit = RetailWorldPicker.TryIntersectPolygon(
|
||||
new Vector3(0f, 1f, 0f), -Vector3.UnitZ, concave, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
private static RetailSelectionPart Part(
|
||||
uint guid,
|
||||
float z,
|
||||
IReadOnlyList<RetailSelectionPolygon> polygons,
|
||||
Vector3? sphereCenter = null,
|
||||
float sphereRadius = 2f)
|
||||
=> new(
|
||||
guid,
|
||||
0,
|
||||
Matrix4x4.CreateTranslation(0f, 0f, z),
|
||||
new RetailSelectionMesh(
|
||||
sphereCenter ?? Vector3.Zero,
|
||||
sphereRadius,
|
||||
polygons));
|
||||
|
||||
private static RetailSelectionPolygon SquareAtLocalZ(
|
||||
float z,
|
||||
bool singleSided = false)
|
||||
=> new(
|
||||
[
|
||||
new Vector3(-1f, -1f, z),
|
||||
new Vector3( 1f, -1f, z),
|
||||
new Vector3( 1f, 1f, z),
|
||||
new Vector3(-1f, 1f, z),
|
||||
],
|
||||
singleSided);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class WorldPickerCellOcclusionTests
|
||||
{
|
||||
private static CellPhysics MakeWallAtY10()
|
||||
{
|
||||
// A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space
|
||||
// because WorldTransform = Identity). The occluder triangulates it as a fan:
|
||||
// tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10.
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, 10, -5),
|
||||
new Vector3( 5, 10, -5),
|
||||
new Vector3( 5, 10, 5),
|
||||
new Vector3(-5, 10, 5),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
|
||||
{
|
||||
Id = guid,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a quad wall at Z=-10 in front of the camera (identity view,
|
||||
/// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 —
|
||||
/// large enough to cover the center-pixel ray. An entity at Z=-20 sits
|
||||
/// behind it.
|
||||
///
|
||||
/// Wall normal direction doesn't affect Möller-Trumbore (the occluder
|
||||
/// is two-sided), but the Plane is stored for completeness. For a plane
|
||||
/// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0
|
||||
/// → D = 10.
|
||||
/// </summary>
|
||||
private static CellPhysics MakeWallAtZNeg10()
|
||||
{
|
||||
var verts = new[]
|
||||
{
|
||||
new Vector3(-5, -5, -10),
|
||||
new Vector3( 5, -5, -10),
|
||||
new Vector3( 5, 5, -10),
|
||||
new Vector3(-5, 5, -10),
|
||||
};
|
||||
var poly = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
return new CellPhysics
|
||||
{
|
||||
BSP = null,
|
||||
Resolved = new() { [0] = poly },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Screen-rect overload + cell-BSP occlusion
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Production path exercised by GameWindow.PickAndStoreSelection.
|
||||
/// Camera at origin looking down -Z (identity view). Entity at Z=-20
|
||||
/// projects to the center of the viewport. A wall at Z=-10 sits between
|
||||
/// camera and entity; with cellOccluder wired up the entity must be
|
||||
/// occluded → null result.
|
||||
///
|
||||
/// This test specifically covers the clip.W depth-conversion math in
|
||||
/// WorldPicker.Pick's screen-rect overload (issue #86).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp()
|
||||
{
|
||||
// Use the same camera convention as WorldPickerRectOverloadTests.StdCam():
|
||||
// identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300).
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800f, 600f);
|
||||
|
||||
var wall = MakeWallAtZNeg10();
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
|
||||
|
||||
// Entity is dead-ahead: center of viewport.
|
||||
var result = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: (origin, direction) =>
|
||||
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp,
|
||||
/// but with a null cellOccluder. Verifies that the no-occluder path still
|
||||
/// resolves the entity to a hit (the new parameter is a pure no-op when null).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pick_ScreenRect_NoWall_HitsEntity()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800f, 600f);
|
||||
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
|
||||
inflatePixels: 8f,
|
||||
cellOccluder: null);
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Ray-sphere overload (legacy path)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
|
||||
{
|
||||
var wall = MakeWallAtY10();
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitY,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
cellOccluder: (origin, direction) =>
|
||||
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RaySphere_NoWall_HitsEntity()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitY,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u,
|
||||
cellOccluder: null); // null occluder = no occlusion
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public sealed class WorldPickerRectOverloadTests
|
||||
{
|
||||
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
||||
var viewport = new Vector2(800, 600);
|
||||
return (view, proj, viewport);
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
||||
{
|
||||
Id = serverGuid == 0u ? 1u : serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 50f, mouseY: 50f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
|
||||
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { far, near } /* deliberately reversed */,
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10001u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_NullResolverSkipsCandidates()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e1 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e1, e2 },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => x.ServerGuid == 0x10001u
|
||||
? ((Vector3, float)?)null
|
||||
: ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_RespectsSkipServerGuid()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
|
||||
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
||||
uint? picked = WorldPicker.Pick(
|
||||
mouseX: 400f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { player, npc },
|
||||
skipServerGuid: 0x5000000Au,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Equal(0x10002u, picked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RectHitTest_InflateExpandsClickableArea()
|
||||
{
|
||||
var (view, proj, viewport) = StdCam();
|
||||
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
||||
|
||||
uint? withoutInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 0f);
|
||||
Assert.Null(withoutInflate);
|
||||
|
||||
uint? withInflate = WorldPicker.Pick(
|
||||
mouseX: 400f + 200f, mouseY: 300f,
|
||||
view, proj, viewport,
|
||||
new[] { e },
|
||||
skipServerGuid: 0u,
|
||||
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
||||
inflatePixels: 250f);
|
||||
Assert.Equal(0x10001u, withInflate);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Selection;
|
||||
|
||||
public class WorldPickerTests
|
||||
{
|
||||
private const float Epsilon = 0.01f;
|
||||
|
||||
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
|
||||
{
|
||||
var view = Matrix4x4.Identity;
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
fieldOfView: MathF.PI / 3f,
|
||||
aspectRatio: 16f / 9f,
|
||||
nearPlaneDistance: 0.1f,
|
||||
farPlaneDistance: 100f);
|
||||
return (view, proj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW / 2f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
// Right-handed perspective + identity view -> camera looks down -Z.
|
||||
// Center pixel ray = (0, 0, -1) within float epsilon.
|
||||
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
|
||||
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
|
||||
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
|
||||
{
|
||||
var (view, proj) = MakeIdentityCamera();
|
||||
const float vpW = 1920f, vpH = 1080f;
|
||||
|
||||
var (_, direction) = WorldPicker.BuildRay(
|
||||
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
|
||||
viewportW: vpW, viewportH: vpH,
|
||||
view, proj);
|
||||
|
||||
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
||||
{
|
||||
Id = serverGuid == 0u ? 1u : serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Pick_RayThroughEntity_ReturnsServerGuid()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RayMisses_ReturnsNull()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: Vector3.UnitX,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_TwoEntitiesInLine_ReturnsCloser()
|
||||
{
|
||||
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
|
||||
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { far, near }, // iteration order shouldn't matter
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Equal(0x1111u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SkipsSkipGuid()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0xABCDu);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_SkipsZeroServerGuid()
|
||||
{
|
||||
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
|
||||
// and aren't valid Use targets — server would reject guid=0.
|
||||
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0xDEADu);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_BeyondMaxDistance_ReturnsNull()
|
||||
{
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u); // default maxDistance = 50f
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid()
|
||||
{
|
||||
// Player ~3m from a door -> camera near-plane sits INSIDE the door's
|
||||
// 5m bounding sphere. Naive t_near < 0 guard would skip; correct
|
||||
// behavior is to fall through to t_far (the sphere exit point).
|
||||
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -3));
|
||||
|
||||
var result = WorldPicker.Pick(
|
||||
origin: Vector3.Zero,
|
||||
direction: -Vector3.UnitZ,
|
||||
candidates: new[] { entity },
|
||||
skipServerGuid: 0u);
|
||||
|
||||
Assert.Equal(0xABCDu, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue