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;
}
}

View file

@ -1,114 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Selection;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
/// (currently-loaded EnvCells with resolved polygon planes), returns
/// the nearest world-space <c>t</c> along the ray that hits any cell
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
/// all cells.
///
/// <para>
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
/// ray-triangle intersection; one test per triangle. Cells are
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
/// so the ray runs in cell-local space and the resolved-polygon
/// vertices don't need re-transformation per query.
/// </para>
///
/// <para>
/// No BSP traversal — iterates every polygon in every cell. Cell count
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
/// </para>
/// </summary>
public static class CellBspRayOccluder
{
/// <summary>
/// Returns the nearest positive <c>t</c> such that
/// <c>origin + t * direction</c> intersects a polygon in any cell.
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
/// is intersected.
/// </summary>
/// <param name="direction">Need not be normalized; returned <c>t</c>
/// scales with direction length the same as a parametric ray.</param>
public static float NearestWallT(
Vector3 origin,
Vector3 direction,
IEnumerable<CellPhysics> loadedCells)
{
if (loadedCells is null) return float.PositiveInfinity;
float bestT = float.PositiveInfinity;
foreach (var cell in loadedCells)
{
if (cell?.Resolved is null) continue;
// Bring the ray into cell-local space ONCE per cell.
var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
foreach (var (_, poly) in cell.Resolved)
{
// Triangulate the (possibly polygonal) face into a fan.
int n = poly.NumPoints;
if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
continue;
for (int i = 1; i < n - 1; i++)
{
if (TryRayTriangle(
localOrigin, localDirection,
poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
out var t)
&& t < bestT)
{
bestT = t;
}
}
}
}
return bestT;
}
/// <summary>
/// Möller-Trumbore ray-triangle intersection. Returns true with
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
/// at a positive distance.
/// </summary>
private static bool TryRayTriangle(
Vector3 origin, Vector3 direction,
Vector3 v0, Vector3 v1, Vector3 v2,
out float t)
{
const float Epsilon = 1e-7f;
var edge1 = v1 - v0;
var edge2 = v2 - v0;
var pvec = Vector3.Cross(direction, edge2);
float det = Vector3.Dot(edge1, pvec);
// No two-sided handling here — picker should be permissive so
// a wall blocks regardless of which side the camera is on.
if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
float invDet = 1f / det;
var tvec = origin - v0;
float u = Vector3.Dot(tvec, pvec) * invDet;
if (u < 0f || u > 1f) { t = 0f; return false; }
var qvec = Vector3.Cross(tvec, edge1);
float v = Vector3.Dot(direction, qvec) * invDet;
if (v < 0f || u + v > 1f) { t = 0f; return false; }
t = Vector3.Dot(edge2, qvec) * invDet;
return t > Epsilon;
}
}

View file

@ -0,0 +1,32 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Immutable CPU geometry used by retail's world-selection pass. One instance
/// represents one GfxObj part: its exact drawing-BSP root sphere and its visual
/// polygons in DAT order.
/// </summary>
public sealed record RetailSelectionMesh(
Vector3 SphereCenter,
float SphereRadius,
IReadOnlyList<RetailSelectionPolygon> Polygons);
/// <summary>One visual polygon. Vertex order and one/two-sidedness are DAT-authored.</summary>
public sealed record RetailSelectionPolygon(
IReadOnlyList<Vector3> Vertices,
bool SingleSided);
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
public readonly record struct RetailSelectionPart(
uint ServerGuid,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
public readonly record struct RetailSelectionHit(
uint ServerGuid,
int PartIndex,
double Distance,
bool PolygonHit);

View file

@ -0,0 +1,181 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Pure port of retail's render-coupled mouse selection accumulator.
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> broad-phases each
/// visible part against its drawing sphere, then scans visual polygons in DAT
/// order. Any polygon hit globally outranks every sphere-only fallback.
/// </summary>
public static class RetailWorldPicker
{
private const double RetailRayEpsilon = 0.0002;
public static RetailSelectionHit? Pick(
Vector3 worldOrigin,
Vector3 worldDirection,
IEnumerable<RetailSelectionPart> visibleParts,
uint skipServerGuid = 0u)
{
if (worldDirection.LengthSquared() < 1e-10f)
return null;
RetailSelectionHit? closestSphere = null;
RetailSelectionHit? closestPolygon = null;
foreach (var part in visibleParts)
{
if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid)
continue;
if (part.Mesh.SphereRadius <= 0f
|| !Matrix4x4.Invert(part.LocalToWorld, out var worldToLocal))
continue;
// Keep direction unnormalised after the affine inverse. With row-vector
// transforms this preserves the same ray parameter t in world metres even
// when the part carries scale (retail divides by gfxobj_scale likewise).
Vector3 localOrigin = Vector3.Transform(worldOrigin, worldToLocal);
Vector3 localDirection = Vector3.TransformNormal(worldDirection, worldToLocal);
if (!TryIntersectSphere(
localOrigin,
localDirection,
part.Mesh.SphereCenter,
part.Mesh.SphereRadius,
out double sphereT))
continue;
// Retail skips a part whose broad sphere starts beyond an already-found
// polygon, because that part cannot improve the global polygon winner.
if (closestPolygon is { } polygonWinner && sphereT > polygonWinner.Distance)
continue;
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
closestSphere = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
// Retail stops at the FIRST hit polygon in this part's stored flat order.
foreach (var polygon in part.Mesh.Polygons)
{
if (!TryIntersectPolygon(localOrigin, localDirection, polygon, out double polygonT))
continue;
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
closestPolygon = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
break;
}
}
return closestPolygon ?? closestSphere;
}
internal static bool TryIntersectSphere(
Vector3 origin,
Vector3 direction,
Vector3 center,
float radius,
out double distance)
{
// CSphere::sphere_intersects_ray @ 0x005377A0. Retail intentionally
// declines a broad-phase hit when the ray begins in or on the sphere.
// The render view-cone normally keeps selectable objects in front of
// the camera, so the routine does not separately reject a negative t.
distance = 0d;
Vector3 offset = origin - center;
double c = Vector3.Dot(offset, offset) - (double)radius * radius;
if (c <= 0d)
return false;
double a = Vector3.Dot(direction, direction);
if (a < RetailRayEpsilon)
return false;
double b = -Vector3.Dot(offset, direction);
double discriminant = b * b - c * a;
if (discriminant < 0d)
return false;
double root = Math.Sqrt(discriminant);
distance = b > root ? (b - root) / a : (b + root) / a;
return true;
}
internal static bool TryIntersectPolygon(
Vector3 origin,
Vector3 direction,
RetailSelectionPolygon polygon,
out double distance)
{
distance = 0d;
if (polygon.Vertices.Count < 3
|| !TryPlane(polygon.Vertices, out Vector3 normal, out float planeD))
return false;
double denominator = Vector3.Dot(direction, normal);
// CPolygon::polygon_hits_ray @ 0x005395E0: raw sides_type 0 is
// single-sided and rejects a ray travelling with the positive normal.
if (polygon.SingleSided && denominator > 0d)
return false;
if (Math.Abs(denominator) < RetailRayEpsilon)
return false;
distance = -(Vector3.Dot(origin, normal) + planeD) / denominator;
if (distance < 0d)
return false;
Vector3 point = origin + direction * (float)distance;
return PointInPolygon(point, polygon.Vertices, normal);
}
private static bool TryPlane(
IReadOnlyList<Vector3> vertices,
out Vector3 normal,
out float planeD)
{
// CPolygon::make_plane @ 0x005383D0 builds a triangle fan from
// vertex zero, sums the fan normals, normalizes once, then chooses d
// from the average signed distance of every vertex. DatReaderWriter
// exposes vertices rather than retail's derived Plane, so reconstruct
// that load-time result here.
Vector3 first = vertices[0];
Vector3 normalSum = Vector3.Zero;
for (int i = 1; i + 1 < vertices.Count; i++)
normalSum += Vector3.Cross(vertices[i] - first, vertices[i + 1] - first);
if (normalSum.LengthSquared() > 1e-12f)
{
normal = Vector3.Normalize(normalSum);
double averageDot = 0d;
foreach (Vector3 vertex in vertices)
averageDot += Vector3.Dot(normal, vertex);
planeD = (float)-(averageDot / vertices.Count);
return true;
}
normal = default;
planeD = 0f;
return false;
}
private static bool PointInPolygon(
Vector3 point,
IReadOnlyList<Vector3> vertices,
Vector3 normal)
{
// CPolygon::point_in_polygon @ 0x00538D90. Retail visual polygons are
// convex: the point must remain on the inward side of every ordered
// edge. Zero is accepted, so a click exactly on an edge still hits.
Vector3 previous = vertices[^1];
for (int i = 0; i < vertices.Count; i++)
{
Vector3 current = vertices[i];
Vector3 inward = Vector3.Cross(normal, current - previous);
if (Vector3.Dot(point - previous, inward) < 0f)
return false;
previous = current;
}
return true;
}
}

View file

@ -1,286 +1,54 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.Core.Selection;
/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
/// </summary>
public static class WorldPicker
{
/// <summary>
/// Unprojects a pixel coordinate to a world-space ray using the supplied
/// view + projection matrices (System.Numerics row-vector convention,
/// composed as view * projection — same as the rest of acdream's camera
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
/// row-vector convention (<c>view * projection</c>).
/// </summary>
/// <returns>
/// (origin = world point on the near plane, direction = normalized
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
/// if the view-projection composition is singular.
/// The camera viewpoint and normalized direction, or two zero vectors
/// when the camera transform is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
float mouseX,
float mouseY,
float viewportW,
float viewportH,
Matrix4x4 view,
Matrix4x4 projection)
{
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
float ndcX = (2f * mouseX) / viewportW - 1f;
float ndcY = 1f - (2f * mouseY) / viewportH;
var vp = view * projection;
if (!Matrix4x4.Invert(vp, out var invVp))
Matrix4x4 vp = view * projection;
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
return (Vector3.Zero, Vector3.Zero);
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
var n4 = Vector4.Transform(nearClip, invVp);
var f4 = Vector4.Transform(farClip, invVp);
if (n4.W == 0f || f4.W == 0f)
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
Vector4 near = Vector4.Transform(nearClip, invVp);
Vector4 far = Vector4.Transform(farClip, invVp);
if (near.W == 0f || far.W == 0f)
return (Vector3.Zero, Vector3.Zero);
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
var dir = farWorld - nearWorld;
if (dir.LengthSquared() < 1e-10f)
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
Vector3 direction = farWorld - nearWorld;
if (direction.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <param name="direction">
/// World-space ray direction. <b>Must be normalized</b> — the geometric
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
/// makes <c>maxDistance</c> compare against ray-parameter units instead
/// of world meters.
/// </param>
/// <remarks>
/// <para>
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
/// statics) are skipped — they have no server-side identity and can't be
/// the target of a Use packet. The player's own guid is skipped via
/// <paramref name="skipServerGuid"/>.
/// </para>
/// <para>
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
/// in practice this over-picked massively — any cursor anywhere near an
/// NPC selected the NPC instead of a nearby item, and "click empty
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
/// most items. A future refinement is per-itemType radius (smaller for
/// tapers, bigger for shop chests) or priority sorting (items beat
/// NPCs at equal hit-distance).
/// </para>
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f,
Func<uint, float>? radiusForGuid = null,
Func<uint, float>? verticalOffsetForGuid = null,
Func<Vector3, Vector3, float>? cellOccluder = null)
{
const float DefaultRadius = 1.0f;
const float DefaultVerticalOffset = 0.9f;
if (direction.LengthSquared() < 1e-10f) return null;
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
// a cell-BSP occluder, query the nearest wall hit along the ray
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
// and are skipped.
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Per-entity radius + vertical offset (caller-supplied).
//
// <para>
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
// is at the entity's feet (Z=ground for a humanoid). User
// clicks usually land on chest/head (Z ≈ 11.8 m). With the
// sphere centred at feet, a chest click is 1.2 m of vertical
// distance from sphere centre — bigger than any reasonable
// body radius — so the ray misses. Lifting the sphere
// centre to mid-body fixes this: 0.9 m default for
// humanoids, smaller for items, larger for tall objects.
// </para>
//
// <para>
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
// accommodate the new vertical-offset sphere placement
// (chest-height sphere centre + 1.0 m radius covers from
// shin to top-of-head for a 1.8 m humanoid).
// </para>
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
float r2 = r * r;
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
var sphereCenter = new Vector3(
entity.Position.X,
entity.Position.Y,
entity.Position.Z + vz);
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
// the ray misses the sphere. Otherwise nearest intersection is
// t = -b - sqrt(discriminant).
var oc = origin - sphereCenter;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - r2;
float d = b * b - c;
if (d < 0f) continue;
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
// to t_far so the entity is still pickable at point-blank range.
float sqrtD = MathF.Sqrt(d);
float t = -b - sqrtD;
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
if (t >= maxDistance) continue;
if (t >= wallT) continue; // wall is between camera and entity (#86)
if (t < bestT)
{
bestT = t;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
/// <summary>
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
/// projects to a screen-space rectangle through
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
/// rect is inflated by <paramref name="inflatePixels"/> on every side
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
/// hit-tested against the mouse pixel. Among rects that contain the
/// mouse, the entity with the nearest camera-space depth wins.
///
/// <para>
/// Why screen-space instead of world-space ray-sphere: the indicator
/// draws a screen-space RECT. A world-space sphere projects to a
/// screen CIRCLE inscribed in that rect — leaving the four rect
/// corners as click dead zones. Per user feedback 2026-05-16, the
/// click area must match the visible indicator extent exactly. By
/// sharing the <see cref="ScreenProjection"/> helper with
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
/// cannot drift.
/// </para>
///
/// <para>
/// Resolver returning <c>null</c> skips the candidate (matches retail
/// "no Setup → not pickable" behavior). Entities with
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
/// guid are also skipped.
/// </para>
///
/// <para>
/// Stage A of the picker port. Stage B (polygon refine via
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
/// per issue #71 — only needed if visual testing surfaces a Stage A
/// over-pick on entities whose visible mesh is well inside the
/// indicator rect.
/// </para>
/// </summary>
/// <param name="inflatePixels">Pixel inflate on each side of the
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
/// so the click area extends to where the visible bracket corners
/// sit — the user perceives the inflated rect as the clickable area.</param>
public static uint? Pick(
float mouseX, float mouseY,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
float inflatePixels = 8f,
Func<Vector3, Vector3, float>? cellOccluder = null)
{
uint? bestGuid = null;
float bestDepth = float.PositiveInfinity;
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
// Build the click ray, query the nearest wall along it, convert
// to the same camera-space depth metric (clip.W) that
// ScreenProjection.TryProjectSphereToScreenRect returns per
// candidate. Candidates with depth > wallDepth sit behind a wall.
float wallDepth = float.PositiveInfinity;
if (cellOccluder is not null)
{
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
if (rayDir.LengthSquared() > 0f)
{
float wallT = cellOccluder(rayOrigin, rayDir);
if (!float.IsPositiveInfinity(wallT))
{
var wallPoint = rayOrigin + rayDir * wallT;
// ScreenProjection uses clip.W as its depth metric —
// "camera-space depth" in the row-vector convention is
// the W component of the homogeneous clip-space vector,
// which equals the eye-space Z distance to the point.
var viewProj = view * projection;
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
if (clip.W > 0f)
wallDepth = clip.W;
}
}
}
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
var sphere = sphereForEntity(entity);
if (sphere is null) continue;
var (center, radius) = sphere.Value;
if (radius <= 0f) continue;
if (!ScreenProjection.TryProjectSphereToScreenRect(
center, radius, view, projection, viewport,
out var rMin, out var rMax, out var depth))
continue;
// Inflate by inflatePixels on each side — extend hit area to
// where the indicator brackets sit.
float minX = rMin.X - inflatePixels;
float minY = rMin.Y - inflatePixels;
float maxX = rMax.X + inflatePixels;
float maxY = rMax.Y + inflatePixels;
if (mouseX < minX || mouseX > maxX) continue;
if (mouseY < minY || mouseY > maxY) continue;
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
if (depth < bestDepth)
{
bestDepth = depth;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
// not the near-plane point. Inverse-view origin is that viewpoint in
// System.Numerics' row-vector convention.
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
return (viewpoint, Vector3.Normalize(direction));
}
}

View file

@ -52,7 +52,7 @@ public sealed record GameplaySettings(
ToggleRun: true,
AdvancedCombatUI: false,
ShowTooltips: true,
VividTargetingIndicator: false,
VividTargetingIndicator: true,
SideBySideVitals: false,
// Retail default character-options mask 0x50C4A54A includes
// CoordinatesOnRadar (0x00400000).