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>
854 lines
37 KiB
C#
854 lines
37 KiB
C#
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 & PROPERTY_INT32): table<i32>
|
||
/// if (propertyFlags & PROPERTY_INT64): table<i64>
|
||
/// if (propertyFlags & PROPERTY_BOOL): table<u32> (treated as bool)
|
||
/// if (propertyFlags & PROPERTY_DOUBLE): table<f64>
|
||
/// if (propertyFlags & PROPERTY_STRING): table<string16L>
|
||
/// if (propertyFlags & PROPERTY_DID): table<u32>
|
||
/// if (propertyFlags & PROPERTY_IID): table<u32>
|
||
/// if (propertyFlags & POSITION): table<WorldPosition (32 B)>
|
||
///
|
||
/// // 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<CreatureSkill (32 B)>
|
||
/// // Spells (gated on Spell vector flag): packed table<u32 spellId, f32 power>
|
||
/// // 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 <= 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;
|
||
}
|
||
}
|