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:
parent
58e155615d
commit
f4f4143ac0
3 changed files with 303 additions and 68 deletions
|
|
@ -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>0x00518ba6–0x00518be3</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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue