From 58e155615d0858765d6853f3c1e04f118834e5f0 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 15 May 2026 20:07:32 +0200 Subject: [PATCH] feat(B.8): retail useability gate + tall-scenery indicator scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 171 ++++++++++++++++++ src/AcDream.App/UI/TargetIndicatorPanel.cs | 24 ++- src/AcDream.Core.Net/Messages/CreateObject.cs | 89 ++++++++- src/AcDream.Core.Net/WorldSession.cs | 17 +- .../Messages/CreateObjectTests.cs | 84 ++++++++- 5 files changed, 369 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2efad8c..7cdf527 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; } + /// + /// 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. + /// + /// + /// Mirrors 's + /// classification — both fall into the "everything else: 3 m default" + /// branch — so the visible indicator box and the click sphere + /// match. + /// + /// + 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; + } + + /// + /// 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 ITEM_USEABLE _useability field + /// (acclient.h:6478) — specifically when the USEABLE_REMOTE + /// (0x20) bit is set OR a composite form containing it + /// (USEABLE_REMOTE_NEVER_WALK = 0x60, the + /// SOURCE_X_TARGET_REMOTE variants in the 0x200000+ range). + /// + /// + /// 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. + /// + /// + /// + /// Fallback when useability is unknown. The wire's + /// weenieFlags & 0x10 bit gates whether ACE serializes + /// useability at all. If absent, + /// 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". + /// + /// + 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) diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs index e657f40..283386e 100644 --- a/src/AcDream.App/UI/TargetIndicatorPanel.cs +++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs @@ -79,15 +79,23 @@ public sealed class TargetIndicatorPanel /// Small carry items (Money, Food, Gem, SpellComponents, /// Misc, Weapons, Armour, Clothing, Jewelry, Container): /// 0.8 m (item dropped on the ground) - /// Everything else (signs, generic objects, untyped - /// scenery interactables): 1.5 m (mid-sized object - /// default; without mesh AABB this is a best guess) + /// 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 > 1 grows + /// the box proportionally.) /// /// /// /// 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. + /// + /// already caches per-GfxObj AABBs; combining them across a + /// multi-part Setup gives the entity-level bounds we'd want. /// /// 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; } /// diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index a5fcd7b..580de7d 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -126,7 +126,20 @@ public static class CreateObject // weren't set; subscribers fall back to PhysicsBody constructor // defaults (0.05f elasticity, 0.5f friction). 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. (Useability & USEABLE_REMOTE + // (0x20)) != 0 means the entity is useable from the world via + // mouse Use. Signs / banners / decorative scenery have + // USEABLE_UNDEF (0x0) 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). + // UseRadius 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); /// /// The relevant subset of the server-sent MovementData / @@ -470,9 +483,10 @@ public static class CreateObject // ObjectDescriptionFlags, align. string? name = null; uint? itemType = null; + uint weenieFlags = 0; if (body.Length - pos >= 4) { - pos += 4; // skip weenieFlags u32 + weenieFlags = ReadU32(body, ref pos); try { name = ReadString16L(body, ref pos); @@ -496,11 +510,80 @@ public static class CreateObject 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, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, physicsState, objectDescriptionFlags, - friction, elasticity); + friction, elasticity, + useability, useRadius); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 2e644c6..8b4e0f7 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -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); /// Fires when the session finishes parsing a CreateObject. public event Action? 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) diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 71bf72d..64f9566 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -74,12 +74,78 @@ public sealed class CreateObjectTests 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( uint guid, string name, uint itemType, 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(); WriteU32(bytes, CreateObject.Opcode); @@ -99,7 +165,7 @@ public sealed class CreateObjectTests Align4(bytes); // Fixed WeenieHeader prefix per ACE SerializeCreateObject. - WriteU32(bytes, 0); // weenieFlags + WriteU32(bytes, weenieFlags); // weenieFlags WriteString16L(bytes, name); WritePackedDword(bytes, 0x1234); // WeenieClassId WritePackedDword(bytes, 0); // IconId via known-type writer @@ -107,6 +173,20 @@ public sealed class CreateObjectTests WriteU32(bytes, objectDescriptionFlags); 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 tmp = stackalloc byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f); + bytes.AddRange(tmp.ToArray()); + } + return bytes.ToArray(); }