feat(B.7): retail-faithful target indicator via Setup.SelectionSphere

Replaces the mesh-AABB approximation with retail's actual selection
mechanism. The user observed the indicator was too small and didn't
scale with the object the way retail does — root cause was using the
wrong source data.

Retail trace (decomp anchors in named-retail/acclient_2013_pseudo_c.txt):
- VividTargetIndicator::Draw at 0x004f6c30 is registered as the
  SmartBox targetting callback (0x004f6df6).
- SmartBox::DoTargettingChecks at 0x00453bb4 calls
  SmartBox::GetObjectBoundingBox (0x00452e20) to compute the rect.
- GetObjectBoundingBox uses CPhysicsObj::GetSelectionSphere
  (0x0050ea40) → CPartArray::GetSelectionSphere (0x00518b80) which
  reads setup->selection_sphere from the DAT, applies part-array
  scale (component-wise on center, Z-scale on radius), then calls
  Render::GetViewerBBox (0x0054b400) to project the sphere as a
  screen-space camera-aligned BBox.
- VividTargetIndicator::OnDraw at 0x004f62b0 inflates that rect by
  one triangle width/height on every side before drawing (eax_21 /
  eax_23 in 0x004f6a0b–0x004f6a99), so the corner triangles sit
  outside the projected sphere with a small gap.

Implementation:
- GameWindow.TryGetEntitySelectionSphere reads setup.SelectionSphere
  from the DAT (Setup type already exposes Origin + Radius),
  applies entity scale, rotates center via entity orientation, and
  produces a world-space sphere.
- TargetIndicatorPanel.TryComputeScreenRectFromSphere projects the
  sphere center via the view-projection matrix and computes
  screenRadius = worldRadius * projection.M22 * viewport.Y /
  (2 * clip.W). M22 = cot(fovY/2) for a standard right-handed
  perspective. Mathematically equivalent to retail's
  Render::GetViewerBBox followed by 2-corner xformPointInternal,
  faster (no double projection).
- TargetInfo carries WorldSphereCenter + WorldSphereRadius (replaces
  the previous WorldAabbMin/Max). Fallback to per-type height
  heuristic still in place if Setup has no baked selection_sphere
  (rare; Radius <= 1e-4f short-circuits).
- Inflate by TriangleSize on every side matches retail's eax_21 +
  eax_23 offsets exactly.
- Triangle right-angle apex flipped to point INWARD toward the
  target (per user feedback) — apex at corner + (±t, ±t),
  hypotenuse along the outer diagonal of the corner.
- TriangleSize 10 → 14 → 8 (retail sprite is small).

Also fixes a parser bug in CreateObject.cs introduced in 58e1556:
BF_INCLUDES_SECOND_HEADER is 0x04000000 per acclient.h:6458 (ACE
ObjectDescriptionFlag.IncludesSecondHeader matches), NOT 0x80000000.
The wrong bit meant the weenieFlags2 4-byte skip never fired for
entities that had the bit set, potentially shifting Useability /
UseRadius reads by 4 bytes. Now correct.

Visual verification (2026-05-16):
- Holtburg town sign — indicator traces the visible sign + pole at
  the right size (matches retail screenshot proportions).
- Sign R-key still silent no-op (B.8 useability gate intact).
- NPCs / doors / items still get correctly-sized indicators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-15 21:25:00 +02:00
parent 58e155615d
commit f4f4143ac0
3 changed files with 303 additions and 68 deletions

View file

@ -1182,11 +1182,30 @@ public sealed class GameWindow : IDisposable
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;
uint? useability = null;
if (_lastSpawnByGuid.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);
entity.Position, rawItemType, pwdBits, entity.Scale, useability,
sphereCenter, sphereRadius);
},
cameraProvider: () =>
{
@ -9066,12 +9085,24 @@ public sealed class GameWindow : IDisposable
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;
uint? pickUseability = null;
float? pickUseRadius = null;
float pickScale = 1f;
uint? pickSetupId = null;
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
{
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
pickUseability = spawn.Useability;
pickUseRadius = spawn.UseRadius;
pickScale = spawn.ObjScale ?? 1f;
pickSetupId = spawn.SetupTableId;
}
var col = AcDream.Core.Ui.RadarBlipColors.For(rawItemType, pwdBits);
string useStr = pickUseability.HasValue ? $"0x{pickUseability.Value:X4}" : "null";
string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null";
string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null";
Console.WriteLine(System.FormattableString.Invariant(
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} color=({col.R},{col.G},{col.B})"));
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} tallScenery={IsTallSceneryGuid(guid)} color=({col.R},{col.G},{col.B})"));
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
@ -9494,6 +9525,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private bool IsTallSceneryGuid(uint guid)
{
// Creatures are never "tall scenery" — picker uses humanoid sphere.
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
return false;
@ -9501,18 +9533,26 @@ public sealed class GameWindow : IDisposable
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
return false;
// Doors / lifestones / portals / corpses → LargeFlatMask branch.
if (spawn.ObjectDescriptionFlags is { } odf)
{
// Excludes door/lifestone/portal/corpse — handled by
// the LargeFlatMask branch in the picker callbacks.
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return false;
}
uint it = spawn.ItemType ?? 0u;
if (it == 0u) return false; // no ItemType info — keep default behaviour
// Same SmallItemMask as TargetIndicatorPanel.EntityHeightFor.
// 2026-05-15 — useability-based discriminator. Mirrors
// TargetIndicatorPanel.EntityHeightFor exactly so the click
// sphere matches the visible box. A small-item ItemType is
// a REAL pickup item only if it is also useable from the
// world (USEABLE_REMOTE bit, acclient.h:6478). Otherwise
// (Misc + USEABLE_UNDEF — the Holtburg sign case) it's tall
// scenery and gets the lifted+widened sphere.
const uint USEABLE_REMOTE_BIT = 0x20u;
bool useableFromWorld = spawn.Useability is uint u
&& (u & USEABLE_REMOTE_BIT) != 0;
const uint SmallItemMask =
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
| AcDream.Core.Items.ItemType.Armor
@ -9528,10 +9568,74 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Items.ItemType.Writable
| AcDream.Core.Items.ItemType.Key
| AcDream.Core.Items.ItemType.Caster);
if ((it & SmallItemMask) != 0) return false;
// Real pickup item: small-item-class AND useable. NOT tall scenery.
if ((it & SmallItemMask) != 0 && useableFromWorld) return false;
// Has an ItemType, but not creature / flat-class / small-item:
// tall scenery (sign / banner / generic post-mounted object).
// Everything else (signs / banners / untyped scenery /
// Misc-typed-but-non-useable): tall scenery.
return true;
}
/// <summary>
/// 2026-05-16 — retail-faithful port of
/// <c>SmartBox::GetObjectBoundingBox</c> (decomp <c>0x00452e20</c>)
/// using <c>CPhysicsObj::GetSelectionSphere</c> (<c>0x0050ea40</c>)
/// → <c>CPartArray::GetSelectionSphere</c> (<c>0x00518b80</c>).
///
/// <para>
/// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
/// it uses the Setup's <c>selection_sphere</c> field (a single
/// sphere encompassing the entire entity, baked at Setup-creation
/// time). The sphere is scaled by the part-array scale
/// (component-wise on center, Z-scale on radius — retail's exact
/// formula at <c>0x00518ba60x00518be3</c>) and rotated by entity
/// orientation. The screen indicator rect is the projection of
/// the camera-aligned BBox of this sphere — i.e. a screen circle
/// of radius <c>worldRadius * focalLength / depth</c>.
/// </para>
///
/// <para>
/// Result: the indicator rect MATCHES the Setup's intended
/// "selectable extent" — which is typically larger than the mesh
/// AABB by design (Setups bake a slightly oversized selection
/// sphere so far targets still get pickable indicators). That's
/// why our previous mesh-AABB indicator was smaller than retail's.
/// </para>
/// </summary>
private bool TryGetEntitySelectionSphere(uint guid,
out System.Numerics.Vector3 worldCenter,
out float worldRadius)
{
worldCenter = default;
worldRadius = 0f;
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn)) return false;
if (spawn.SetupTableId is not uint setupId) return false;
if (_dats is null) return false;
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) return false;
// DAT Setup carries `SelectionSphere` (Origin + Radius). A zero
// radius means the Setup didn't bake one — fall back to the
// caller's other path.
var sel = setup.SelectionSphere;
if (sel is null || sel.Radius <= 1e-4f) return false;
// Retail GetSelectionSphere applies part-array scale to the
// sphere center (component-wise) and to the radius (Z-scale
// only). For uniform entity scale these coincide.
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var localCenter = new System.Numerics.Vector3(
sel.Origin.X * scale,
sel.Origin.Y * scale,
sel.Origin.Z * scale);
// Setup-local center → world. Entity rotation applies; entity
// position is the world origin of the setup.
var rot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation);
var rotated = System.Numerics.Vector3.Transform(localCenter, rot);
worldCenter = entity.Position + rotated;
worldRadius = sel.Radius * scale;
return true;
}