acdream/src/AcDream.App/UI/TargetIndicatorPanel.cs
Erik b5da17db76 feat(retail): Commit B — retail-faithful AP cadence + screen-rect picker
Retires divergences flagged in the 2026-05-16 faithfulness audit:

1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat
   with a diff-driven model gated on `Contact && OnWalkable`
   (acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on
   position or cell change while grounded on walkable, plus a 1 sec
   heartbeat; suppressed entirely airborne. PlayerMovementController
   exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps
   after each AutonomousPosition / MoveToState send — mirrors retail's
   shared `last_sent_position_time` between SendPositionEvent
   (0x006b4770) and SendMovementEvent (0x006b4680). Known divergence
   from retail: ours is per-frame-while-moving, retail's effective rate
   is ~1 Hz during smooth motion (cell/plane checks). Filed as #74,
   blocked by #63 — when #63 lands we revert to retail's narrower gate.

2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival)
   and the AP-flush before re-send (`SendAutonomousPositionNow`). The
   diff-driven cadence makes both obsolete. Close-range turn-first
   deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87
   mirrors retail's CreateMoveToChain pre-callback rotation), renamed
   `OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send.
   `isRetryAfterArrival` parameter dropped.

3. Far-range Use/PickUp retry. Restored — was load-bearing, not the
   "redundant cleanup" the Group 2 audit thought. Issue #63 means ACE
   drops the first Use as too-far without re-polling on subsequent APs;
   the arrival re-send is what makes far-range Use complete. Logs
   include `(queued for arrival re-send pending #63)` to make this
   explicit. Removes when #63 closes.

4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection`
   helper shared by `WorldPicker` and `TargetIndicatorPanel`. The
   `Setup.SelectionSphere` projects to a screen-space square (retail
   anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker
   hit-tests the mouse pixel against the same rect the indicator draws,
   inflated by 8 px (`TriangleSize`). Guarantees what-you-see is
   what-you-click — including rect corners that were dead zones under
   the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and
   vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired;
   `IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale
   defensive default. No defensive sphere synth — entities without a
   baked `SelectionSphere` are skipped, matching retail's
   `GfxObjUnderSelectionRay` (0x0054c740).

5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)`
   helper applies retail's `run_turn_factor = 1.5f` (PDB-named
   0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at
   0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s.
   Keyboard A/D + ApplyAutoWalkOverlay both use it.

6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to
   `useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero
   passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the
   USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail
   (ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697,
   UsingItem 0x367638, disable-button-state 0x198826). Added
   `ProbeUseabilityFallbackEnabled` diagnostic
   (`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the
   creature/BF_DOOR fallback fires for ACE-seed-DB entities with
   null useability.

CLAUDE.md updated with the graceful-shutdown rule for relaunch:
Stop-Process bypasses the logout packet, leaving ACE's session marked
logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the
shutdown hook runs and the logout packet reaches ACE.

Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9.
Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures
unchanged). Visual-verified 2026-05-16: rotation rate, useability,
screen-rect click area, double-click + R-key + F-key Use/PickUp at
short and long range — dialogue/door/pickup fire on arrival.

Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker
Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message
sweep pattern), #74 (per-frame AP chattier than retail — blocked by
#63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)`
overload kept for back-compat; no callers in acdream proper.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:56:08 +02:00

265 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> — host's current <c>_selectedGuid</c>.</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;
}
}