acdream/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs
Erik b5b230c860 feat(ui): preserve exact retail shortcut records
Carry signed index, object id, and raw spell word losslessly through PlayerDescription, session storage, drag mutation, and AddShortcut wire serialization while keeping gmToolbarUI object-only. Retire AP-103 and record the live ACE relog persistence gate.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 09:17:33 +02:00

854 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser for <c>GameEventType.PlayerDescription (0x0013)</c> — the
/// load-bearing post-EnterWorld message that hands the client the local
/// player's full state (properties, attributes, vitals, skills, spells,
/// enchantments, options, hotbars, inventory, equipped objects).
///
/// <para>
/// <b>Wire format reference:</b> holtburger
/// <c>crates/holtburger-protocol/src/messages/player/events.rs</c>
/// (<c>PlayerDescriptionEventData::unpack</c>, lines 220-625). ACE
/// producer: <c>GameEventPlayerDescription.WriteEventBody</c>.
/// </para>
///
/// <para>
/// <b>NOT</b> the same wire format as
/// <c>IdentifyObjectResponse (0x00C9)</c> — that uses
/// <c>AppraiseInfo.Write</c>'s flag-driven format
/// (<see cref="AppraiseInfoParser"/>). PlayerDescription has its own
/// hand-written body with property hashtables (<c>DescriptionPropertyFlag</c>),
/// vector-flag-gated blocks (<c>DescriptionVectorFlag</c>), and a long
/// trailer of options + hotbars + inventory. Don't conflate the two.
/// </para>
///
/// <para>
/// <b>Body layout</b> (all little-endian):
/// </para>
/// <code>
/// // Header (8 B):
/// u32 propertyFlags // DescriptionPropertyFlag — gates the property hashtables
/// u32 wee_type // ACE WeenieType enum
///
/// // Property hashtables — each gated on a propertyFlags bit. Header is
/// // u16 count, u16 buckets then `count` × (u32 key, value).
/// if (propertyFlags &amp; PROPERTY_INT32): table&lt;i32&gt;
/// if (propertyFlags &amp; PROPERTY_INT64): table&lt;i64&gt;
/// if (propertyFlags &amp; PROPERTY_BOOL): table&lt;u32&gt; (treated as bool)
/// if (propertyFlags &amp; PROPERTY_DOUBLE): table&lt;f64&gt;
/// if (propertyFlags &amp; PROPERTY_STRING): table&lt;string16L&gt;
/// if (propertyFlags &amp; PROPERTY_DID): table&lt;u32&gt;
/// if (propertyFlags &amp; PROPERTY_IID): table&lt;u32&gt;
/// if (propertyFlags &amp; POSITION): table&lt;WorldPosition (32 B)&gt;
///
/// // Vector flags + has_health (8 B):
/// u32 vectorFlags // DescriptionVectorFlag — Attribute=0x01, Skill=0x02, Spell=0x100, Enchantment=0x200
/// u32 has_health // 0 or 1
///
/// // Attribute block (gated on Attribute vector flag):
/// u32 attributeFlags // AttributeCache bitmask — bit (i-1) ⇒ entry i present
/// for i = 1..=6 (primary attrs Strength..Self), if bit set:
/// u32 ranks, u32 start, u32 xp // 12 B per entry
/// for i = 7..=9 (vitals Health/Stamina/Mana), if bit set:
/// u32 ranks, u32 start, u32 xp, u32 current // 16 B per entry
///
/// // Skills (gated on Skill vector flag): packed table&lt;CreatureSkill (32 B)&gt;
/// // Spells (gated on Spell vector flag): packed table&lt;u32 spellId, f32 power&gt;
/// // Enchantments (gated on Enchantment vector flag): EnchantmentMask + per-section count + N×Enchantment(60 or 64 B)
/// // ... then options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped
/// </code>
///
/// <para>
/// <b>Scope of this port:</b> walks all sections through enchantments;
/// the trailing options / inventory / equipped sections are partial
/// because they involve variable-length opaque blobs that holtburger
/// uses heuristics to skip. The early termination still leaves the
/// parser useful for its primary purpose — populating
/// <see cref="AcDream.Core.Player.LocalPlayerState"/> from the
/// attribute block — and a follow-up issue can extend it once the
/// remaining sections become consumed by panels.
/// </para>
/// </summary>
public static class PlayerDescriptionParser
{
[Flags]
public enum DescriptionPropertyFlag : uint
{
None = 0x0000,
PropertyInt32 = 0x0001,
PropertyBool = 0x0002,
PropertyDouble = 0x0004,
PropertyDid = 0x0008,
PropertyString = 0x0010,
Position = 0x0020,
PropertyIid = 0x0040,
PropertyInt64 = 0x0080,
}
[Flags]
public enum DescriptionVectorFlag : uint
{
None = 0x0000,
Attribute = 0x0001,
Skill = 0x0002,
Spell = 0x0100,
Enchantment = 0x0200,
}
/// <summary>One entry from the attribute block. Entries 1-6 are
/// primary attributes; 7-9 are vitals (Current is non-null only for
/// vitals).</summary>
public readonly record struct AttributeEntry(
uint AtType,
uint Ranks,
uint Start,
uint Xp,
uint? Current);
/// <summary>One skill entry — see ACE
/// <c>CreatureSkill</c> + holtburger
/// <c>messages/player/skills.rs</c>.</summary>
public readonly record struct SkillEntry(
uint SkillId,
uint Ranks,
uint Status,
uint Xp,
uint Init,
uint Resistance,
double LastUsed);
public readonly record struct WorldPosition(
uint LandblockId,
float X, float Y, float Z,
float Qw, float Qx, float Qy, float Qz);
/// <summary>One enchantment entry from the trailer enchantment
/// block. Wire layout per holtburger
/// <c>messages/magic/types.rs:40</c> (60 or 64 bytes per record).
/// </summary>
public readonly record struct EnchantmentEntry(
ushort SpellId,
ushort Layer,
ushort SpellCategory,
ushort HasSpellSetId,
uint PowerLevel,
double StartTime,
double Duration,
uint CasterGuid,
float DegradeModifier,
float DegradeLimit,
double LastTimeDegraded,
uint StatModType,
uint StatModKey,
float StatModValue,
uint? SpellSetId,
EnchantmentBucket Bucket);
/// <summary>Bucket the enchantment came from in the
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
/// stat-mod aggregator multiplies or adds. **Bit values match
/// ACE's `EnchantmentMask` enum** at
/// `references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs` —
/// Vitae = 0x4 (bit 2), Cooldown = 0x8 (bit 3). Critically NOT the
/// reverse: getting these wrong causes the parser to read a Vitae
/// singleton as a Cooldown list-with-count and fail.</summary>
public enum EnchantmentBucket : uint
{
Multiplicative = 1,
Additive = 2,
Vitae = 4,
Cooldown = 8,
}
[Flags]
public enum EnchantmentMask : uint
{
None = 0,
Multiplicative = 0x01,
Additive = 0x02,
Vitae = 0x04,
Cooldown = 0x08,
}
/// <summary>Bitmask of which optional trailer sections are present in
/// the PlayerDescription wire payload. Holtburger
/// <c>events.rs:503-607</c>; ACE <c>CharacterOptionDataFlag</c>.</summary>
[Flags]
public enum CharacterOptionDataFlag : uint
{
None = 0,
Shortcut = 0x00000001,
SquelchList = 0x00000002,
MultiSpellList = 0x00000004,
DesiredComps = 0x00000008,
ExtendedMultiSpellLists = 0x00000010,
SpellbookFilters = 0x00000020,
CharacterOptions2 = 0x00000040,
TimestampFormat = 0x00000080,
GenericQualitiesData = 0x00000100,
GameplayOptions = 0x00000200,
SpellLists8 = 0x00000400,
}
/// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>
/// in <c>unpack_inventory_and_equipped_strict</c>.</summary>
public readonly record struct InventoryEntry(
uint Guid,
uint ContainerType);
/// <summary>One equipped object entry. Holtburger
/// <c>events.rs:180-190</c>: <c>(Guid guid, u32 loc, u32 prio)</c>.
/// <paramref name="EquipLocation"/> is an <c>EquipMask</c> bitfield;
/// <paramref name="Priority"/> orders overlapping equips in the
/// same slot.</summary>
public readonly record struct EquippedEntry(
uint Guid,
uint EquipLocation,
uint Priority);
/// <summary>Result of <see cref="TryParse"/>. Trailer fields
/// (<c>OptionFlags</c> through <c>Equipped</c>) may be partially
/// populated when <see cref="TrailerTruncated"/> is <c>true</c> —
/// the parse degraded gracefully rather than discarding upstream
/// attribute / skill / spell / enchantment data. Callers that
/// require all-or-nothing trailer semantics should ignore the
/// trailer fields when this flag is set.</summary>
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
DescriptionVectorFlag VectorFlags,
bool HasHealth,
PropertyBundle Properties,
IReadOnlyDictionary<uint, WorldPosition> Positions,
IReadOnlyList<AttributeEntry> Attributes,
IReadOnlyList<SkillEntry> Skills,
IReadOnlyDictionary<uint, float> Spells,
IReadOnlyList<EnchantmentEntry> Enchantments,
CharacterOptionDataFlag OptionFlags,
uint Options1,
uint Options2,
IReadOnlyList<ShortcutEntry> Shortcuts,
IReadOnlyList<IReadOnlyList<uint>> HotbarSpells,
IReadOnlyList<(uint Id, uint Amount)> DesiredComps,
uint SpellbookFilters,
ReadOnlyMemory<byte> GameplayOptions,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<EquippedEntry> Equipped,
bool TrailerTruncated);
/// <summary>
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
/// stripped by <see cref="GameEventEnvelope.TryParse"/> so the
/// payload starts at the property-flags placeholder u32 — i.e. the
/// first byte after the GameEvent header.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
int pos = 0;
try
{
DescriptionPropertyFlag propertyFlags = (DescriptionPropertyFlag)ReadU32(payload, ref pos);
uint weenieType = ReadU32(payload, ref pos);
var bundle = new PropertyBundle();
var positions = new Dictionary<uint, WorldPosition>();
var attributes = new List<AttributeEntry>();
var skills = new List<SkillEntry>();
var spells = new Dictionary<uint, float>();
var enchantments = new List<EnchantmentEntry>();
// ── Property hashtables (each gated on a flag bit) ──────────────
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32))
ReadIntTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt64))
ReadInt64Table(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyBool))
ReadBoolTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyDouble))
ReadDoubleTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyString))
ReadStringTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyDid))
ReadDataIdTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyIid))
ReadInstanceIdTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.Position))
ReadPositionTable(payload, ref pos, positions);
// ── Vector flags + has_health ───────────────────────────────────
if (payload.Length - pos < 8) return BuildPartial(weenieType, propertyFlags,
DescriptionVectorFlag.None, hasHealth: false, bundle, positions, attributes, skills, spells);
DescriptionVectorFlag vectorFlags = (DescriptionVectorFlag)ReadU32(payload, ref pos);
bool hasHealth = ReadU32(payload, ref pos) != 0;
// ── Attribute block (Health/Stam/Mana live at ids 7/8/9) ───────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Attribute))
ReadAttributeBlock(payload, ref pos, attributes);
// ── Skills ──────────────────────────────────────────────────────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Skill))
ReadSkillTable(payload, ref pos, skills);
// ── Spells (learned spellbook) ──────────────────────────────────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell))
ReadSpellTable(payload, ref pos, spells);
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
ReadEnchantmentBlock(payload, ref pos, enchantments);
// ── Trailer (Issue #13): options + shortcuts + hotbars + inventory ──
// Wrapped in its own try/catch — a malformed trailer must not destroy
// the attribute / skill / spell / enchantment data we already extracted.
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
ReadOnlyMemory<byte> gameplayOptions = ReadOnlyMemory<byte>.Empty;
List<InventoryEntry> inventory = new();
List<EquippedEntry> equipped = new();
bool trailerTruncated = false;
try
{
if (payload.Length - pos >= 8)
{
optionFlags = (CharacterOptionDataFlag)ReadU32(payload, ref pos);
options1 = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.Shortcut))
{
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable shortcut count");
for (uint i = 0; i < count; i++)
{
int index = unchecked((int)ReadU32(payload, ref pos));
uint objectId = ReadU32(payload, ref pos);
uint spellId = ReadU32(payload, ref pos);
shortcuts.Add(new ShortcutEntry(index, objectId, spellId));
}
}
if (optionFlags.HasFlag(CharacterOptionDataFlag.SpellLists8))
{
for (int b = 0; b < 8; b++)
{
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable hotbar count");
var list = new List<uint>((int)count);
for (uint i = 0; i < count; i++)
list.Add(ReadU32(payload, ref pos));
hotbarSpells.Add(list);
}
}
else if (payload.Length - pos >= 4)
{
// Legacy single-list fallback (holtburger events.rs:544-556).
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable hotbar count");
var list = new List<uint>((int)count);
for (uint i = 0; i < count; i++)
list.Add(ReadU32(payload, ref pos));
hotbarSpells.Add(list);
}
if (optionFlags.HasFlag(CharacterOptionDataFlag.DesiredComps))
{
// holtburger events.rs:558-574 — u16 count + u16 padding (4-byte header).
if (payload.Length - pos < 4) throw new FormatException("truncated desired_comps header");
ushort count = ReadU16(payload, ref pos);
ReadU16(payload, ref pos); // padding/buckets — discarded
if (count > 10_000) throw new FormatException("unreasonable desired_comps count");
for (int i = 0; i < count; i++)
{
uint id = ReadU32(payload, ref pos);
uint amt = ReadU32(payload, ref pos);
desiredComps.Add((id, amt));
}
}
// holtburger events.rs:576-582 — spellbook_filters is optional; defaults
// to 0 if EOF.
if (payload.Length - pos >= 4)
spellbookFilters = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2))
options2 = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.GameplayOptions))
{
int gameplayStart = pos;
if (TryHeuristicInventoryStart(payload, gameplayStart, out int invStart, out int end,
inventory, equipped))
{
gameplayOptions = payload.Slice(gameplayStart, invStart - gameplayStart).ToArray();
pos = end;
}
}
else
{
// Strict path: inventory + equipped follow directly.
TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
}
}
}
catch (FormatException ex)
{
// Trailer corrupted — keep what we have and flag it. Once
// Tasks 3-9 add list reads inside this try block, partial
// lists may be visible to callers; TrailerTruncated tells
// them so they can ignore the trailer if they need all-or-
// nothing semantics.
trailerTruncated = true;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1")
System.Console.WriteLine($"PlayerDescriptionParser: trailer FormatException at pos={pos}/{payload.Length}: {ex.Message}");
}
return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells, enchantments,
optionFlags, options1, options2,
shortcuts, hotbarSpells, desiredComps, spellbookFilters,
gameplayOptions, inventory, equipped, trailerTruncated);
}
catch (FormatException ex)
{
// Truncation mid-walk — return null so caller knows parse failed.
// Diagnostic when ACDREAM_DUMP_VITALS=1 surfaces the failure point.
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1")
System.Console.WriteLine($"PlayerDescriptionParser: FormatException at pos={pos}/{payload.Length}: {ex.Message}");
return null;
}
}
private static Parsed BuildPartial(
uint weenieType, DescriptionPropertyFlag pFlags, DescriptionVectorFlag vFlags,
bool hasHealth, PropertyBundle bundle,
Dictionary<uint, WorldPosition> positions,
List<AttributeEntry> attributes, List<SkillEntry> skills,
Dictionary<uint, float> spells)
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells,
System.Array.Empty<EnchantmentEntry>(),
CharacterOptionDataFlag.None, 0u, 0u,
System.Array.Empty<ShortcutEntry>(),
System.Array.Empty<IReadOnlyList<uint>>(),
System.Array.Empty<(uint, uint)>(),
0u,
ReadOnlyMemory<byte>.Empty,
System.Array.Empty<InventoryEntry>(),
System.Array.Empty<EquippedEntry>(),
TrailerTruncated: false);
}
// ── Attribute block reader ──────────────────────────────────────────────
private static void ReadAttributeBlock(
ReadOnlySpan<byte> src, ref int pos, List<AttributeEntry> attributes)
{
// u32 attributeFlags bitmask — see ACE AttributeCache:
// bit 0 → Strength (id=1), ..., bit 5 → Self (id=6),
// bit 6 → Health (id=7), bit 7 → Stamina (id=8), bit 8 → Mana (id=9).
uint attrFlags = ReadU32(src, ref pos);
// Primary attributes (1..=6): 12-byte entries (ranks, start, xp).
for (uint i = 1; i <= 6; i++)
{
uint bit = 1u << (int)(i - 1);
if ((attrFlags & bit) == 0) continue;
uint ranks = ReadU32(src, ref pos);
uint start = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
attributes.Add(new AttributeEntry(i, ranks, start, xp, Current: null));
}
// Vitals (7..=9): 16-byte entries (ranks, start, xp, current).
for (uint i = 7; i <= 9; i++)
{
uint bit = 1u << (int)(i - 1);
if ((attrFlags & bit) == 0) continue;
uint ranks = ReadU32(src, ref pos);
uint start = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
uint current = ReadU32(src, ref pos);
attributes.Add(new AttributeEntry(i, ranks, start, xp, current));
}
}
// ── Property hashtable readers ──────────────────────────────────────────
private static (ushort count, ushort buckets) ReadHeader(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated table header");
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
ushort buckets = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos + 2));
pos += 4;
return (count, buckets);
}
private static void ReadIntTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
int val = (int)ReadU32(src, ref pos);
bundle.Ints[key] = val;
}
}
private static void ReadInt64Table(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
long val = ReadI64(src, ref pos);
bundle.Int64s[key] = val;
}
}
private static void ReadBoolTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.Bools[key] = val != 0;
}
}
private static void ReadDoubleTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
double val = ReadF64(src, ref pos);
bundle.Floats[key] = val;
}
}
private static void ReadStringTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
string val = ReadString16L(src, ref pos);
bundle.Strings[key] = val;
}
}
private static void ReadDataIdTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.DataIds[key] = val;
}
}
private static void ReadInstanceIdTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.InstanceIds[key] = val;
}
}
// ── Position table (one position keyed by PositionType u32) ─────────────
private static void ReadPositionTable(
ReadOnlySpan<byte> src, ref int pos, Dictionary<uint, WorldPosition> positions)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
positions[key] = ReadWorldPosition(src, ref pos);
}
}
private static WorldPosition ReadWorldPosition(ReadOnlySpan<byte> src, ref int pos)
{
// 32 bytes: u32 landblockId + 3 f32 coords + 4 f32 quaternion (w,x,y,z).
uint landblockId = ReadU32(src, ref pos);
float x = ReadF32(src, ref pos);
float y = ReadF32(src, ref pos);
float z = ReadF32(src, ref pos);
float qw = ReadF32(src, ref pos);
float qx = ReadF32(src, ref pos);
float qy = ReadF32(src, ref pos);
float qz = ReadF32(src, ref pos);
return new WorldPosition(landblockId, x, y, z, qw, qx, qy, qz);
}
// ── Skill table ─────────────────────────────────────────────────────────
private static void ReadSkillTable(ReadOnlySpan<byte> src, ref int pos, List<SkillEntry> skills)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
// 32-byte CreatureSkill — holtburger skills.rs:23-46.
uint sk_type = ReadU32(src, ref pos);
// ranks is u16 not u32; constant 1u then status u32.
if (src.Length - pos < 4) throw new FormatException("truncated skill ranks/const");
uint ranks = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
// u16 const_one — discarded.
BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
uint status = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
uint init = ReadU32(src, ref pos);
uint resistance = ReadU32(src, ref pos);
double lastUsed = ReadF64(src, ref pos);
skills.Add(new SkillEntry(sk_type, ranks, status, xp, init, resistance, lastUsed));
}
}
// ── Spell table (learned spells) ────────────────────────────────────────
private static void ReadSpellTable(
ReadOnlySpan<byte> src, ref int pos, Dictionary<uint, float> spells)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint spellId = ReadU32(src, ref pos);
float power = ReadF32(src, ref pos);
spells[spellId] = power;
}
}
// ── Enchantment block reader ────────────────────────────────────────────
// Wire format per holtburger events.rs:462-501 + magic/types.rs:40:
// u32 EnchantmentMask
// if mask & MULTIPLICATIVE: u32 count, count × Enchantment(60-64 B)
// if mask & ADDITIVE: u32 count, count × Enchantment(60-64 B)
// if mask & COOLDOWN: u32 count, count × Enchantment(60-64 B)
// if mask & VITAE: single Enchantment(60-64 B)
private static void ReadEnchantmentBlock(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> enchantments)
{
if (src.Length - pos < 4) return;
EnchantmentMask mask = (EnchantmentMask)ReadU32(src, ref pos);
if (mask.HasFlag(EnchantmentMask.Multiplicative))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Multiplicative);
if (mask.HasFlag(EnchantmentMask.Additive))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Additive);
if (mask.HasFlag(EnchantmentMask.Cooldown))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Cooldown);
if (mask.HasFlag(EnchantmentMask.Vitae))
{
// Vitae is a single enchantment (no count prefix).
enchantments.Add(ReadEnchantment(src, ref pos, EnchantmentBucket.Vitae));
}
}
private static void ReadEnchantmentList(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
ReadOnlySpan<byte> src, ref int pos, EnchantmentBucket bucket)
{
// Holtburger Enchantment::unpack — 28 + 4 (Guid) + 28 + (4 if has_set_id) bytes:
// u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id,
// u32 power_level, f64 start_time, f64 duration, (28 bytes total)
// u32 caster_guid, (4)
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if
/// the bytes from <paramref name="pos"/> parse cleanly per holtburger
/// events.rs:143-193 (<c>unpack_inventory_and_equipped_strict</c>).
/// Counts capped at 10,000; inventory ContainerType must be 0..2
/// (NonContainer / Container / Foci).</summary>
private static bool TryUnpackInventoryStrict(
ReadOnlySpan<byte> src, ref int pos,
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
{
inventory.Clear();
equipped.Clear();
if (pos + 4 > src.Length) return false;
uint invCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
if (invCount > 10_000) return false;
for (uint i = 0; i < invCount; i++)
{
if (pos + 8 > src.Length) return false;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
uint wtype = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
pos += 8;
if (wtype > 2) return false;
inventory.Add(new InventoryEntry(guid, wtype));
}
if (pos + 4 > src.Length) return false;
uint eqCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
if (eqCount > 10_000) return false;
for (uint i = 0; i < eqCount; i++)
{
if (pos + 12 > src.Length) return false;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
uint loc = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
uint prio = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 8));
pos += 12;
equipped.Add(new EquippedEntry(guid, loc, prio));
}
return true;
}
/// <summary>4-byte-aligned forward scan from <paramref name="start"/>
/// looking for the first offset where <c>TryUnpackInventoryStrict</c>
/// consumes exactly to end-of-buffer. Mirrors holtburger
/// <c>find_inventory_start_after_gameplay_options</c> in events.rs:195-218.</summary>
private static bool TryHeuristicInventoryStart(
ReadOnlySpan<byte> src, int start,
out int invStart, out int end,
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
{
invStart = end = 0;
inventory.Clear();
equipped.Clear();
if (start + 8 > src.Length) return false;
int candidate = start;
int misalign = candidate & 3;
if (misalign != 0) candidate += 4 - misalign;
int last = src.Length - 8;
while (candidate <= last)
{
int tmp = candidate;
var tmpInv = new List<InventoryEntry>();
var tmpEq = new List<EquippedEntry>();
if (TryUnpackInventoryStrict(src, ref tmp, tmpInv, tmpEq) && tmp == src.Length)
{
invStart = candidate;
end = tmp;
inventory.AddRange(tmpInv);
equipped.AddRange(tmpEq);
return true;
}
candidate += 4;
}
return false;
}
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated u16");
ushort v = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
return v;
}
// ── Primitive readers ───────────────────────────────────────────────────
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated u32");
uint v = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
return v;
}
private static long ReadI64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated i64");
long v = BinaryPrimitives.ReadInt64LittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static float ReadF32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated f32");
float v = BinaryPrimitives.ReadSingleLittleEndian(src.Slice(pos));
pos += 4;
return v;
}
private static double ReadF64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated f64");
double v = BinaryPrimitives.ReadDoubleLittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static string ReadString16L(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated string length");
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
if (src.Length - pos < len) throw new FormatException("truncated string body");
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
string v = Encoding.GetEncoding(1252).GetString(src.Slice(pos, len));
pos += len;
// String16L records pad to 4-byte alignment per AppraiseInfoParser convention.
int record = 2 + len;
int pad = (4 - (record & 3)) & 3;
pos += pad;
return v;
}
}