diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7cdf527..32e74b5 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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
///
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;
+ }
+
+ ///
+ /// 2026-05-16 — retail-faithful port of
+ /// SmartBox::GetObjectBoundingBox (decomp 0x00452e20)
+ /// using CPhysicsObj::GetSelectionSphere (0x0050ea40)
+ /// → CPartArray::GetSelectionSphere (0x00518b80).
+ ///
+ ///
+ /// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
+ /// it uses the Setup's selection_sphere 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 0x00518ba6–0x00518be3) 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 worldRadius * focalLength / depth.
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ 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(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;
}
diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs
index 283386e..c65d249 100644
--- a/src/AcDream.App/UI/TargetIndicatorPanel.cs
+++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs
@@ -40,13 +40,26 @@ public sealed class TargetIndicatorPanel
/// for colour selection.
/// Scale multiplies the per-type base height in
/// — a scaled-up sign or oversized NPC
- /// gets a proportionally bigger box.
+ /// gets a proportionally bigger box. Useability (acclient.h:6478
+ /// ITEM_USEABLE 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).
///
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 _selectedGuidProvider;
private readonly Func _entityResolver;
@@ -54,8 +67,12 @@ public sealed class TargetIndicatorPanel
///
/// Pixel size of each corner triangle's right-angle legs.
+ /// Retail uses UIRegion::GetWidth(m_rgOnScreenCorners.m_data[1])
+ /// of the triangle sprite (decomp 0x004f69c8). The retail
+ /// sprite is small — ~8 px legs. 14 was too chunky per user
+ /// feedback on 2026-05-16; 8 matches the retail screenshot.
///
- public float TriangleSize { get; set; } = 10f;
+ public float TriangleSize { get; set; } = 8f;
///
/// 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.
///
///
- 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);
+ }
+
+ ///
+ /// 2026-05-16. Project a world-space sphere (center + radius) as a
+ /// screen-space square and return its bounding rectangle. Matches
+ /// retail SmartBox::GetObjectBoundingBox (decomp
+ /// 0x00452e20) which uses
+ /// Render::GetViewerBBox(sphere, &corner1, &corner2)
+ /// to compute a camera-aligned BBox of the sphere then projects
+ /// the 2 corner points.
+ ///
+ ///
+ /// Mathematical equivalent (faster, no per-corner reprojection):
+ /// project the sphere center to screen, then compute the
+ /// screen-space radius as
+ /// worldRadius * projection.M22 * viewport.Y / (2 * clip.W)
+ /// where M22 = 1/tan(fovY/2) for a standard right-handed
+ /// perspective. The rect is centered at the projected sphere
+ /// center with side length 2 * screenRadius.
+ ///
+ ///
+ ///
+ /// Returns false if the sphere center is behind the camera
+ /// (clip.W <= 0).
+ ///
+ ///
+ 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;
}
///
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 580de7d..971c726 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -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