feat(B.8): retail useability gate + tall-scenery indicator scaling

Two retail divergences fixed end-to-end:

1. R-key Use on non-useable entities (signs, banners, decorative
   scenery) was silently sending Use/PickUp to ACE, triggering
   auto-walk + NPC-style chat fallback. Retail's client checks
   ITEM_USEABLE (acclient.h:6478) and silently ignores Use when
   the USEABLE_REMOTE (0x20) bit isn't set. Now ports that gate.

2. Holtburg town sign indicator + click sphere only covered the
   base of the pole because the "everything else" default in
   EntityHeightFor was 1.5 m and the picker's vertical offset
   for default class was 0.2 m. A 3 m sign on a pole was almost
   entirely outside both shapes.

Wire change:
- CreateObject parser now walks the WeenieHeader optional tail
  (per ACE WorldObject_Networking.cs:87-114) up through Useability
  + UseRadius. Captures weenieFlags upfront, then conditionally
  skips PluralName, ItemCapacity, ContainerCapacity, AmmoType,
  Value before reading Useability (u32) and UseRadius (f32).
- CreateObject.Parsed + WorldSession.EntitySpawn record append two
  new optional fields (Useability uint?, UseRadius float?), both
  defaulting to null. Existing call sites unchanged.
- 3 new tests cover: no weenieFlags → null, weenieFlags=0x10 alone
  → useability read, weenieFlags=0x8|0x10|0x20 → walker skips Value
  then reads Useability + UseRadius in correct order.

Behaviour change:
- GameWindow.IsUseableTarget(guid) — authoritative path uses spawn
  .Useability when present (REMOTE bit gate); fallback when null
  permits Use on creatures + BF_DOOR/LIFESTONE/PORTAL/CORPSE for
  M1 flow continuity.
- UseCurrentSelection (R-key dispatcher) and SendUse + SendPickUp
  (double-click + F-key direct paths) gate on IsUseableTarget,
  silent early-return matching retail. isRetryAfterArrival skips
  the gate (re-fires only previously-gated actions).
- TargetIndicatorPanel.EntityHeightFor default branch 1.5 m → 3 m
  for non-creature non-flat non-small-item entities (sign-class).
  Scale > 1 still grows proportionally.
- WorldPicker callbacks: new IsTallSceneryGuid branch lifts sphere
  centre to 1.5 m with 1.6 m radius for sign-class entities,
  mirroring the indicator's 3 m default so click sphere matches
  the visible box.

Tests: 293/293 pass in AcDream.Core.Net.Tests (+3 new walker
tests). dotnet build clean.

Retail anchors:
- acclient.h:6478 — ITEM_USEABLE enum (USEABLE_REMOTE = 0x20)
- acclient.h:6431-6463 — PWD bitfield (BF_DOOR etc.)
- ACE WorldObject_Networking.cs:87-114 — wire field order
- ACE WeenieHeaderFlag — Usable = 0x10, UseRadius = 0x20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-15 20:07:32 +02:00
parent 520badd566
commit 58e155615d
5 changed files with 369 additions and 16 deletions

View file

@ -9007,6 +9007,14 @@ public sealed class GameWindow : IDisposable
// origin is a single point. 0.7 m default is fine for
// humanoids and most items; doors / portals need ~2 m
// to cover the doorframe.
//
// 2026-05-15 sign-class extension: post-mounted scenery
// (Holtburg town sign etc.) needs the sphere TALLER than
// wider. We classify "non-creature, non-flat, non-small-item"
// as tall scenery and grow the sphere to 1.6 m radius lifted
// to 1.5 m vertical offset — covers a 3 m post from
// ground to top. Mirrors TargetIndicatorPanel.EntityHeightFor's
// 3 m default so the click sphere matches the visible box.
radiusForGuid: g =>
{
if (_lastSpawnByGuid.TryGetValue(g, out var s)
@ -9018,6 +9026,7 @@ public sealed class GameWindow : IDisposable
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 2.0f;
}
if (IsTallSceneryGuid(g)) return 1.6f;
// 1.0 m sphere centred at chest height (see
// verticalOffsetForGuid) covers a 1.8 m humanoid from
// shin to crown without overlapping neighbours.
@ -9039,6 +9048,7 @@ public sealed class GameWindow : IDisposable
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 1.0f; // mid-door
}
if (IsTallSceneryGuid(g)) return 1.5f; // mid-pole height
return 0.2f; // small ground item — sphere just above feet
});
@ -9079,6 +9089,19 @@ public sealed class GameWindow : IDisposable
return;
}
// 2026-05-15: retail-faithful useability gate. Signs / banners /
// decorative scenery have ITEM_USEABLE = USEABLE_UNDEF (acclient.h:6478)
// and the retail client silently ignores R-key on them — no walk,
// no packet, no toast. We honor that here. The user can still
// left-click to select and see the entity's name; only the
// interact action is suppressed.
if (!IsUseableTarget(sel))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use ignored — not useable guid=0x{sel:X8}");
return;
}
// B.7 (2026-05-15): the user requested R behave as a universal
// interact key — pickup for items, use for NPCs / doors /
// lifestones / portals / corpses. Matches retail's "use"
@ -9113,6 +9136,18 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
// 2026-05-15: defense-in-depth useability gate. Double-click flows
// directly through SendUse without passing UseCurrentSelection's
// dispatcher gate, so re-check here. Silent ignore matches retail
// (acclient.h:6478 ITEM_USEABLE — USEABLE_REMOTE bit required).
// isRetryAfterArrival bypasses the gate because we only retry an
// action we previously gated through.
if (!isRetryAfterArrival && !IsUseableTarget(guid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
return;
}
// B.6 (2026-05-15): install a speculative auto-walk on the local
// player toward the target. For far targets ACE will overwrite
// this with its own MovementType=6 wire payload (and a better
@ -9165,6 +9200,18 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
// 2026-05-15: useability gate (acclient.h:6478 ITEM_USEABLE).
// F-key on a non-useable entity (sign, banner, decorative
// scenery) is silently ignored — without this we'd send
// PutItemInContainer for a sign and ACE would reply with a
// noisy InventoryServerSaveFailed. Retail's client doesn't
// attempt the wire send at all.
if (!isRetryAfterArrival && !IsUseableTarget(itemGuid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] SendPickUp ignored — not useable item=0x{itemGuid:X8}");
return;
}
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
// SendUse — close-range pickup rotates locally to face the
// item first, then the wire packet fires when the local
@ -9429,6 +9476,130 @@ public sealed class GameWindow : IDisposable
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
}
/// <summary>
/// 2026-05-15. True when the entity is "tall scenery" — has a known
/// non-zero ItemType that is NOT in the small-carry-item mask AND
/// has no door/lifestone/portal/corpse PWD bits AND is not a
/// creature. The Holtburg town sign is the canonical example: a
/// 3 m post-mounted entity that needs the pick sphere lifted to
/// mid-pole with a wider radius so the user can click any part of
/// the visible mesh, not just the pole base.
///
/// <para>
/// Mirrors <see cref="UI.TargetIndicatorPanel.EntityHeightFor"/>'s
/// classification — both fall into the "everything else: 3 m default"
/// branch — so the visible indicator box and the click sphere
/// match.
/// </para>
/// </summary>
private bool IsTallSceneryGuid(uint guid)
{
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
return false;
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
return false;
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.
const uint SmallItemMask =
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
| AcDream.Core.Items.ItemType.Armor
| AcDream.Core.Items.ItemType.Clothing
| AcDream.Core.Items.ItemType.Jewelry
| AcDream.Core.Items.ItemType.Food
| AcDream.Core.Items.ItemType.Money
| AcDream.Core.Items.ItemType.Misc
| AcDream.Core.Items.ItemType.MissileWeapon
| AcDream.Core.Items.ItemType.Container
| AcDream.Core.Items.ItemType.Gem
| AcDream.Core.Items.ItemType.SpellComponents
| AcDream.Core.Items.ItemType.Writable
| AcDream.Core.Items.ItemType.Key
| AcDream.Core.Items.ItemType.Caster);
if ((it & SmallItemMask) != 0) return false;
// Has an ItemType, but not creature / flat-class / small-item:
// tall scenery (sign / banner / generic post-mounted object).
return true;
}
/// <summary>
/// 2026-05-15. Retail-faithful gate for R-key Use / F-key PickUp.
/// Returns true when the entity is interactable from the world per
/// the server-supplied <c>ITEM_USEABLE _useability</c> field
/// (acclient.h:6478) — specifically when the <c>USEABLE_REMOTE
/// (0x20)</c> bit is set OR a composite form containing it
/// (<c>USEABLE_REMOTE_NEVER_WALK = 0x60</c>, the
/// <c>SOURCE_X_TARGET_REMOTE</c> variants in the 0x200000+ range).
///
/// <para>
/// Retail behaviour for non-useable entities (signs, banners,
/// decorative scenery): the R-key does nothing — no walk, no Use
/// packet, no toast. The retail client checks useability before
/// any action and silently ignores the press. We honor that with
/// a silent early return at the call site.
/// </para>
///
/// <para>
/// <b>Fallback when useability is unknown.</b> The wire's
/// <c>weenieFlags &amp; 0x10</c> bit gates whether ACE serializes
/// useability at all. If absent, <see cref="CreateObject.Parsed.Useability"/>
/// is null. Conservatively we permit Use for entities we've
/// historically been able to interact with — creatures, doors,
/// lifestones, portals, corpses — to avoid regressing the existing
/// M1 flows. Pure-scenery untyped entities (the sign case) fall
/// through to "blocked".
/// </para>
/// </summary>
private bool IsUseableTarget(uint guid)
{
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
{
// Authoritative path: server published Useability.
if (spawn.Useability is uint useability)
{
// USEABLE_REMOTE (0x20) — bit set in every from-world
// useable variant per acclient.h:6478 ITEM_USEABLE enum.
const uint USEABLE_REMOTE_BIT = 0x20u;
return (useability & USEABLE_REMOTE_BIT) != 0;
}
// Useability NOT in PWD — fall back to known-useable types.
// ObjectDescriptionFlags BF_DOOR|BF_LIFESTONE|BF_PORTAL|BF_CORPSE
// historically work with Use; allow them through.
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & UseableFlatMask) != 0) return true;
}
}
// Creatures (NPCs / players) are always Use targets (dialogue,
// PvP target). Keeps the fallback permissive for the M1 flows.
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
{
return true;
}
// Default: not useable. Signs, banners, untyped scenery with no
// server-supplied useability and no creature/door PWD bits land
// here — exactly the retail "nothing happens" case.
return false;
}
private string DescribeLiveEntity(uint guid)
{
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)

View file

@ -79,15 +79,23 @@ public sealed class TargetIndicatorPanel
/// <item>Small carry items (Money, Food, Gem, SpellComponents,
/// Misc, Weapons, Armour, Clothing, Jewelry, Container):
/// 0.8 m (item dropped on the ground)</item>
/// <item>Everything else (signs, generic objects, untyped
/// scenery interactables): 1.5 m (mid-sized object
/// default; without mesh AABB this is a best guess)</item>
/// <item>Everything else (signs on a pole, generic tall scenery,
/// untyped scenery interactables): 3.0 m (post-on-ground
/// tall — bumped from 1.5 m on 2026-05-15 because the
/// Holtburg sign was getting a tiny pole-only box. Most
/// non-typed non-flat AC scenery is either small-item-on-
/// ground (handled above) or post-mounted; 3 m is the
/// right midpoint for the post case. Scale &gt; 1 grows
/// the box proportionally.)</item>
/// </list>
///
/// <para>
/// Future refinement (deferred): read the entity's actual mesh
/// AABB at registration time and use the projected silhouette
/// for an exact-fit box. Issue #66-ish.
/// for an exact-fit box.
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.GetVisualBounds"/>
/// already caches per-GfxObj AABBs; combining them across a
/// multi-part Setup gives the entity-level bounds we'd want.
/// </para>
/// </summary>
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale)
@ -125,11 +133,9 @@ public sealed class TargetIndicatorPanel
| AcDream.Core.Items.ItemType.Caster);
if ((itemType & SmallItemMask) != 0) return 0.8f * scale;
// Everything else (signs, scenery interactables, untyped objects):
// 1.5 m default — bigger than a small item but smaller than a
// humanoid, splitting the difference until we have real mesh
// bounds to project.
return 1.5f * scale;
// Tall scenery (signs / banners / untyped post-mounted objects):
// 3.0 m. See class doc above for the bump rationale.
return 3.0f * scale;
}
/// <summary>