acdream/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Erik e0d5d271f3 fix(retail): rotation rate, useability gate, retail toast strings
Two retail divergences fixed from the 2026-05-16 faithfulness audit
(Commit A of the plan at docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md).

1. Rotation rate ignored HoldKey.Run. Retail's CMotionInterp::
   apply_run_to_command (decomp 0x00527be0 line 305098) multiplies
   turn_speed by run_turn_factor (1.5, PDB-named symbol at 0x007c8914)
   when input is TurnRight/TurnLeft under HoldKey.Run. Effective
   running rotation is 50% faster (~135°/s vs walking ~90°/s).
   Our keyboard A/D and ApplyAutoWalkOverlay used a fixed walking
   rate.

   New: RemoteMoveToDriver.TurnRateFor(running) helper. Keyboard
   path passes input.Run; auto-walk overlay passes
   _autoWalkInitiallyRunning. The walking-rate base
   (BaseTurnRateRadPerSec = π/2) is unchanged; TurnRateRadPerSec
   constant is preserved as the walking-rate alias for callers
   that don't have run/walk state (NPC remotes).

2. IsUseableTarget gated on `useability & USEABLE_REMOTE (0x20)`,
   which was stricter than retail. Per ItemUses::IsUseable
   (acclient_2013_pseudo_c.txt:256455) cross-referenced with 4
   call sites, retail's IsUseable() semantic is `_useability != 0`.
   But visually retail's USEABLE_NO (1) entities don't approach
   either, because ACE never broadcasts MovementType=6 for them.
   Our client installs a speculative auto-walk BEFORE the server
   responds, so we'd visibly approach + face signs before the
   wire packet was rejected.

   Pragmatic fix: block USEABLE_UNDEF (0) AND USEABLE_NO (1) in
   IsUseableTarget — slightly stricter than retail's
   IsUseable but matches retail's user-visible behaviour
   ("R on sign does nothing"). Documented in the doc-comment so
   a future implementer knows the gap.

3. New IsPickupableTarget gate for F-key path — requires
   USEABLE_REMOTE (0x20) bit. Null-useability fallback for
   BF_CORPSE + small-item ItemTypes (preserves M1 ground-item
   pickup flow when ACE seed DB doesn't publish useability).

4. R-key (UseCurrentSelection) upfront gate now ALWAYS uses
   IsUseableTarget. R is conceptually "use" with smart-routing
   to pickup as a downstream optimization. F-key (SendPickUp)
   uses IsPickupableTarget directly.

5. Retail toast strings on block, centralised in new
   src/AcDream.Core/Ui/RetailMessages.cs:
   - "The X cannot be used" (data 0x007e2a70, sprintf 0x00588ea4)
     fires on UseCurrentSelection / SendUse gate block.
   - "The X can't be picked up!" (sprintf 0x00587353) fires on
     SendPickUp non-pickupable block.
   - "You cannot pick up creatures!" (data 0x007e22b4) fires on
     SendPickUp creature block (was previously silent).
   - Plus 4 inactive retail strings ready for future call sites:
     CannotBeUsedWith (two-target Use), CannotBePickedUp (formal
     pickup variant), CannotBeUsedWhileOnHook_HooksOff +
     CannotBeUsedWhileOnHook_NotOwner (housing). All cite their
     retail data addresses + runtime sprintf addresses.

6. ProbeUseabilityFallbackEnabled diagnostic (env var
   ACDREAM_PROBE_USEABILITY_FALLBACK=1) logs every time the
   null-useability fallback fires. Settles whether the
   fallback for creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE
   entries in ACE's seed DB without useability is hot code
   or theoretical defense.

Test coverage:
- +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/back-compat.
- +7 RetailMessagesTests cover each retail string with retail anchor.
- +1 CreateObjectTests TryParse_WeenieFlagsUsable_ReadsUseableNoValue
  pins parser correctness for USEABLE_NO=1.
- 294/294 Core.Net pass; 24/24 new+touched Core tests pass.
- Pre-existing baseline of 8 Physics test failures unchanged
  (BSPStepUp + MotionInterpreter regression noise from prior
  sessions; out of scope here).

Deferred to a separate session per user direction:
- Click area = indicator-rect retail fidelity. Retail's picker
  uses per-part CGfxObj.drawing_sphere + polygon refine
  (0x0054c740); ours uses single Setup.SelectionSphere ray-
  intersect. The rect corners are dead zones today. Three fix
  options analyzed: screen-space rectangle hit-test, sqrt(2)
  sphere inflation, polygon refine Stage B.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:17:54 +02:00

252 lines
8.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_WeenieFlagsUsable_ReadsUseableNoValue()
{
// Holtburg sign case (observed 2026-05-16): ACE sends
// weenieFlags=0x10 + Useability=USEABLE_NO (0x01) for signs.
// The parser must read this verbatim — downstream code
// distinguishes USEABLE_NO from USEABLE_REMOTE for the
// pickup vs use gate.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x7A9B3001u, name: "Holtburg",
itemType: 0x80u, // Misc
weenieFlags: 0x10u,
useability: 0x01u); // USEABLE_NO
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x01u, 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);
}
}