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>
232 lines
8 KiB
C#
232 lines
8 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
public sealed class CreateObjectTests
|
|
{
|
|
[Fact]
|
|
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
|
|
{
|
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
|
guid: 0x50000002u,
|
|
name: "Drudge",
|
|
itemType: (uint)ItemType.Creature);
|
|
|
|
var parsed = CreateObject.TryParse(body);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(0x50000002u, parsed.Value.Guid);
|
|
Assert.Equal("Drudge", parsed.Value.Name);
|
|
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Commit A of 2026-04-29 live-entity collision port:
|
|
// PhysicsState (post-flags u32) + ObjectDescriptionFlags (PWD bitfield)
|
|
// must be surfaced for downstream collision registration.
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void TryParse_PhysicsState_Parsed()
|
|
{
|
|
// ETHEREAL_PS = 0x4 + IGNORE_COLLISIONS_PS = 0x10 → 0x14
|
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
|
guid: 0x50000003u, name: "GhostNpc",
|
|
itemType: (uint)ItemType.Creature,
|
|
physicsState: 0x14u);
|
|
|
|
var parsed = CreateObject.TryParse(body);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(0x14u, parsed!.Value.PhysicsState);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParse_ObjectDescriptionFlags_PlayerKillerBitsSurface()
|
|
{
|
|
// BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20) → a PK player.
|
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
|
guid: 0x50000004u, name: "+PkPlayer",
|
|
itemType: (uint)ItemType.Creature,
|
|
objectDescriptionFlags: 0x8u | 0x20u);
|
|
|
|
var parsed = CreateObject.TryParse(body);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(0x28u, parsed!.Value.ObjectDescriptionFlags);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParse_ObjectDescriptionFlags_PkLiteBit()
|
|
{
|
|
// BF_PLAYER (0x8) | BF_PKLITE_PKSTATUS (0x2000000) → a PK-lite player.
|
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
|
guid: 0x50000005u, name: "+PklPlayer",
|
|
itemType: (uint)ItemType.Creature,
|
|
objectDescriptionFlags: 0x8u | 0x2000000u);
|
|
|
|
var parsed = CreateObject.TryParse(body);
|
|
|
|
Assert.NotNull(parsed);
|
|
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 weenieFlags = 0,
|
|
uint? value = null,
|
|
uint? useability = null,
|
|
float? useRadius = null)
|
|
{
|
|
var bytes = new List<byte>();
|
|
WriteU32(bytes, CreateObject.Opcode);
|
|
WriteU32(bytes, guid);
|
|
|
|
// ModelData header: marker, subpalette count, texture count, animpart count.
|
|
bytes.Add(0x11);
|
|
bytes.Add(0);
|
|
bytes.Add(0);
|
|
bytes.Add(0);
|
|
|
|
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps.
|
|
WriteU32(bytes, 0);
|
|
WriteU32(bytes, physicsState);
|
|
for (int i = 0; i < 9; i++)
|
|
WriteU16(bytes, 0);
|
|
Align4(bytes);
|
|
|
|
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
|
|
WriteU32(bytes, weenieFlags); // weenieFlags
|
|
WriteString16L(bytes, name);
|
|
WritePackedDword(bytes, 0x1234); // WeenieClassId
|
|
WritePackedDword(bytes, 0); // IconId via known-type writer
|
|
WriteU32(bytes, itemType);
|
|
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<byte> tmp = stackalloc byte[4];
|
|
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
|
|
bytes.AddRange(tmp.ToArray());
|
|
}
|
|
|
|
return bytes.ToArray();
|
|
}
|
|
|
|
private static void WriteU32(List<byte> bytes, uint value)
|
|
{
|
|
Span<byte> tmp = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
|
|
bytes.AddRange(tmp.ToArray());
|
|
}
|
|
|
|
private static void WriteU16(List<byte> bytes, ushort value)
|
|
{
|
|
Span<byte> tmp = stackalloc byte[2];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
|
|
bytes.AddRange(tmp.ToArray());
|
|
}
|
|
|
|
private static void WritePackedDword(List<byte> bytes, uint value)
|
|
{
|
|
if (value <= 0x7FFF)
|
|
{
|
|
WriteU16(bytes, (ushort)value);
|
|
return;
|
|
}
|
|
|
|
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
|
|
WriteU16(bytes, (ushort)(value & 0xFFFF));
|
|
}
|
|
|
|
private static void WriteString16L(List<byte> bytes, string value)
|
|
{
|
|
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
|
|
WriteU16(bytes, checked((ushort)encoded.Length));
|
|
bytes.AddRange(encoded);
|
|
Align4(bytes);
|
|
}
|
|
|
|
private static void Align4(List<byte> bytes)
|
|
{
|
|
while ((bytes.Count & 3) != 0)
|
|
bytes.Add(0);
|
|
}
|
|
}
|