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