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>
265 lines
12 KiB
C#
265 lines
12 KiB
C#
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 0x004f6a0b–0x004f6a99):
|
||
// 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;
|
||
}
|
||
}
|