From ec1bb19609208c0f709b303d762bf0c7ba317526 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Jul 2026 07:41:12 +0200 Subject: [PATCH] feat(selection): port retail offscreen target indicator --- docs/ISSUES.md | 16 +- docs/plans/2026-04-11-roadmap.md | 2 +- ...07-17-retail-world-selection-pseudocode.md | 71 ++++- src/AcDream.App/Rendering/GameWindow.cs | 27 +- .../Selection/RetailSelectionScene.cs | 7 - .../Layout/VividTargetIndicatorController.cs | 281 +++++++++++++++--- .../Selection/ScreenProjection.cs | 14 +- .../Rendering/RetailSelectionAssetTests.cs | 9 +- .../Layout/VividTargetIndicatorLayoutTests.cs | 74 +++++ 9 files changed, 404 insertions(+), 97 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f676c7b0..b8fdfcb9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 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. +category `0x10000009`, values `1..12`, to the four installed 12×12 corner +surfaces and eight 24×24 off-screen direction surfaces; colorizes their +grayscale masks with `gmRadarUI::GetBlipColor` equivalent colors; places them +outside the exact Setup selection-sphere screen rectangle; and ports retail's +8-pixel viewport clamp. The marker is available without devtools, remains +visible through walls, has no marker-side distance cutoff, and uses the larger +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 `SelectionSphere` indicator ship, the triangle apex direction diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2f9c097f..79d5e49d 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -524,7 +524,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. -- **✓ SHIPPED — retail world selection + 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.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11. - **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut. diff --git a/docs/research/2026-07-17-retail-world-selection-pseudocode.md b/docs/research/2026-07-17-retail-world-selection-pseudocode.md index df15efb6..79aaa904 100644 --- a/docs/research/2026-07-17-retail-world-selection-pseudocode.md +++ b/docs/research/2026-07-17-retail-world-selection-pseudocode.md @@ -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 art with the same color as the object's radar blip. -`VividTargetIndicator::OnDraw @ 0x004F62B0` (on-screen branch) +`SmartBox::GetObjectBoundingBox @ 0x00452E20` ```text -if disabled, no selected id, or selected part was not reported by this world draw: - hide indicator +resolve the selected id directly through CObjectMaint::GetObjectA +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: - 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 + angle = (450 - degrees(atan2(target.screenRight, 0))) mod 360 +return ObjectOffscreen and angle ``` -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`. +This path has no object-distance test and does not ask whether the selected +object participated in the visible world draw. `set_default_view` also removes +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 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bb640f57..65eaa37e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2294,7 +2294,6 @@ public sealed class GameWindow : IDisposable () => _playerServerGuid, () => _persistedGameplay.VividTargetingIndicator, ResolveVividTargetInfo, - guid => _retailSelectionScene?.WasVisible(guid) == true, GetSelectionCamera), Indicators: new AcDream.App.UI.IndicatorRuntimeBindings( SpellBook, @@ -13667,7 +13666,7 @@ public sealed class GameWindow : IDisposable 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)) return null; @@ -13715,17 +13714,25 @@ public sealed class GameWindow : IDisposable worldCenter = default; worldRadius = 0f; - if (!_visibleEntitiesByServerGuid.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; - if (_dats is null) return false; - if (!_dats.TryGet(setupId, out var setup)) return false; + if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false; + + // SmartBox::GetObjectBoundingBox @ 0x00452E20 installs a 0.1-unit + // origin sphere when CPhysicsObj::GetSelectionSphere has no authored + // 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(setupId, out var setup)) + return true; // DAT Setup carries `SelectionSphere` (Origin + Radius). A zero - // radius means the Setup didn't bake one — fall back to the - // caller's other path. + // radius means the Setup did not author one, so retain the retail + // 0.1-unit fallback installed above. 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 // sphere center (component-wise) and to the radius (Z-scale diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 16ae5f87..8510be56 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -16,8 +16,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail private List _building = new(); private List _published = new(); private readonly HashSet _buildingKeys = new(); - private HashSet _buildingGuids = new(); - private HashSet _publishedGuids = new(); private FrustumPlanes? _viewFrustum; private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId); @@ -47,7 +45,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail { _building.Clear(); _buildingKeys.Clear(); - _buildingGuids.Clear(); _viewFrustum = null; } @@ -82,13 +79,11 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail partIndex, partWorld, mesh)); - _buildingGuids.Add(entity.ServerGuid); } public void CompleteFrame() { (_published, _building) = (_building, _published); - (_publishedGuids, _buildingGuids) = (_buildingGuids, _publishedGuids); } public uint? Pick( @@ -107,8 +102,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid; } - public bool WasVisible(uint serverGuid) => _publishedGuids.Contains(serverGuid); - internal static bool DrawingSphereIntersectsFrustum( RetailSelectionMesh mesh, Matrix4x4 localToWorld, diff --git a/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs index f1dcf438..6afc4a20 100644 --- a/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs +++ b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs @@ -15,35 +15,43 @@ public sealed record VividTargetRuntimeBindings( Func PlayerGuid, Func Enabled, Func ResolveTarget, - Func WasDrawn, Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera); /// /// Retained-UI port of VividTargetIndicator @ 0x004F5CE0..0x004F6DF6. -/// The four corner surfaces are client-enum category 0x10000009 values 1..4, colorized -/// with the selected object's retail radar-blip color and placed around the -/// Setup selection sphere's SmartBox screen rectangle. +/// Client-enum category 0x10000009 values 1..4 are the four on-screen corners; +/// values 5..12 are retail's 24x24 off-screen direction images. SmartBox computes +/// this presentation from the live object's selection sphere, independently of +/// whether the world renderer drew or occluded the object. /// public sealed class VividTargetIndicatorController { private const uint ClientEnumCategory = 0x10000009u; private const uint FirstSourceImageEnum = 1u; + private const int SourceImageCount = 12; private const float ViewportMargin = 8f; - private readonly UiPanel _root; + private readonly UiPanel _onScreen; private readonly UiTextureElement[] _corners; + private readonly UiTextureElement _offScreen; private readonly VividTargetRuntimeBindings _bindings; - private readonly (float Width, float Height)[] _sizes; + private readonly VividTargetSource[] _sources; + private readonly (float Width, float Height)[] _cornerSizes; private VividTargetIndicatorController( - UiPanel root, + UiPanel onScreen, UiTextureElement[] corners, - (float Width, float Height)[] sizes, + UiTextureElement offScreen, + VividTargetSource[] sources, VividTargetRuntimeBindings bindings) { - _root = root; + _onScreen = onScreen; _corners = corners; - _sizes = sizes; + _offScreen = offScreen; + _sources = sources; + _cornerSizes = sources.Take(4) + .Select(source => (source.Width, source.Height)) + .ToArray(); _bindings = bindings; } @@ -52,9 +60,9 @@ public sealed class VividTargetIndicatorController RetailUiAssets assets, VividTargetRuntimeBindings bindings) { - var root = new UiPanel + var onScreen = new UiPanel { - Name = "VividTargetIndicator", + Name = "VividTargetIndicatorOnScreen", BackgroundColor = Vector4.Zero, BorderColor = Vector4.Zero, ClickThrough = true, @@ -63,9 +71,8 @@ public sealed class VividTargetIndicatorController Anchors = AnchorEdges.None, }; - var corners = new UiTextureElement[4]; - var sizes = new (float Width, float Height)[4]; - for (uint i = 0; i < 4; i++) + var sources = new VividTargetSource[SourceImageCount]; + for (uint i = 0; i < SourceImageCount; i++) { uint did; lock (assets.DatLock) @@ -79,22 +86,39 @@ public sealed class VividTargetIndicatorController var resolved = assets.ResolveSprite(did); if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0) 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 { Name = $"VividTargetCorner{i + 1}", - Texture = resolved.Texture, - Width = resolved.Width, - Height = resolved.Height, + Texture = source.Texture, + Width = source.Width, + Height = source.Height, ClickThrough = true, Anchors = AnchorEdges.None, }; - root.AddChild(corners[i]); + onScreen.AddChild(corners[i]); } - host.AddChild(root); - return new VividTargetIndicatorController(root, corners, sizes, bindings); + var offScreen = new UiTextureElement + { + 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() @@ -103,44 +127,67 @@ public sealed class VividTargetIndicatorController || _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; + Hide(); 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)) + VividTargetProjection projection = ComputeProjection( + target.SelectionSphereCenter, + target.SelectionSphereRadius, + camera.View, + camera.Projection, + camera.Viewport); + if (projection.Status == VividTargetProjectionStatus.Invalid) { - _root.Visible = false; + Hide(); 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; + if (projection.Status == VividTargetProjectionStatus.OnScreen) + { + 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) @@ -149,6 +196,128 @@ public sealed class VividTargetIndicatorController _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( Vector2 rectMin, Vector2 rectMax, @@ -202,3 +371,21 @@ internal readonly record struct VividTargetLayout( Vector2 RootPosition, Vector2 RootSize, IReadOnlyList 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); diff --git a/src/AcDream.Core/Selection/ScreenProjection.cs b/src/AcDream.Core/Selection/ScreenProjection.cs index ec71ca6b..e50a4d97 100644 --- a/src/AcDream.Core/Selection/ScreenProjection.cs +++ b/src/AcDream.Core/Selection/ScreenProjection.cs @@ -3,10 +3,9 @@ using System.Numerics; namespace AcDream.Core.Selection; /// -/// Shared screen-space projection math for the target indicator and the -/// world picker. Both call into -/// so the click hit-area is guaranteed to match the visible indicator -/// rect — "what you see is what you click". +/// Screen-space selection-sphere projection used by SmartBox's vivid target +/// indicator. World picking separately ports retail's visible GfxObj polygon +/// ray tests; the persistent marker intentionally ignores world occlusion. /// /// /// Retail equivalent: SmartBox::GetObjectBoundingBox at @@ -35,8 +34,7 @@ public static class ScreenProjection /// Out: camera-space depth (clip.W) of the sphere /// center — use this for nearest-first sorting when multiple rects overlap. /// Minimum side length of the rect. Distant - /// entities clamp to this so they remain pickable / visible. 12 px - /// matches the indicator's clamp floor. + /// entities may clamp to this for a stable presentation. /// /// true if the sphere is in front of the camera and the rect was /// produced; false 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 (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false; - // Floor at minSidePixels so distant entities still get a visible / - // clickable rect. The picker must apply the same floor as the - // indicator or distant clicks won't match the visible bracket. + // Optional presentation floor for very distant spheres. if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f; rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius); diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs index c3744769..0c30d504 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs @@ -15,7 +15,7 @@ namespace AcDream.App.Tests.Rendering; public sealed class RetailSelectionAssetTests { [Fact] - public void InstalledDat_ResolvesAllFourVividTargetCorners() + public void InstalledDat_ResolvesAllTwelveVividTargetImages() { string datDir = ResolveDatDir(); using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -24,7 +24,7 @@ public sealed class RetailSelectionAssetTests uint subMapDid = master.ClientEnumToID[0x10000009u]; var subMap = Assert.IsType(dats.Get(subMapDid)); - for (uint index = 0; index < 4; index++) + for (uint index = 0; index < 12; index++) { uint enumValue = 1u + index; uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u); @@ -35,7 +35,8 @@ public sealed class RetailSelectionAssetTests $"enum 0x{enumValue:X8} resolved 0x{did:X8}"); Assert.NotNull(surface); 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 ? dats.Get(surface.DefaultPaletteId) @@ -51,7 +52,7 @@ public sealed class RetailSelectionAssetTests coloredPixels++; } Assert.True(coloredPixels == 0, - $"corner {index + 1} has {coloredPixels} non-grayscale source pixels"); + $"image {index + 1} has {coloredPixels} non-grayscale source pixels"); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs b/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs index 383bc7f0..d5f4486f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VividTargetIndicatorLayoutTests.cs @@ -37,4 +37,78 @@ public sealed class VividTargetIndicatorLayoutTests Assert.Equal(new Vector2(8f, 8f), layout.RootPosition); 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)); }