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:
parent
520badd566
commit
58e155615d
5 changed files with 369 additions and 16 deletions
|
|
@ -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 & 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)
|
||||||
|
|
|
||||||
|
|
@ -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 > 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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue