refactor(net): Slice 5.0 — extract the PublicWeenieDesc body parser for shared use (behavior-preserving)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

The WeenieHeader fixed prefix plus the ~300-line conditional
optional-tail cascade moves verbatim from CreateObject.TryParse into
PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs), so Slice 5.1's
vendor-item parser can share it instead of duplicating it — each shop
item on the wire is a full CreateObject-style PublicWeenieDesc
(research doc §A.2). Same field order, same nested try/catch swallow
shape, same truncation messages; CreateObject composes its Parsed from
the returned record. Zero test files changed; Core.Net tests 764/0/0
unchanged; clean-room complete solution 11,271 passed / 4 skipped / 0
failed.

Per the Slice 5 contract (decision 2): extraction FIRST, as its own
bisectable commit, before any vendor code exists to call it.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 14:41:09 +02:00
parent 763b127ad9
commit 7bd4388b6b
2 changed files with 560 additions and 406 deletions

View file

@ -535,7 +535,9 @@ public static class CreateObject
// _bitfield (acclient.h:6431-6463) was previously discarded as
// "ObjectDescriptionFlags" at the WeenieHeader trailer.
uint? physicsState = null;
uint? objectDescriptionFlags = null;
// ObjectDescriptionFlags (retail PWD._bitfield) is captured inside
// PublicWeenieDescParser.Parse below (Slice 5.0 extraction) — no
// longer a top-level accumulator here.
// L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded
// when their PhysicsDescriptionFlag bits are set. Default values
// come from PhysicsBody constructors; these overrides drive the
@ -778,383 +780,20 @@ public static class CreateObject
DefaultScriptIntensity: defaultScriptIntensity,
Timestamps: timestamps);
// --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
// weenieFlags, Name, WeenieClassId(PackedDword),
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
// ObjectDescriptionFlags, align.
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
string? pluralName = null;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
int? wStackSize = null;
int? wMaxStackSize = null;
int? wBurden = null;
int? wItemsCapacity = null;
int? wContainersCapacity = null;
uint? wHookItemTypes = null;
uint? wHookType = null;
uint? wContainerId = null;
uint? wWielderId = null;
uint? wValidLocations = null;
uint? wCurrentWieldedLocation = null;
uint? wPriority = null;
int? wStructure = null;
int? wMaxStructure = null;
float? wWorkmanship = null;
if (body.Length - pos >= 4)
{
weenieFlags = ReadU32(body, ref pos);
try
{
name = ReadString16L(body, ref pos);
weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos);
if (body.Length - pos >= 4)
{
// ObjectDescriptionFlags = retail PWD._bitfield
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
// acclient_2013_pseudo_c.txt:406898-406918 read for
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
// discarded; now surfaced for the PvP collision rule.
objectDescriptionFlags = ReadU32(body, ref pos);
}
AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
// --- WeenieHeader optional tail: walk every conditional field
// in EXACT ACE write order (WorldObject_Networking.cs:87-219)
// so the cursor reaches IconOverlay + IconUnderlay.
//
// We MUST skip every field that precedes IconOverlay even when
// we don't need its value — each one occupies bytes on the wire
// and a cursor error here would desync ALL downstream optional
// reads for the rest of this entity's packet.
//
// Wire order (verified against ACE WorldObject_Networking.cs):
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
// 0x00000001 PluralName String16L CAPTURE
// 0x00000002 ItemsCapacity s8 -> int CAPTURE
// 0x00000004 ContainersCapacity s8 -> int CAPTURE
// 0x00000100 AmmoType u16 (skip)
// 0x00000008 Value u32 (skip)
// 0x00000010 Usable u32 KEPT
// 0x00000020 UseRadius f32 KEPT
// 0x00080000 TargetType u32 (skip)
// 0x00000080 UiEffects u32 CAPTURE (D.5.2)
// 0x00000200 CombatUse sbyte/1 byte (skip)
// 0x00000400 Structure u16 (skip)
// 0x00000800 MaxStructure u16 (skip)
// 0x00001000 StackSize u16 (skip)
// 0x00002000 MaxStackSize u16 (skip)
// 0x00004000 Container u32 (skip)
// 0x00008000 Wielder u32 (skip)
// 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 CAPTURE
// 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
// 0x00400000 Spell u16 (skip)
// 0x02000000 HouseOwner u32 CAPTURE (AP-129)
// 0x04000000 HouseRestrictions RestrictionDB CAPTURE (AP-129, variable-length)
// 0x20000000 HookItemTypes u32 CAPTURE
// 0x00000040 Monarch u32 CAPTURE (AP-129)
// 0x10000000 HookType u16 CAPTURE
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01:
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
// 0x80000000 MaterialType u32 CAPTURE
// weenieFlags2 bit 0x02:
// CooldownId u32 CAPTURE
// weenieFlags2 bit 0x04:
// CooldownDuration f64 CAPTURE
// weenieFlags2 bit 0x08:
// PetOwner u32 CAPTURE
//
// The entire walk is inside try/catch. A truncated packet degrades
// gracefully: whatever was parsed before the throw is kept, and
// IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is
// SAFE because IconComposer early-returns on id==0 per layer.
uint? useability = null;
float? useRadius = null;
uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
uint? petOwnerId = null;
uint? cooldownId = null;
double? cooldownDuration = null;
uint? materialType = null;
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields, previously skipped. HouseOwner/Monarch are plain u32
// ids; HouseRestrictions is the full RestrictionDB (open flag + allegiance
// monarch + guest table) — see AcDream.Core.Items.HouseRestrictionRecord.
uint? houseOwnerId = null;
uint? monarchId = null;
HouseRestrictionRecord? restrictions = null;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
// (ACE ObjectDescriptionFlag.IncludesSecondHeader matches).
// Earlier code had this as 0x80000000 — wrong bit, so the
// weenieFlags2 4-byte skip never fired for entities that
// actually had it set, corrupting downstream optional-tail
// offsets. Now correct. We CAPTURE weenieFlags2 now (instead
// of skipping) so we can gate IconUnderlay from bit 0x01.
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
if (hasSecondHeader)
{
if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
weenieFlags2 = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000001u) != 0) // PluralName
pluralName = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
// PublicWeenieDesc::UnPack @ 0x005AD52C uses MOVSX.
// ACE stores the creature/non-container sentinel -1 as
// 0xFF; retail retains -1 rather than turning it into 255.
wItemsCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
// PublicWeenieDesc::UnPack @ 0x005AD53E likewise uses
// MOVSX before assigning the 32-bit public field.
wContainersCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
ammoType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
wValue = (int)ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000010u) != 0) // Usable 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;
}
// ---- Extended walk: fields after UseRadius through IconOverlay ----
// Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17).
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
targetType = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
{
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
combatUse = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00004000u) != 0) // Container u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Container");
wContainerId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
wWielderId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
{
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
wValidLocations = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
wCurrentWieldedLocation = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
wPriority = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
radarBlipColor = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
radarBehavior = body[pos]; pos += 1;
}
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{
if (body.Length - pos < 2) throw new FormatException("trunc PScript");
pos += 2;
}
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{
if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
}
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
houseOwnerId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) ← CAPTURE (AP-129)
{
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
// protocol.xml:6270-6275): u32 Version, u32 Flags, u32 MonarchId, then a
// PHashTable<uint,uint> (NOT the u16-count/u16-size PackableHashTable shape
// this skip previously assumed — the byte total happened to match for any
// realistic guest-list size, but the count extraction was reading the wrong
// half of the packed dword). PHashTable's own header is ONE packed u32 whose
// low 24 bits are the entry count.
if (body.Length - pos < 12) throw new FormatException("trunc RestrictionDB header");
pos += 4; // Version — not consulted
uint flags = ReadU32(body, ref pos); // 0 = private, 1 = open
uint restrictionMonarchId = ReadU32(body, ref pos);
if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header");
uint packedSize = ReadU32(body, ref pos);
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8; // each entry: u32 guid + u32 value
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = ReadU32(body, ref pos);
uint permission = ReadU32(body, ref pos);
guests[guestId] = permission;
}
restrictions = new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: restrictionMonarchId,
Guests: guests);
}
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
wHookItemTypes = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
monarchId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc HookType");
wHookType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconOverlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
}
// IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags.
// weenieFlags2 is only present when hasSecondHeader (captured above).
if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconUnderlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
}
// PublicWeenieDesc::UnPack @ 0x005AD7AC..0x005AD7F8 and ACE
// SerializeCreateObject both place these fields after IconUnderlay.
if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc MaterialType");
materialType = ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
cooldownId = ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
{
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8;
}
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
{
if (body.Length - pos < 4) throw new FormatException("trunc PetOwner");
petOwnerId = ReadU32(body, ref pos);
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
// --- WeenieHeader / PublicWeenieDesc body ---
// Extracted (Slice 5.0, world-interaction-completion plan
// decision 2) into PublicWeenieDescParser.Parse
// (PublicWeenieDescBody.cs) so the Slice 5.1 vendor item-list
// parser (ApproachVendor, GameEvent 0x0062) can share this exact
// conditional field cascade instead of duplicating it.
// Extraction only — same fields, same order, same truncation
// handling as the original inline code (both inner try/catches
// there swallowed and returned a partial result; this call does
// the same).
var desc = PublicWeenieDescParser.Parse(body, ref pos);
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
textureChanges, subPalettes, basePaletteId, objScale, desc.Name, desc.ItemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
PositionSequence: positionSeq,
@ -1162,34 +801,34 @@ public static class CreateObject
ParentLocation: parentLocation,
PlacementId: placementId,
PhysicsState: physicsState,
ObjectDescriptionFlags: objectDescriptionFlags,
ObjectDescriptionFlags: desc.ObjectDescriptionFlags,
Friction: friction,
Elasticity: elasticity,
IconId: iconId,
Useability: useability, UseRadius: useRadius, TargetType: targetType,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects,
WeenieClassId: weenieClassId,
Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize,
Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity,
HookItemTypes: wHookItemTypes, HookType: wHookType,
ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
CombatUse: combatUse,
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
CooldownId: cooldownId,
CooldownDuration: cooldownDuration,
IconId: desc.IconId,
Useability: desc.Useability, UseRadius: desc.UseRadius, TargetType: desc.TargetType,
IconOverlayId: desc.IconOverlayId, IconUnderlayId: desc.IconUnderlayId,
UiEffects: desc.UiEffects,
WeenieClassId: desc.WeenieClassId,
Value: desc.Value, StackSize: desc.StackSize, StackSizeMax: desc.StackSizeMax,
Burden: desc.Burden, ItemsCapacity: desc.ItemsCapacity, ContainersCapacity: desc.ContainersCapacity,
HookItemTypes: desc.HookItemTypes, HookType: desc.HookType,
ContainerId: desc.ContainerId, WielderId: desc.WielderId,
ValidLocations: desc.ValidLocations, CurrentWieldedLocation: desc.CurrentWieldedLocation,
Priority: desc.Priority, Structure: desc.Structure, MaxStructure: desc.MaxStructure,
Workmanship: desc.Workmanship,
RadarBlipColor: desc.RadarBlipColor, RadarBehavior: desc.RadarBehavior,
CombatUse: desc.CombatUse,
PluralName: desc.PluralName,
PetOwnerId: desc.PetOwnerId,
AmmoType: desc.AmmoType,
SpellId: desc.SpellId,
CooldownId: desc.CooldownId,
CooldownDuration: desc.CooldownDuration,
Physics: physics,
MaterialType: materialType,
HouseOwnerId: houseOwnerId,
MonarchId: monarchId,
Restrictions: restrictions);
MaterialType: desc.MaterialType,
HouseOwnerId: desc.HouseOwnerId,
MonarchId: desc.MonarchId,
Restrictions: desc.Restrictions);
}
catch
{
@ -1271,7 +910,12 @@ public static class CreateObject
return new ModelData(basePaletteId, subPalettes, textureChanges, animParts);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
// Widened from `private` to `internal` (Slice 5.0, extraction only) so
// PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs) — the shared
// desc-body parser used by both CreateObject and the upcoming vendor
// item-list parser — can reuse these primitive readers instead of
// duplicating them. No behavior change.
internal static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint v = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
@ -1279,7 +923,7 @@ public static class CreateObject
return v;
}
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
internal static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
@ -1307,7 +951,7 @@ public static class CreateObject
/// written as a little-endian u32. The reader is the inverse — sees the high-
/// bit marker in the first u16, then reads the second u16.
/// </summary>
private static uint ReadPackedDword(ReadOnlySpan<byte> source, ref int pos)
internal static uint ReadPackedDword(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated PackedDword");
ushort first = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
@ -1331,13 +975,13 @@ public static class CreateObject
/// recover the original dat id. The zero sentinel is preserved as-is
/// (a 0 means "no value" and must not be turned into <c>knownType</c>).
/// </summary>
private static uint ReadPackedDwordOfKnownType(ReadOnlySpan<byte> source, ref int pos, uint knownType)
internal static uint ReadPackedDwordOfKnownType(ReadOnlySpan<byte> source, ref int pos, uint knownType)
{
uint packed = ReadPackedDword(source, ref pos);
return packed == 0 ? 0 : (packed | knownType);
}
private static void AlignTo4(ref int pos)
internal static void AlignTo4(ref int pos)
{
int padding = (4 - (pos & 3)) & 3;
pos += padding;

View file

@ -0,0 +1,510 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// The result of parsing a retail <c>PublicWeenieDesc</c> body: the fixed
/// weenieFlags/name/wcid/icon/itemType/objDescFlags prefix, plus every
/// conditional optional-tail field, in exact wire order.
///
/// <para>
/// This is the SAME parse retail's <c>PublicWeenieDesc::UnPack</c> performs
/// (and ACE's <c>WorldObject_Networking.SerializeCreateObject</c> writes) at
/// two distinct wire sites: the <c>CreateObject</c> GameMessage (0xF745,
/// where this body follows Guid + ModelData + PhysicsData), and the vendor
/// <c>ApproachVendor</c> item list (GameEvent 0x0062, where — per ACE's
/// <c>gamedataonly</c> serialization — this body follows only the packed
/// stack-size dword and the item's Guid; there is no ModelData/PhysicsData
/// at all for a vendor's listed items). See
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §A.2.
/// </para>
///
/// <para>
/// Extracted from <see cref="CreateObject.TryParse"/> (Slice 5.0,
/// <c>docs/plans/2026-07-23-world-interaction-completion.md</c> Slice 5
/// decision 2) so the Slice 5.1 vendor item-list parser can reuse this exact
/// ~300-line conditional field cascade instead of duplicating it.
/// Extraction only — the field set, wire order, and truncation/failure
/// behavior are unchanged from the original inline code.
/// </para>
/// </summary>
public readonly record struct PublicWeenieDescBody(
string? Name = null,
uint? ItemType = null,
uint? ObjectDescriptionFlags = null,
uint IconId = 0,
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null,
uint? Useability = null,
float? UseRadius = null,
uint? TargetType = null,
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
uint UiEffects = 0,
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null,
uint? MaterialType = null,
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// Parses the shared <see cref="PublicWeenieDescBody"/> wire shape. See the
/// type doc above for the two call sites and the extraction rationale.
/// </summary>
public static class PublicWeenieDescParser
{
/// <summary>
/// Parse a PublicWeenieDesc body starting at <paramref name="pos"/>.
/// Never throws: both the fixed-prefix read and the optional-tail walk
/// are wrapped in their own try/catch (matching the pre-extraction
/// inline code exactly), so a truncated body degrades gracefully —
/// whatever fields parsed before the truncation are kept, and the
/// cursor position reflects exactly how far the original inline
/// parser would have advanced.
/// </summary>
public static PublicWeenieDescBody Parse(ReadOnlySpan<byte> body, ref int pos)
{
// --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
// weenieFlags, Name, WeenieClassId(PackedDword),
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
// ObjectDescriptionFlags, align.
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
string? pluralName = null;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
int? wStackSize = null;
int? wMaxStackSize = null;
int? wBurden = null;
int? wItemsCapacity = null;
int? wContainersCapacity = null;
uint? wHookItemTypes = null;
uint? wHookType = null;
uint? wContainerId = null;
uint? wWielderId = null;
uint? wValidLocations = null;
uint? wCurrentWieldedLocation = null;
uint? wPriority = null;
int? wStructure = null;
int? wMaxStructure = null;
float? wWorkmanship = null;
uint? objectDescriptionFlags = null;
if (body.Length - pos >= 4)
{
weenieFlags = CreateObject.ReadU32(body, ref pos);
try
{
name = CreateObject.ReadString16L(body, ref pos);
weenieClassId = CreateObject.ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
iconId = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.IconTypePrefix);
if (body.Length - pos >= 4)
itemType = CreateObject.ReadU32(body, ref pos);
if (body.Length - pos >= 4)
{
// ObjectDescriptionFlags = retail PWD._bitfield
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
// acclient_2013_pseudo_c.txt:406898-406918 read for
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
// discarded; now surfaced for the PvP collision rule.
objectDescriptionFlags = CreateObject.ReadU32(body, ref pos);
}
CreateObject.AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
// --- WeenieHeader optional tail: walk every conditional field
// in EXACT ACE write order (WorldObject_Networking.cs:87-219)
// so the cursor reaches IconOverlay + IconUnderlay.
//
// We MUST skip every field that precedes IconOverlay even when
// we don't need its value — each one occupies bytes on the wire
// and a cursor error here would desync ALL downstream optional
// reads for the rest of this entity's packet.
//
// Wire order (verified against ACE WorldObject_Networking.cs):
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
// 0x00000001 PluralName String16L CAPTURE
// 0x00000002 ItemsCapacity s8 -> int CAPTURE
// 0x00000004 ContainersCapacity s8 -> int CAPTURE
// 0x00000100 AmmoType u16 (skip)
// 0x00000008 Value u32 (skip)
// 0x00000010 Usable u32 KEPT
// 0x00000020 UseRadius f32 KEPT
// 0x00080000 TargetType u32 (skip)
// 0x00000080 UiEffects u32 CAPTURE (D.5.2)
// 0x00000200 CombatUse sbyte/1 byte (skip)
// 0x00000400 Structure u16 (skip)
// 0x00000800 MaxStructure u16 (skip)
// 0x00001000 StackSize u16 (skip)
// 0x00002000 MaxStackSize u16 (skip)
// 0x00004000 Container u32 (skip)
// 0x00008000 Wielder u32 (skip)
// 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 CAPTURE
// 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
// 0x00400000 Spell u16 (skip)
// 0x02000000 HouseOwner u32 CAPTURE (AP-129)
// 0x04000000 HouseRestrictions RestrictionDB CAPTURE (AP-129, variable-length)
// 0x20000000 HookItemTypes u32 CAPTURE
// 0x00000040 Monarch u32 CAPTURE (AP-129)
// 0x10000000 HookType u16 CAPTURE
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01:
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
// 0x80000000 MaterialType u32 CAPTURE
// weenieFlags2 bit 0x02:
// CooldownId u32 CAPTURE
// weenieFlags2 bit 0x04:
// CooldownDuration f64 CAPTURE
// weenieFlags2 bit 0x08:
// PetOwner u32 CAPTURE
//
// The entire walk is inside try/catch. A truncated packet degrades
// gracefully: whatever was parsed before the throw is kept, and
// IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is
// SAFE because IconComposer early-returns on id==0 per layer.
uint? useability = null;
float? useRadius = null;
uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
uint? petOwnerId = null;
uint? cooldownId = null;
double? cooldownDuration = null;
uint? materialType = null;
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields, previously skipped. HouseOwner/Monarch are plain u32
// ids; HouseRestrictions is the full RestrictionDB (open flag + allegiance
// monarch + guest table) — see AcDream.Core.Items.HouseRestrictionRecord.
uint? houseOwnerId = null;
uint? monarchId = null;
HouseRestrictionRecord? restrictions = null;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
// (ACE ObjectDescriptionFlag.IncludesSecondHeader matches).
// Earlier code had this as 0x80000000 — wrong bit, so the
// weenieFlags2 4-byte skip never fired for entities that
// actually had it set, corrupting downstream optional-tail
// offsets. Now correct. We CAPTURE weenieFlags2 now (instead
// of skipping) so we can gate IconUnderlay from bit 0x01.
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
if (hasSecondHeader)
{
if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
weenieFlags2 = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000001u) != 0) // PluralName
pluralName = CreateObject.ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
// PublicWeenieDesc::UnPack @ 0x005AD52C uses MOVSX.
// ACE stores the creature/non-container sentinel -1 as
// 0xFF; retail retains -1 rather than turning it into 255.
wItemsCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
// PublicWeenieDesc::UnPack @ 0x005AD53E likewise uses
// MOVSX before assigning the 32-bit public field.
wContainersCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
ammoType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
wValue = (int)CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = CreateObject.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;
}
// ---- Extended walk: fields after UseRadius through IconOverlay ----
// Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17).
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
targetType = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
{
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
combatUse = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00004000u) != 0) // Container u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Container");
wContainerId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
wWielderId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
{
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
wValidLocations = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
wCurrentWieldedLocation = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
wPriority = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
radarBlipColor = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
radarBehavior = body[pos]; pos += 1;
}
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{
if (body.Length - pos < 2) throw new FormatException("trunc PScript");
pos += 2;
}
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{
if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
}
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
houseOwnerId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) ← CAPTURE (AP-129)
{
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
// protocol.xml:6270-6275): u32 Version, u32 Flags, u32 MonarchId, then a
// PHashTable<uint,uint> (NOT the u16-count/u16-size PackableHashTable shape
// this skip previously assumed — the byte total happened to match for any
// realistic guest-list size, but the count extraction was reading the wrong
// half of the packed dword). PHashTable's own header is ONE packed u32 whose
// low 24 bits are the entry count.
if (body.Length - pos < 12) throw new FormatException("trunc RestrictionDB header");
pos += 4; // Version — not consulted
uint flags = CreateObject.ReadU32(body, ref pos); // 0 = private, 1 = open
uint restrictionMonarchId = CreateObject.ReadU32(body, ref pos);
if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header");
uint packedSize = CreateObject.ReadU32(body, ref pos);
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8; // each entry: u32 guid + u32 value
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = CreateObject.ReadU32(body, ref pos);
uint permission = CreateObject.ReadU32(body, ref pos);
guests[guestId] = permission;
}
restrictions = new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: restrictionMonarchId,
Guests: guests);
}
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
wHookItemTypes = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
monarchId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc HookType");
wHookType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconOverlayId = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.IconTypePrefix);
}
// IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags.
// weenieFlags2 is only present when hasSecondHeader (captured above).
if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconUnderlayId = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.IconTypePrefix);
}
// PublicWeenieDesc::UnPack @ 0x005AD7AC..0x005AD7F8 and ACE
// SerializeCreateObject both place these fields after IconUnderlay.
if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc MaterialType");
materialType = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
cooldownId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
{
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8;
}
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
{
if (body.Length - pos < 4) throw new FormatException("trunc PetOwner");
petOwnerId = CreateObject.ReadU32(body, ref pos);
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
return new PublicWeenieDescBody(
Name: name,
ItemType: itemType,
ObjectDescriptionFlags: objectDescriptionFlags,
IconId: iconId,
WeenieClassId: weenieClassId,
Value: wValue,
StackSize: wStackSize,
StackSizeMax: wMaxStackSize,
Burden: wBurden,
ItemsCapacity: wItemsCapacity,
ContainersCapacity: wContainersCapacity,
HookItemTypes: wHookItemTypes,
HookType: wHookType,
ContainerId: wContainerId,
WielderId: wWielderId,
ValidLocations: wValidLocations,
CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority,
Structure: wStructure,
MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
Useability: useability,
UseRadius: useRadius,
TargetType: targetType,
IconOverlayId: iconOverlayId,
IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects,
RadarBlipColor: radarBlipColor,
RadarBehavior: radarBehavior,
CombatUse: combatUse,
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
CooldownId: cooldownId,
CooldownDuration: cooldownDuration,
MaterialType: materialType,
HouseOwnerId: houseOwnerId,
MonarchId: monarchId,
Restrictions: restrictions);
}
}