feat(selection): port retail polygon picking and vivid marker

Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks.

Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp.

Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 21:32:51 +02:00
parent 0f82a08f0a
commit 146a963aeb
26 changed files with 1302 additions and 1340 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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