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

@ -69,7 +69,18 @@ public sealed class WorldSession : IDisposable
// Elasticity defaults to 0.05f. When set, drives the velocity-
// reflection bounce magnitude (clamped to [0, 0.1] retail-side).
float? Friction = null,
float? Elasticity = null);
float? Elasticity = null,
// 2026-05-15: from the WeenieHeader optional tail.
// Useability: retail ITEM_USEABLE enum (acclient.h:6478). Bit
// USEABLE_REMOTE (0x20) means the entity accepts R-key Use from
// the world; signs/banners have USEABLE_UNDEF (0x0) and should
// silently ignore Use attempts. null = weenieFlags didn't include
// the field (treat conservatively as not-useable).
// UseRadius: server's use-action reach in meters. Doubles as a
// sizing hint for tall-scenery selection indicators when the
// server publishes it for non-useable display entities.
uint? Useability = null,
float? UseRadius = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -703,7 +714,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction,
parsed.Value.Elasticity));
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius));
}
}
else if (op == DeleteObject.Opcode)