diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index bc81639..c5a4b5c 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -565,6 +565,11 @@ 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;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
@@ -1150,6 +1155,41 @@ 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 → _selectedGuid (set by PickAndStoreSelection)
+ // - entity resolver → position from _entitiesByServerGuid +
+ // itemType / PWD bits from cached LiveEntityInfo + 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: () => _selectedGuid,
+ entityResolver: guid =>
+ {
+ if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
+ return null;
+ uint rawItemType = 0;
+ if (_liveEntityInfoByGuid.TryGetValue(guid, out var info))
+ rawItemType = (uint)info.ItemType;
+ uint pwdBits = 0;
+ if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)
+ && spawn.ObjectDescriptionFlags is { } odf)
+ pwdBits = odf;
+ return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
+ entity.Position, rawItemType, pwdBits);
+ },
+ 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
@@ -7043,6 +7083,11 @@ 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();
_imguiBootstrap.Render();
}
diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs
new file mode 100644
index 0000000..25a74ed
--- /dev/null
+++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Ui;
+using ImGuiNET;
+
+namespace AcDream.App.UI;
+
+///
+/// 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 VividTargetIndicator
+/// (named decomp at 0x004d6165 / 0x004f5ce0).
+///
+///
+/// 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.
+///
+///
+///
+/// The panel pulls its inputs through delegates supplied by the host
+/// () so it doesn't have to depend
+/// on internal state types:
+///
+///
+/// - selectedGuidProvider — host's current _selectedGuid.
+/// - entityResolver — returns
+/// for a given guid, or null if
+/// the entity is no longer in the world (despawned).
+/// - cameraProvider — host's active camera + viewport
+/// dimensions; called once per frame.
+///
+///
+public sealed class TargetIndicatorPanel
+{
+ ///
+ /// What the panel needs to know about the selected entity per frame.
+ /// ItemType + ObjectDescriptionFlags feed
+ /// for colour selection.
+ ///
+ public readonly record struct TargetInfo(
+ Vector3 WorldPosition,
+ uint ItemType,
+ uint ObjectDescriptionFlags);
+
+ private readonly Func _selectedGuidProvider;
+ private readonly Func _entityResolver;
+ private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider;
+
+ ///
+ /// Pixel half-extent of the bounding square. Triangles sit at the
+ /// four corners of a 2 × HalfExtent square centred on the entity.
+ /// Future: scale by apparent radius (project entity bounds + read
+ /// pixel size) instead of a fixed value.
+ ///
+ public float HalfExtent { get; set; } = 24f;
+
+ ///
+ /// Pixel size of each corner triangle's right-angle legs.
+ ///
+ public float TriangleSize { get; set; } = 8f;
+
+ ///
+ /// World-space vertical offset to place the indicator box around
+ /// the entity's torso/body rather than its feet. 0.9 m matches
+ /// rough humanoid mid-body height.
+ ///
+ public float WorldVerticalOffset { get; set; } = 0.9f;
+
+ public TargetIndicatorPanel(
+ Func selectedGuidProvider,
+ Func entityResolver,
+ Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider)
+ {
+ _selectedGuidProvider = selectedGuidProvider;
+ _entityResolver = entityResolver;
+ _cameraProvider = cameraProvider;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+
+ // Project world position (lifted to mid-body height) to clip space.
+ var worldPos = new Vector4(
+ info.WorldPosition.X,
+ info.WorldPosition.Y,
+ info.WorldPosition.Z + WorldVerticalOffset,
+ 1f);
+ var clip = Vector4.Transform(worldPos, view * projection);
+ if (clip.W <= 0.001f) return; // behind / on the near plane
+
+ // NDC and viewport conversion.
+ float ndcX = clip.X / clip.W;
+ float ndcY = clip.Y / clip.W;
+ if (ndcX < -1f || ndcX > 1f || ndcY < -1f || ndcY > 1f)
+ return; // off-screen — MVP doesn't render the edge arrow.
+
+ // GL NDC Y points UP; screen Y points DOWN. Flip Y.
+ float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
+ float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
+
+ var rgba = RadarBlipColors.For(info.ItemType, info.ObjectDescriptionFlags);
+ uint col = MakeImGuiColor(rgba);
+
+ var drawList = ImGui.GetBackgroundDrawList();
+
+ // Four corners of the bounding square.
+ Vector2 tl = new(screenX - HalfExtent, screenY - HalfExtent);
+ Vector2 tr = new(screenX + HalfExtent, screenY - HalfExtent);
+ Vector2 br = new(screenX + HalfExtent, screenY + HalfExtent);
+ Vector2 bl = new(screenX - HalfExtent, screenY + HalfExtent);
+
+ float t = TriangleSize;
+
+ // Each corner triangle: right-angle at the corner itself, legs
+ // pointing INTO the bounding square (toward the entity). Mirrors
+ // retail's selection bracket pose where the triangle "hugs"
+ // the corner of the rectangle.
+ drawList.AddTriangleFilled(tl, tl + new Vector2( t, 0), tl + new Vector2(0, t), col);
+ drawList.AddTriangleFilled(tr, tr + new Vector2(-t, 0), tr + new Vector2(0, t), col);
+ drawList.AddTriangleFilled(br, br + new Vector2(-t, 0), br + new Vector2(0, -t), col);
+ drawList.AddTriangleFilled(bl, bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
+ }
+
+ 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;
+ }
+}