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 // origin is a single point. 0.7 m default is fine for
// humanoids and most items; doors / portals need ~2 m // humanoids and most items; doors / portals need ~2 m
// to cover the doorframe. // 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 => radiusForGuid: g =>
{ {
if (_lastSpawnByGuid.TryGetValue(g, out var s) if (_lastSpawnByGuid.TryGetValue(g, out var s)
@ -9018,6 +9026,7 @@ public sealed class GameWindow : IDisposable
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 2.0f; if ((odf & LargeFlatMask) != 0) return 2.0f;
} }
if (IsTallSceneryGuid(g)) return 1.6f;
// 1.0 m sphere centred at chest height (see // 1.0 m sphere centred at chest height (see
// verticalOffsetForGuid) covers a 1.8 m humanoid from // verticalOffsetForGuid) covers a 1.8 m humanoid from
// shin to crown without overlapping neighbours. // shin to crown without overlapping neighbours.
@ -9039,6 +9048,7 @@ public sealed class GameWindow : IDisposable
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 1.0f; // mid-door 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 return 0.2f; // small ground item — sphere just above feet
}); });
@ -9079,6 +9089,19 @@ public sealed class GameWindow : IDisposable
return; 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 // B.7 (2026-05-15): the user requested R behave as a universal
// interact key — pickup for items, use for NPCs / doors / // interact key — pickup for items, use for NPCs / doors /
// lifestones / portals / corpses. Matches retail's "use" // lifestones / portals / corpses. Matches retail's "use"
@ -9113,6 +9136,18 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world"); _debugVm?.AddToast("Not in world");
return; 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 // B.6 (2026-05-15): install a speculative auto-walk on the local
// player toward the target. For far targets ACE will overwrite // player toward the target. For far targets ACE will overwrite
// this with its own MovementType=6 wire payload (and a better // 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"); _debugVm?.AddToast("Not in world");
return; 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 // B.6 (2026-05-15): same speculative turn-to-target + deferral as
// SendUse — close-range pickup rotates locally to face the // SendUse — close-range pickup rotates locally to face the
// item first, then the wire packet fires when the local // 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; 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) private string DescribeLiveEntity(uint guid)
{ {
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) 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, /// <item>Small carry items (Money, Food, Gem, SpellComponents,
/// Misc, Weapons, Armour, Clothing, Jewelry, Container): /// Misc, Weapons, Armour, Clothing, Jewelry, Container):
/// 0.8 m (item dropped on the ground)</item> /// 0.8 m (item dropped on the ground)</item>
/// <item>Everything else (signs, generic objects, untyped /// <item>Everything else (signs on a pole, generic tall scenery,
/// scenery interactables): 1.5 m (mid-sized object /// untyped scenery interactables): 3.0 m (post-on-ground
/// default; without mesh AABB this is a best guess)</item> /// 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> /// </list>
/// ///
/// <para> /// <para>
/// Future refinement (deferred): read the entity's actual mesh /// Future refinement (deferred): read the entity's actual mesh
/// AABB at registration time and use the projected silhouette /// 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> /// </para>
/// </summary> /// </summary>
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale) public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale)
@ -125,11 +133,9 @@ public sealed class TargetIndicatorPanel
| AcDream.Core.Items.ItemType.Caster); | AcDream.Core.Items.ItemType.Caster);
if ((itemType & SmallItemMask) != 0) return 0.8f * scale; if ((itemType & SmallItemMask) != 0) return 0.8f * scale;
// Everything else (signs, scenery interactables, untyped objects): // Tall scenery (signs / banners / untyped post-mounted objects):
// 1.5 m default — bigger than a small item but smaller than a // 3.0 m. See class doc above for the bump rationale.
// humanoid, splitting the difference until we have real mesh return 3.0f * scale;
// bounds to project.
return 1.5f * scale;
} }
/// <summary> /// <summary>

View file

@ -126,7 +126,20 @@ public static class CreateObject
// weren't set; subscribers fall back to PhysicsBody constructor // weren't set; subscribers fall back to PhysicsBody constructor
// defaults (0.05f elasticity, 0.5f friction). // defaults (0.05f elasticity, 0.5f friction).
float? Friction = null, float? Friction = null,
float? Elasticity = null); float? Elasticity = null,
// 2026-05-15: optional WeenieHeader tail. The retail
// `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the
// R-key Use action does anything. <c>(Useability &amp; USEABLE_REMOTE
// (0x20)) != 0</c> means the entity is useable from the world via
// mouse Use. Signs / banners / decorative scenery have
// <c>USEABLE_UNDEF (0x0)</c> here — selecting them via left-click is
// fine, but R-key Use should be a no-op (retail-faithful: the
// character does not walk toward; nothing happens).
// <c>UseRadius</c> is the use-action's reach in meters; doubles as
// a sizing hint for selection indicators on entities that
// publish it.
uint? Useability = null,
float? UseRadius = null);
/// <summary> /// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> / /// The relevant subset of the server-sent <c>MovementData</c> /
@ -470,9 +483,10 @@ public static class CreateObject
// ObjectDescriptionFlags, align. // ObjectDescriptionFlags, align.
string? name = null; string? name = null;
uint? itemType = null; uint? itemType = null;
uint weenieFlags = 0;
if (body.Length - pos >= 4) if (body.Length - pos >= 4)
{ {
pos += 4; // skip weenieFlags u32 weenieFlags = ReadU32(body, ref pos);
try try
{ {
name = ReadString16L(body, ref pos); name = ReadString16L(body, ref pos);
@ -496,11 +510,80 @@ public static class CreateObject
catch { /* truncated name — partial result is still useful */ } catch { /* truncated name — partial result is still useful */ }
} }
// --- WeenieHeader optional tail (2026-05-15): walk the
// conditional fields up through Useability + UseRadius.
//
// Wire order is fixed by ACE WorldObject_Networking.cs:87-114
// and matches retail PWD::Pack order. We MUST skip every
// preceding optional field (even those we don't care about)
// because each one moves the parse cursor.
//
// Field bit width decoded?
// ------- ------ -------- --------
// weenieFlags2 conditional on objDescFlags &amp; 0x80000000 (BF_INCLUDES_SECOND_HEADER)
// u32 skipped
// PluralName 0x1 String16L (variable, padded to 4) skipped
// ItemCapacity 0x2 1 byte skipped
// ContainerCap 0x4 1 byte skipped
// AmmoType 0x100 u16 skipped
// Value 0x8 u32 skipped
// Useability 0x10 u32 KEPT
// UseRadius 0x20 f32 KEPT
//
// Wrapped in try/catch — if a malformed entity truncates the
// tail we still return the prefix fields. Most spawned entities
// either have all of these or none of them.
uint? useability = null;
float? useRadius = null;
try
{
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x80000000u) != 0;
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
if ((weenieFlags & 0x00000001u) != 0) // PluralName
_ = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
pos += 2;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
pos += 4;
}
if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc UseRadius");
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
return new Parsed(guid, position, setupTableId, animParts, return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
physicsState, objectDescriptionFlags, physicsState, objectDescriptionFlags,
friction, elasticity); friction, elasticity,
useability, useRadius);
// Local helper: if we ran out of fields past PhysicsData, still // Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -69,7 +69,18 @@ public sealed class WorldSession : IDisposable
// Elasticity defaults to 0.05f. When set, drives the velocity- // Elasticity defaults to 0.05f. When set, drives the velocity-
// reflection bounce magnitude (clamped to [0, 0.1] retail-side). // reflection bounce magnitude (clamped to [0, 0.1] retail-side).
float? Friction = null, 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> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -703,7 +714,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.PhysicsState, parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags, parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction, parsed.Value.Friction,
parsed.Value.Elasticity)); parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius));
} }
} }
else if (op == DeleteObject.Opcode) else if (op == DeleteObject.Opcode)

View file

@ -74,12 +74,78 @@ public sealed class CreateObjectTests
Assert.Equal(0x2000008u, parsed!.Value.ObjectDescriptionFlags); Assert.Equal(0x2000008u, parsed!.Value.ObjectDescriptionFlags);
} }
// -----------------------------------------------------------------------
// 2026-05-15: WeenieHeader optional-tail walker landed for Useability +
// UseRadius (acclient.h ITEM_USEABLE enum at line 6478). The R-key Use
// gate consumes Useability; signs without USEABLE_REMOTE (0x20) silently
// ignore Use.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_NoWeenieFlags_LeavesUseabilityNull()
{
// Sign-like entity: weenieFlags=0 (no optional fields).
// Useability stays null (parser walked past nothing).
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000006u, name: "Holtburg Sign",
itemType: 0x8000u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed!.Value.Useability);
Assert.Null(parsed.Value.UseRadius);
}
[Fact]
public void TryParse_WeenieFlagsUsable_ReadsUseability()
{
// Useable NPC: weenieFlags has bit 0x10 set, body carries
// ITEM_USEABLE = USEABLE_REMOTE (0x20).
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000007u, name: "Tirenia",
itemType: (uint)ItemType.Creature,
weenieFlags: 0x10u,
useability: 0x20u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x20u, parsed!.Value.Useability);
}
[Fact]
public void TryParse_WeenieFlagsValueAndUsableAndUseRadius_AllReadInOrder()
{
// Verify the walker skips Value (bit 0x8, 4 bytes) BEFORE reading
// Useability (bit 0x10) and UseRadius (bit 0x20). Wire order in
// ACE WorldObject_Networking.cs:99-106 is Value, Useable, UseRadius.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000008u, name: "PriceyDoor",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x8u | 0x10u | 0x20u,
value: 0x12345678u,
useability: 0x20u,
useRadius: 2.5f);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x20u, parsed!.Value.Useability);
Assert.NotNull(parsed.Value.UseRadius);
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader( private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid, uint guid,
string name, string name,
uint itemType, uint itemType,
uint physicsState = 0, uint physicsState = 0,
uint objectDescriptionFlags = 0) uint objectDescriptionFlags = 0,
uint weenieFlags = 0,
uint? value = null,
uint? useability = null,
float? useRadius = null)
{ {
var bytes = new List<byte>(); var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode); WriteU32(bytes, CreateObject.Opcode);
@ -99,7 +165,7 @@ public sealed class CreateObjectTests
Align4(bytes); Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject. // Fixed WeenieHeader prefix per ACE SerializeCreateObject.
WriteU32(bytes, 0); // weenieFlags WriteU32(bytes, weenieFlags); // weenieFlags
WriteString16L(bytes, name); WriteString16L(bytes, name);
WritePackedDword(bytes, 0x1234); // WeenieClassId WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer WritePackedDword(bytes, 0); // IconId via known-type writer
@ -107,6 +173,20 @@ public sealed class CreateObjectTests
WriteU32(bytes, objectDescriptionFlags); WriteU32(bytes, objectDescriptionFlags);
Align4(bytes); Align4(bytes);
// Optional WeenieHeader tail (2026-05-15) — same order as ACE
// WorldObject_Networking.cs:87-114. Each field is written only when
// its weenieFlags bit is set, matching the parser's walker exactly.
if ((weenieFlags & 0x00000008u) != 0) // Value u32
WriteU32(bytes, value ?? 0u);
if ((weenieFlags & 0x00000010u) != 0) // Useability u32
WriteU32(bytes, useability ?? 0u);
if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
bytes.AddRange(tmp.ToArray());
}
return bytes.ToArray(); return bytes.ToArray();
} }