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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,13 +40,26 @@ public sealed class TargetIndicatorPanel
|
|||
/// <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.
|
||||
/// 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);
|
||||
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;
|
||||
|
|
@ -54,8 +67,12 @@ public sealed class TargetIndicatorPanel
|
|||
|
||||
/// <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; } = 10f;
|
||||
public float TriangleSize { get; set; } = 8f;
|
||||
|
||||
/// <summary>
|
||||
/// World-space height of the indicator box for entities that don't
|
||||
|
|
@ -98,24 +115,33 @@ public sealed class TargetIndicatorPanel
|
|||
/// multi-part Setup gives the entity-level bounds we'd want.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale)
|
||||
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null)
|
||||
{
|
||||
if (scale <= 0f) scale = 1f; // defensive
|
||||
bool isCreature = (itemType & (uint)AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
if (isCreature) return 1.8f * scale;
|
||||
|
||||
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000, BF_PORTAL = 0x40000
|
||||
// (acclient.h:6431-6463)
|
||||
const uint TallStructureMask = 0x1000u | 0x4000u | 0x40000u;
|
||||
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000, BF_PORTAL = 0x40000,
|
||||
// BF_CORPSE = 0x2000 (acclient.h:6431-6463).
|
||||
const uint TallStructureMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
if ((pwdBitfield & TallStructureMask) != 0) return 2.4f * scale;
|
||||
|
||||
// 2026-05-15 — KEY DISCRIMINATOR. Misc-class ItemTypes are
|
||||
// ambiguous in retail: dropped jewellery / coins / food / tapers
|
||||
// are Misc, but so are signs, banners, and decorative scenery.
|
||||
// ACE distinguishes the two via ITEM_USEABLE (acclient.h:6478):
|
||||
// a real pickup item has USEABLE_REMOTE (0x20) set; a sign has
|
||||
// USEABLE_UNDEF (0). If we know useability and it lacks
|
||||
// USEABLE_REMOTE, treat the entity as tall scenery regardless
|
||||
// of ItemType. This is what fixes the Holtburg town sign
|
||||
// showing a tiny pole-base box.
|
||||
const uint USEABLE_REMOTE_BIT = 0x20u;
|
||||
bool useableFromWorld = useability is uint u
|
||||
&& (u & USEABLE_REMOTE_BIT) != 0;
|
||||
|
||||
// Small carry items: weapons / armour / clothing / jewellery /
|
||||
// money / food / misc / weapons / containers / gems / spell
|
||||
// components / writable / keys / casters / lockables / promissory
|
||||
// notes / mana stones / craft bases / craft intermediates /
|
||||
// tinkering tools / tinkering materials / gameboard.
|
||||
// Per AcDream.Core.Items.ItemType — most non-zero values in
|
||||
// the lower 28 bits map to carryable items.
|
||||
// components / writable / keys / casters / lockables.
|
||||
const uint SmallItemMask =
|
||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||||
| AcDream.Core.Items.ItemType.Armor
|
||||
|
|
@ -131,10 +157,15 @@ public sealed class TargetIndicatorPanel
|
|||
| AcDream.Core.Items.ItemType.Writable
|
||||
| AcDream.Core.Items.ItemType.Key
|
||||
| AcDream.Core.Items.ItemType.Caster);
|
||||
if ((itemType & SmallItemMask) != 0) return 0.8f * scale;
|
||||
|
||||
// Tall scenery (signs / banners / untyped post-mounted objects):
|
||||
// 3.0 m. See class doc above for the bump rationale.
|
||||
// Real pickup item: ItemType is small-item-class AND the server
|
||||
// marked it useable from the world. 0.8 m × scale box.
|
||||
if ((itemType & SmallItemMask) != 0 && useableFromWorld) return 0.8f * scale;
|
||||
|
||||
// Tall scenery: anything else (signs, banners, untyped
|
||||
// post-mounted objects, AND Misc-typed-but-non-useable entities
|
||||
// like the Holtburg sign). 3 m × scale covers a typical
|
||||
// post-mounted sign from ground to top.
|
||||
return 3.0f * scale;
|
||||
}
|
||||
|
||||
|
|
@ -179,39 +210,62 @@ public sealed class TargetIndicatorPanel
|
|||
|
||||
var viewProj = view * projection;
|
||||
|
||||
// Project the entity's feet and head to screen space. Apparent
|
||||
// pixel height (= |headY - feetY|) gives the box height; halve
|
||||
// it for width. Closer entity → bigger projected height → bigger
|
||||
// box. Distance scaling is automatic from the perspective
|
||||
// projection.
|
||||
if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen))
|
||||
return;
|
||||
float entityHeight = EntityHeightFor(info.ItemType, info.ObjectDescriptionFlags, info.Scale);
|
||||
var headWorld = new Vector3(
|
||||
info.WorldPosition.X,
|
||||
info.WorldPosition.Y,
|
||||
info.WorldPosition.Z + entityHeight);
|
||||
if (!TryProjectToScreen(headWorld, viewProj, viewport, out var headScreen))
|
||||
return;
|
||||
Vector2 tl, tr, br, bl;
|
||||
|
||||
// Apparent height + width.
|
||||
float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y);
|
||||
if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight;
|
||||
float screenWidth = screenHeight * WidthHeightRatio;
|
||||
if (info.WorldSphereCenter is Vector3 sphereCenter
|
||||
&& info.WorldSphereRadius is float sphereRadius
|
||||
&& TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport,
|
||||
out var rMin, out var rMax))
|
||||
{
|
||||
// 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;
|
||||
|
||||
// Box center = midpoint of feet/head projection. Use the X from
|
||||
// the head projection only (feet may project to a slightly
|
||||
// different X if the camera looks down at an angle; midpoint is
|
||||
// a stable centre regardless).
|
||||
Vector2 center = (feetScreen + headScreen) * 0.5f;
|
||||
float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y);
|
||||
if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight;
|
||||
float screenWidth = screenHeight * WidthHeightRatio;
|
||||
|
||||
float halfW = screenWidth * 0.5f;
|
||||
float halfH = screenHeight * 0.5f;
|
||||
Vector2 center = (feetScreen + headScreen) * 0.5f;
|
||||
float halfW = screenWidth * 0.5f;
|
||||
float halfH = screenHeight * 0.5f;
|
||||
|
||||
Vector2 tl = new(center.X - halfW, center.Y - halfH);
|
||||
Vector2 tr = new(center.X + halfW, center.Y - halfH);
|
||||
Vector2 br = new(center.X + halfW, center.Y + halfH);
|
||||
Vector2 bl = new(center.X - halfW, center.Y + halfH);
|
||||
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);
|
||||
|
|
@ -220,14 +274,85 @@ public sealed class TargetIndicatorPanel
|
|||
|
||||
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);
|
||||
// 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>
|
||||
/// 2026-05-16. Project a world-space sphere (center + radius) as a
|
||||
/// screen-space square and return its bounding rectangle. Matches
|
||||
/// retail <c>SmartBox::GetObjectBoundingBox</c> (decomp
|
||||
/// <c>0x00452e20</c>) which uses
|
||||
/// <c>Render::GetViewerBBox(sphere, &corner1, &corner2)</c>
|
||||
/// to compute a camera-aligned BBox of the sphere then projects
|
||||
/// the 2 corner points.
|
||||
///
|
||||
/// <para>
|
||||
/// Mathematical equivalent (faster, no per-corner reprojection):
|
||||
/// project the sphere center to screen, then compute the
|
||||
/// screen-space radius as
|
||||
/// <c>worldRadius * projection.M22 * viewport.Y / (2 * clip.W)</c>
|
||||
/// where <c>M22 = 1/tan(fovY/2)</c> for a standard right-handed
|
||||
/// perspective. The rect is centered at the projected sphere
|
||||
/// center with side length <c>2 * screenRadius</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>false</c> if the sphere center is behind the camera
|
||||
/// (<c>clip.W <= 0</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool TryComputeScreenRectFromSphere(
|
||||
Vector3 worldCenter, float worldRadius,
|
||||
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
|
||||
out Vector2 rectMin, out Vector2 rectMax)
|
||||
{
|
||||
rectMin = default;
|
||||
rectMax = default;
|
||||
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
|
||||
if (clip.W <= 0.001f) return false;
|
||||
|
||||
float ndcX = clip.X / clip.W;
|
||||
float ndcY = clip.Y / clip.W;
|
||||
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
|
||||
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
|
||||
|
||||
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
|
||||
// the camera-space distance (positive in front of camera for
|
||||
// standard right-handed perspective).
|
||||
float scaleY = projection.M22;
|
||||
if (scaleY <= 0f) return false;
|
||||
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
|
||||
|
||||
// Cull obviously-off-screen entities (more than a screen away).
|
||||
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
|
||||
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
|
||||
|
||||
// Floor at MinSide so distant entities still get a visible indicator.
|
||||
const float MinSide = 12f;
|
||||
if (screenRadius < MinSide * 0.5f) screenRadius = MinSide * 0.5f;
|
||||
|
||||
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
|
||||
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -537,8 +537,14 @@ public static class CreateObject
|
|||
float? useRadius = null;
|
||||
try
|
||||
{
|
||||
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
|
||||
// (ACE ObjectDescriptionFlag.IncludesSecondHeader matches).
|
||||
// Earlier code had this as 0x80000000 — wrong bit, so the
|
||||
// weenieFlags2 4-byte skip never fired for entities that
|
||||
// actually had it set, corrupting downstream optional-tail
|
||||
// offsets. Now correct.
|
||||
bool hasSecondHeader = objectDescriptionFlags.HasValue
|
||||
&& (objectDescriptionFlags.Value & 0x80000000u) != 0;
|
||||
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
|
||||
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
|
||||
|
||||
if ((weenieFlags & 0x00000001u) != 0) // PluralName
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue