feat(selection): port retail offscreen target indicator

This commit is contained in:
Erik 2026-07-18 07:41:12 +02:00
parent ea4f52ec51
commit ec1bb19609
9 changed files with 404 additions and 97 deletions

View file

@ -4143,13 +4143,15 @@ play, so this should be scheduled soon after A8 rather than left as polish.
**Resolution:** Deleted the procedural ImGui triangles and mounted a retained **Resolution:** Deleted the procedural ImGui triangles and mounted a retained
gameplay-UI `VividTargetIndicatorController`. It resolves retail client-enum gameplay-UI `VividTargetIndicatorController`. It resolves retail client-enum
category `0x10000009`, values `1..4`, to the four installed 12×12 RenderSurface category `0x10000009`, values `1..12`, to the four installed 12×12 corner
corners; colorizes their grayscale masks with `gmRadarUI::GetBlipColor` surfaces and eight 24×24 off-screen direction surfaces; colorizes their
equivalent colors; places them outside the exact Setup selection-sphere screen grayscale masks with `gmRadarUI::GetBlipColor` equivalent colors; places them
rectangle; and ports retail's separate-edge 8-pixel viewport clamp. The marker outside the exact Setup selection-sphere screen rectangle; and ports retail's
is available without devtools and follows only targets reported by the normal 8-pixel viewport clamp. The marker is available without devtools, remains
world draw. Installed-DAT and layout conformance tests pin the assets and visible through walls, has no marker-side distance cutoff, and uses the larger
placement. authored direction image when the live target leaves the viewport.
Installed-DAT and layout conformance tests pin the assets, direction sectors,
and placement.
**Description:** Per 2026-05-16 user feedback during the **Description:** Per 2026-05-16 user feedback during the
`SelectionSphere` indicator ship, the triangle apex direction `SelectionSphere` indicator ship, the triangle apex direction

View file

@ -524,7 +524,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — 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 — 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 — 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 + complete SmartBox feedback (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. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`. - **✓ SHIPPED — retail world selection + complete SmartBox feedback (2026-07-18).** 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..12` to the four installed 12×12 corner surfaces and eight 24×24 off-screen direction surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle, through-wall/no-distance SmartBox callback, direction sectors, and 8-pixel viewport-edge clamp. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. 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.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.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. - **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.

View file

@ -125,23 +125,70 @@ enum values `1..12`. Values `1..4` are the four on-screen corners.
uses the active retail blit mode (`s_BlitMethod == 4`) to colorize the source uses the active retail blit mode (`s_BlitMethod == 4`) to colorize the source
art with the same color as the object's radar blip. art with the same color as the object's radar blip.
`VividTargetIndicator::OnDraw @ 0x004F62B0` (on-screen branch) `SmartBox::GetObjectBoundingBox @ 0x00452E20`
```text ```text
if disabled, no selected id, or selected part was not reported by this world draw: resolve the selected id directly through CObjectMaint::GetObjectA
hide indicator if the object is absent:
return ObjectNotFound
reset to Render::set_default_view
push the object's position
obtain CPhysicsObj::GetSelectionSphere
if no sphere exists:
use an origin-centered sphere with radius 0.1 world units
if Render::viewconeCheck says the sphere intersects the full viewport cone:
project Render::GetViewerBBox's two corners
return ObjectOnscreen and the resulting screen rectangle
target = normalized selection-sphere center in viewer-local coordinates
if target is in front of the viewer:
angle = (450 - degrees(atan2(target.screenRight, target.screenUp))) mod 360
else: else:
colorize the four DAT corner images with the selected radar-blip color angle = (450 - degrees(atan2(target.screenRight, 0))) mod 360
obtain the selected object's SmartBox screen rectangle return ObjectOffscreen and angle
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
``` ```
The indicator is persistent selection presentation. Retail also has a separate, This path has no object-distance test and does not ask whether the selected
short-lived click confirmation owned by SmartBox; it is not implemented in object participated in the visible world draw. `set_default_view` also removes
`CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`. the per-portal view cone before the test. Consequently, a live selected object
keeps its four corners through walls and at any distance for which it remains in
the client object table.
`VividTargetIndicator::OnDraw @ 0x004F62B0`
```text
if disabled, display-off, or selected id is zero:
hide both roots
else if status is ObjectOnscreen:
colorize source images 1..4 with the selected radar-blip color
use the actual DAT corner width and height
place the corners just outside the SmartBox screen rectangle
clamp the assembled indicator to an 8-pixel viewport margin
show the on-screen root and hide the off-screen root
else if status is ObjectOffscreen:
choose one authored source image by angle:
[338,360) or [0,23) -> 6
[23,68) -> 7
[68,113) -> 9
[113,158) -> 12
[158,203) -> 11
[203,248) -> 10
[248,293) -> 8
[293,338) -> 5
colorize it with the selected radar-blip color
intersect the angle ray from viewport center with the screen edge
center the image on that intersection
clamp its top-left to the 8-pixel viewport margin
hide the on-screen root and show the off-screen image
```
Installed retail DAT verification pins images 1..4 at 12×12 pixels and images
5..12 at 24×24 pixels. The larger off-screen marker is authored art, not a
procedural arrow. The indicator is persistent selection presentation. Retail
also has a separate, short-lived click confirmation owned by SmartBox; it is not
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
## SmartBox click lighting pulse ## SmartBox click lighting pulse

View file

@ -2294,7 +2294,6 @@ public sealed class GameWindow : IDisposable
() => _playerServerGuid, () => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator, () => _persistedGameplay.VividTargetingIndicator,
ResolveVividTargetInfo, ResolveVividTargetInfo,
guid => _retailSelectionScene?.WasVisible(guid) == true,
GetSelectionCamera), GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings( Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook, SpellBook,
@ -13667,7 +13666,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
{ {
if (!_visibleEntitiesByServerGuid.ContainsKey(guid) if (!_entitiesByServerGuid.ContainsKey(guid)
|| !TryGetEntitySelectionSphere(guid, out var center, out float radius)) || !TryGetEntitySelectionSphere(guid, out var center, out float radius))
return null; return null;
@ -13715,17 +13714,25 @@ public sealed class GameWindow : IDisposable
worldCenter = default; worldCenter = default;
worldRadius = 0f; worldRadius = 0f;
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)) return false; if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
if (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
if (spawn.SetupTableId is not uint setupId) return false; // SmartBox::GetObjectBoundingBox @ 0x00452E20 installs a 0.1-unit
if (_dats is null) return false; // origin sphere when CPhysicsObj::GetSelectionSphere has no authored
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) return false; // Setup sphere. Preserve that presentation for every materialized
// object rather than dropping the marker entirely.
worldCenter = entity.Position;
worldRadius = 0.1f;
if (!LastSpawns.TryGetValue(guid, out var spawn)
|| spawn.SetupTableId is not uint setupId
|| _dats is null
|| !_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup))
return true;
// DAT Setup carries `SelectionSphere` (Origin + Radius). A zero // DAT Setup carries `SelectionSphere` (Origin + Radius). A zero
// radius means the Setup didn't bake one — fall back to the // radius means the Setup did not author one, so retain the retail
// caller's other path. // 0.1-unit fallback installed above.
var sel = setup.SelectionSphere; var sel = setup.SelectionSphere;
if (sel is null || sel.Radius <= 1e-4f) return false; if (sel is null || sel.Radius <= 1e-4f) return true;
// Retail GetSelectionSphere applies part-array scale to the // Retail GetSelectionSphere applies part-array scale to the
// sphere center (component-wise) and to the radius (Z-scale // sphere center (component-wise) and to the radius (Z-scale

View file

@ -16,8 +16,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
private List<RetailSelectionPart> _building = new(); private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new(); private List<RetailSelectionPart> _published = new();
private readonly HashSet<PartKey> _buildingKeys = new(); private readonly HashSet<PartKey> _buildingKeys = new();
private HashSet<uint> _buildingGuids = new();
private HashSet<uint> _publishedGuids = new();
private FrustumPlanes? _viewFrustum; private FrustumPlanes? _viewFrustum;
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId); private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
@ -47,7 +45,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
{ {
_building.Clear(); _building.Clear();
_buildingKeys.Clear(); _buildingKeys.Clear();
_buildingGuids.Clear();
_viewFrustum = null; _viewFrustum = null;
} }
@ -82,13 +79,11 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
partIndex, partIndex,
partWorld, partWorld,
mesh)); mesh));
_buildingGuids.Add(entity.ServerGuid);
} }
public void CompleteFrame() public void CompleteFrame()
{ {
(_published, _building) = (_building, _published); (_published, _building) = (_building, _published);
(_publishedGuids, _buildingGuids) = (_buildingGuids, _publishedGuids);
} }
public uint? Pick( public uint? Pick(
@ -107,8 +102,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid; ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
} }
public bool WasVisible(uint serverGuid) => _publishedGuids.Contains(serverGuid);
internal static bool DrawingSphereIntersectsFrustum( internal static bool DrawingSphereIntersectsFrustum(
RetailSelectionMesh mesh, RetailSelectionMesh mesh,
Matrix4x4 localToWorld, Matrix4x4 localToWorld,

View file

@ -15,35 +15,43 @@ public sealed record VividTargetRuntimeBindings(
Func<uint> PlayerGuid, Func<uint> PlayerGuid,
Func<bool> Enabled, Func<bool> Enabled,
Func<uint, VividTargetInfo?> ResolveTarget, Func<uint, VividTargetInfo?> ResolveTarget,
Func<uint, bool> WasDrawn,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera); Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera);
/// <summary> /// <summary>
/// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>. /// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>.
/// The four corner surfaces are client-enum category 0x10000009 values 1..4, colorized /// Client-enum category 0x10000009 values 1..4 are the four on-screen corners;
/// with the selected object's retail radar-blip color and placed around the /// values 5..12 are retail's 24x24 off-screen direction images. SmartBox computes
/// Setup selection sphere's SmartBox screen rectangle. /// this presentation from the live object's selection sphere, independently of
/// whether the world renderer drew or occluded the object.
/// </summary> /// </summary>
public sealed class VividTargetIndicatorController public sealed class VividTargetIndicatorController
{ {
private const uint ClientEnumCategory = 0x10000009u; private const uint ClientEnumCategory = 0x10000009u;
private const uint FirstSourceImageEnum = 1u; private const uint FirstSourceImageEnum = 1u;
private const int SourceImageCount = 12;
private const float ViewportMargin = 8f; private const float ViewportMargin = 8f;
private readonly UiPanel _root; private readonly UiPanel _onScreen;
private readonly UiTextureElement[] _corners; private readonly UiTextureElement[] _corners;
private readonly UiTextureElement _offScreen;
private readonly VividTargetRuntimeBindings _bindings; private readonly VividTargetRuntimeBindings _bindings;
private readonly (float Width, float Height)[] _sizes; private readonly VividTargetSource[] _sources;
private readonly (float Width, float Height)[] _cornerSizes;
private VividTargetIndicatorController( private VividTargetIndicatorController(
UiPanel root, UiPanel onScreen,
UiTextureElement[] corners, UiTextureElement[] corners,
(float Width, float Height)[] sizes, UiTextureElement offScreen,
VividTargetSource[] sources,
VividTargetRuntimeBindings bindings) VividTargetRuntimeBindings bindings)
{ {
_root = root; _onScreen = onScreen;
_corners = corners; _corners = corners;
_sizes = sizes; _offScreen = offScreen;
_sources = sources;
_cornerSizes = sources.Take(4)
.Select(source => (source.Width, source.Height))
.ToArray();
_bindings = bindings; _bindings = bindings;
} }
@ -52,9 +60,9 @@ public sealed class VividTargetIndicatorController
RetailUiAssets assets, RetailUiAssets assets,
VividTargetRuntimeBindings bindings) VividTargetRuntimeBindings bindings)
{ {
var root = new UiPanel var onScreen = new UiPanel
{ {
Name = "VividTargetIndicator", Name = "VividTargetIndicatorOnScreen",
BackgroundColor = Vector4.Zero, BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero, BorderColor = Vector4.Zero,
ClickThrough = true, ClickThrough = true,
@ -63,9 +71,8 @@ public sealed class VividTargetIndicatorController
Anchors = AnchorEdges.None, Anchors = AnchorEdges.None,
}; };
var corners = new UiTextureElement[4]; var sources = new VividTargetSource[SourceImageCount];
var sizes = new (float Width, float Height)[4]; for (uint i = 0; i < SourceImageCount; i++)
for (uint i = 0; i < 4; i++)
{ {
uint did; uint did;
lock (assets.DatLock) lock (assets.DatLock)
@ -79,22 +86,39 @@ public sealed class VividTargetIndicatorController
var resolved = assets.ResolveSprite(did); var resolved = assets.ResolveSprite(did);
if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0) if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0)
return null; return null;
sources[i] = new VividTargetSource(
resolved.Texture, resolved.Width, resolved.Height);
}
sizes[i] = (resolved.Width, resolved.Height); var corners = new UiTextureElement[4];
for (int i = 0; i < corners.Length; i++)
{
VividTargetSource source = sources[i];
corners[i] = new UiTextureElement corners[i] = new UiTextureElement
{ {
Name = $"VividTargetCorner{i + 1}", Name = $"VividTargetCorner{i + 1}",
Texture = resolved.Texture, Texture = source.Texture,
Width = resolved.Width, Width = source.Width,
Height = resolved.Height, Height = source.Height,
ClickThrough = true, ClickThrough = true,
Anchors = AnchorEdges.None, Anchors = AnchorEdges.None,
}; };
root.AddChild(corners[i]); onScreen.AddChild(corners[i]);
} }
host.AddChild(root); var offScreen = new UiTextureElement
return new VividTargetIndicatorController(root, corners, sizes, bindings); {
Name = "VividTargetIndicatorOffScreen",
Visible = false,
ClickThrough = true,
ZOrder = -10_000,
Anchors = AnchorEdges.None,
};
host.AddChild(onScreen);
host.AddChild(offScreen);
return new VividTargetIndicatorController(
onScreen, corners, offScreen, sources, bindings);
} }
public void Tick() public void Tick()
@ -103,44 +127,67 @@ public sealed class VividTargetIndicatorController
|| _bindings.Selection.SelectedObjectId is not uint guid || _bindings.Selection.SelectedObjectId is not uint guid
|| guid == 0u || guid == 0u
|| guid == _bindings.PlayerGuid() || guid == _bindings.PlayerGuid()
|| !_bindings.WasDrawn(guid)
|| _bindings.ResolveTarget(guid) is not VividTargetInfo target) || _bindings.ResolveTarget(guid) is not VividTargetInfo target)
{ {
_root.Visible = false; Hide();
return; return;
} }
var camera = _bindings.Camera(); var camera = _bindings.Camera();
if (!ScreenProjection.TryProjectSphereToScreenRect( VividTargetProjection projection = ComputeProjection(
target.SelectionSphereCenter, target.SelectionSphereCenter,
target.SelectionSphereRadius, target.SelectionSphereRadius,
camera.View, camera.View,
camera.Projection, camera.Projection,
camera.Viewport, camera.Viewport);
out Vector2 rectMin, if (projection.Status == VividTargetProjectionStatus.Invalid)
out Vector2 rectMax,
out _,
minSidePixels: 0f))
{ {
_root.Visible = false; Hide();
return; 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( RadarBlipColors.Rgba color = RadarBlipColors.For(
target.ItemType, target.ObjectDescriptionFlags); target.ItemType, target.ObjectDescriptionFlags);
var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha); var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha);
for (int i = 0; i < _corners.Length; i++) if (projection.Status == VividTargetProjectionStatus.OnScreen)
_corners[i].Tint = tint; {
_root.Visible = true; VividTargetLayout layout = ComputeLayout(
projection.RectMin, projection.RectMax, camera.Viewport, _cornerSizes);
_onScreen.Left = layout.RootPosition.X;
_onScreen.Top = layout.RootPosition.Y;
_onScreen.Width = layout.RootSize.X;
_onScreen.Height = layout.RootSize.Y;
for (int i = 0; i < _corners.Length; i++)
{
SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y);
_corners[i].Tint = tint;
}
_offScreen.Visible = false;
_onScreen.Visible = true;
return;
}
uint imageEnum = SelectOffScreenImageEnum(projection.AngleDegrees);
VividTargetSource offScreenSource = _sources[imageEnum - FirstSourceImageEnum];
Vector2 position = ComputeOffScreenPosition(
projection.AngleDegrees,
camera.Viewport,
new Vector2(offScreenSource.Width, offScreenSource.Height));
_offScreen.Texture = offScreenSource.Texture;
_offScreen.Left = position.X;
_offScreen.Top = position.Y;
_offScreen.Width = offScreenSource.Width;
_offScreen.Height = offScreenSource.Height;
_offScreen.Tint = tint;
_onScreen.Visible = false;
_offScreen.Visible = true;
}
private void Hide()
{
_onScreen.Visible = false;
_offScreen.Visible = false;
} }
private void SetCorner(int index, float left, float top) private void SetCorner(int index, float left, float top)
@ -149,6 +196,128 @@ public sealed class VividTargetIndicatorController
_corners[index].Top = top; _corners[index].Top = top;
} }
internal static VividTargetProjection ComputeProjection(
Vector3 worldCenter,
float worldRadius,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport)
{
if (viewport.X <= 0f || viewport.Y <= 0f
|| !float.IsFinite(worldRadius) || worldRadius < 0f)
return default;
bool projected = ScreenProjection.TryProjectSphereToScreenRect(
worldCenter,
worldRadius,
view,
projection,
viewport,
out Vector2 rectMin,
out Vector2 rectMax,
out _,
minSidePixels: 0f);
// SmartBox::GetObjectBoundingBox @ 0x00452E20 calls
// Render::set_default_view before viewconeCheck. The selected object is
// therefore tested against the full viewport, not the portal/occlusion
// traversal which happened to draw the world.
if (projected
&& rectMax.X >= 0f && rectMin.X <= viewport.X
&& rectMax.Y >= 0f && rectMin.Y <= viewport.Y)
{
return new VividTargetProjection(
VividTargetProjectionStatus.OnScreen,
rectMin,
rectMax,
0f);
}
Vector3 local = Vector3.Transform(worldCenter, view);
float length = local.Length();
float angle = 0f;
if (float.IsFinite(length) && length > 1e-6f)
{
// Retail viewer-local axes are x=screen-right, y=forward,
// z=screen-up. System.Numerics view space is x=right, y=up,
// -z=forward. When the target is behind the viewer, retail
// deliberately flattens forward/up to zero so the arrow chooses
// the nearest horizontal screen edge.
float right = local.X / length;
float forward = -local.Z / length;
float up = local.Y / length;
float radians = forward > 0f
? MathF.Atan2(right, up)
: MathF.Atan2(right, 0f);
angle = PositiveModulo(
450f - radians * (180f / MathF.PI),
360f);
}
return new VividTargetProjection(
VividTargetProjectionStatus.OffScreen,
default,
default,
angle);
}
internal static uint SelectOffScreenImageEnum(float angleDegrees)
{
float angle = PositiveModulo(angleDegrees, 360f);
// VividTargetIndicator::OnDraw @ 0x004F62B0 uses eight 45-degree
// sectors with integer boundaries centered on the cardinal axes.
if (angle >= 338f || angle < 23f) return 6u;
if (angle < 68f) return 7u;
if (angle < 113f) return 9u;
if (angle < 158f) return 12u;
if (angle < 203f) return 11u;
if (angle < 248f) return 10u;
if (angle < 293f) return 8u;
return 5u;
}
internal static Vector2 ComputeOffScreenPosition(
float angleDegrees,
Vector2 viewport,
Vector2 imageSize)
{
float angleRadians = PositiveModulo(angleDegrees, 360f) * (MathF.PI / 180f);
var direction = new Vector2(MathF.Cos(angleRadians), -MathF.Sin(angleRadians));
var halfImage = imageSize * 0.5f;
var center = viewport * 0.5f;
var minimumCenter = new Vector2(ViewportMargin) + halfImage;
var maximumCenter = viewport - new Vector2(ViewportMargin) - halfImage;
float horizontalDistance = direction.X switch
{
> 1e-6f => (maximumCenter.X - center.X) / direction.X,
< -1e-6f => (minimumCenter.X - center.X) / direction.X,
_ => float.PositiveInfinity,
};
float verticalDistance = direction.Y switch
{
> 1e-6f => (maximumCenter.Y - center.Y) / direction.Y,
< -1e-6f => (minimumCenter.Y - center.Y) / direction.Y,
_ => float.PositiveInfinity,
};
float distance = MathF.Min(horizontalDistance, verticalDistance);
if (!float.IsFinite(distance) || distance < 0f)
distance = 0f;
Vector2 position = center + direction * distance - halfImage;
return new Vector2(
Math.Clamp(position.X, ViewportMargin,
MathF.Max(ViewportMargin, viewport.X - imageSize.X - ViewportMargin)),
Math.Clamp(position.Y, ViewportMargin,
MathF.Max(ViewportMargin, viewport.Y - imageSize.Y - ViewportMargin)));
}
private static float PositiveModulo(float value, float modulus)
{
float result = value % modulus;
return result < 0f ? result + modulus : result;
}
internal static VividTargetLayout ComputeLayout( internal static VividTargetLayout ComputeLayout(
Vector2 rectMin, Vector2 rectMin,
Vector2 rectMax, Vector2 rectMax,
@ -202,3 +371,21 @@ internal readonly record struct VividTargetLayout(
Vector2 RootPosition, Vector2 RootPosition,
Vector2 RootSize, Vector2 RootSize,
IReadOnlyList<Vector2> CornerPositions); IReadOnlyList<Vector2> CornerPositions);
internal enum VividTargetProjectionStatus
{
Invalid,
OnScreen,
OffScreen,
}
internal readonly record struct VividTargetProjection(
VividTargetProjectionStatus Status,
Vector2 RectMin,
Vector2 RectMax,
float AngleDegrees);
internal readonly record struct VividTargetSource(
uint Texture,
float Width,
float Height);

View file

@ -3,10 +3,9 @@ using System.Numerics;
namespace AcDream.Core.Selection; namespace AcDream.Core.Selection;
/// <summary> /// <summary>
/// Shared screen-space projection math for the target indicator and the /// Screen-space selection-sphere projection used by SmartBox's vivid target
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/> /// indicator. World picking separately ports retail's visible GfxObj polygon
/// so the click hit-area is guaranteed to match the visible indicator /// ray tests; the persistent marker intentionally ignores world occlusion.
/// rect — "what you see is what you click".
/// ///
/// <para> /// <para>
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at /// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
@ -35,8 +34,7 @@ public static class ScreenProjection
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere /// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
/// center — use this for nearest-first sorting when multiple rects overlap.</param> /// center — use this for nearest-first sorting when multiple rects overlap.</param>
/// <param name="minSidePixels">Minimum side length of the rect. Distant /// <param name="minSidePixels">Minimum side length of the rect. Distant
/// entities clamp to this so they remain pickable / visible. 12 px /// entities may clamp to this for a stable presentation.</param>
/// matches the indicator's clamp floor.</param>
/// <returns> /// <returns>
/// <c>true</c> if the sphere is in front of the camera and the rect was /// <c>true</c> if the sphere is in front of the camera and the rect was
/// produced; <c>false</c> if the center is behind the camera /// produced; <c>false</c> if the center is behind the camera
@ -74,9 +72,7 @@ public static class ScreenProjection
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false; if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false; if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
// Floor at minSidePixels so distant entities still get a visible / // Optional presentation floor for very distant spheres.
// clickable rect. The picker must apply the same floor as the
// indicator or distant clicks won't match the visible bracket.
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f; if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius); rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);

View file

@ -15,7 +15,7 @@ namespace AcDream.App.Tests.Rendering;
public sealed class RetailSelectionAssetTests public sealed class RetailSelectionAssetTests
{ {
[Fact] [Fact]
public void InstalledDat_ResolvesAllFourVividTargetCorners() public void InstalledDat_ResolvesAllTwelveVividTargetImages()
{ {
string datDir = ResolveDatDir(); string datDir = ResolveDatDir();
using var dats = new DatCollection(datDir, DatAccessType.Read); using var dats = new DatCollection(datDir, DatAccessType.Read);
@ -24,7 +24,7 @@ public sealed class RetailSelectionAssetTests
uint subMapDid = master.ClientEnumToID[0x10000009u]; uint subMapDid = master.ClientEnumToID[0x10000009u];
var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid)); var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid));
for (uint index = 0; index < 4; index++) for (uint index = 0; index < 12; index++)
{ {
uint enumValue = 1u + index; uint enumValue = 1u + index;
uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u); uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u);
@ -35,7 +35,8 @@ public sealed class RetailSelectionAssetTests
$"enum 0x{enumValue:X8} resolved 0x{did:X8}"); $"enum 0x{enumValue:X8} resolved 0x{did:X8}");
Assert.NotNull(surface); Assert.NotNull(surface);
Assert.Equal(0x06000000u, did & 0xFF000000u); Assert.Equal(0x06000000u, did & 0xFF000000u);
Assert.Equal((12, 12), (surface.Width, surface.Height)); Assert.Equal(index < 4 ? (12, 12) : (24, 24),
(surface.Width, surface.Height));
Palette? palette = surface.DefaultPaletteId != 0 Palette? palette = surface.DefaultPaletteId != 0
? dats.Get<Palette>(surface.DefaultPaletteId) ? dats.Get<Palette>(surface.DefaultPaletteId)
@ -51,7 +52,7 @@ public sealed class RetailSelectionAssetTests
coloredPixels++; coloredPixels++;
} }
Assert.True(coloredPixels == 0, Assert.True(coloredPixels == 0,
$"corner {index + 1} has {coloredPixels} non-grayscale source pixels"); $"image {index + 1} has {coloredPixels} non-grayscale source pixels");
} }
} }

View file

@ -37,4 +37,78 @@ public sealed class VividTargetIndicatorLayoutTests
Assert.Equal(new Vector2(8f, 8f), layout.RootPosition); Assert.Equal(new Vector2(8f, 8f), layout.RootPosition);
Assert.Equal(new Vector2(66f, 87f), layout.RootSize); Assert.Equal(new Vector2(66f, 87f), layout.RootSize);
} }
[Fact]
public void ProjectionHasNoDistanceOrWorldDrawVisibilityGate()
{
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 2f, 4f / 3f, 0.1f, 100f);
VividTargetProjection result = VividTargetIndicatorController.ComputeProjection(
new Vector3(0f, 0f, -1_000_000f),
1f,
Matrix4x4.Identity,
projection,
new Vector2(800f, 600f));
Assert.Equal(VividTargetProjectionStatus.OnScreen, result.Status);
Assert.InRange((result.RectMin.X + result.RectMax.X) * 0.5f, 399.9f, 400.1f);
Assert.InRange((result.RectMin.Y + result.RectMax.Y) * 0.5f, 299.9f, 300.1f);
}
[Theory]
[InlineData(100f, 0f, -10f, 0f, 6u)]
[InlineData(0f, 100f, -10f, 90f, 9u)]
[InlineData(-100f, 0f, -10f, 180f, 11u)]
[InlineData(0f, -100f, -10f, 270f, 8u)]
public void OffScreenDirectionMatchesRetailViewerAxes(
float x, float y, float z, float expectedAngle, uint expectedImageEnum)
{
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 2f, 4f / 3f, 0.1f, 100f);
VividTargetProjection result = VividTargetIndicatorController.ComputeProjection(
new Vector3(x, y, z),
1f,
Matrix4x4.Identity,
projection,
new Vector2(800f, 600f));
Assert.Equal(VividTargetProjectionStatus.OffScreen, result.Status);
Assert.Equal(expectedAngle, result.AngleDegrees, precision: 3);
Assert.Equal(expectedImageEnum,
VividTargetIndicatorController.SelectOffScreenImageEnum(result.AngleDegrees));
}
[Theory]
[InlineData(0f, 768f, 288f)]
[InlineData(90f, 388f, 8f)]
[InlineData(180f, 8f, 288f)]
[InlineData(270f, 388f, 568f)]
public void OffScreenImageClampsToRetailEightPixelEdge(
float angle, float expectedLeft, float expectedTop)
{
Vector2 position = VividTargetIndicatorController.ComputeOffScreenPosition(
angle,
new Vector2(800f, 600f),
new Vector2(24f, 24f));
Assert.Equal(expectedLeft, position.X, precision: 3);
Assert.Equal(expectedTop, position.Y, precision: 3);
}
[Theory]
[InlineData(0f, 6u)]
[InlineData(22.999f, 6u)]
[InlineData(23f, 7u)]
[InlineData(68f, 9u)]
[InlineData(113f, 12u)]
[InlineData(158f, 11u)]
[InlineData(203f, 10u)]
[InlineData(248f, 8u)]
[InlineData(293f, 5u)]
[InlineData(338f, 6u)]
public void DirectionImageUsesRetailSectorBoundaries(float angle, uint expected)
=> Assert.Equal(expected,
VividTargetIndicatorController.SelectOffScreenImageEnum(angle));
} }