feat(net): #13 read OptionFlags + Options1 after enchantments

First step of the PD trailer walk. Wraps trailer reads in their own
try/catch so a malformed trailer does not null out the upstream
attribute/skill/spell/enchantment data we already extracted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 08:18:38 +02:00
parent 65870349a8
commit becbde60a4
2 changed files with 59 additions and 14 deletions

View file

@ -309,26 +309,42 @@ public static class PlayerDescriptionParser
ReadSpellTable(payload, ref pos, spells); ReadSpellTable(payload, ref pos, spells);
// ── Enchantments (Issue #7 / #12) ─────────────────────────────── // ── Enchantments (Issue #7 / #12) ───────────────────────────────
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
// Holtburger events.rs:462-501. After this block come options /
// shortcuts / hotbars / inventory / equipped — those need a
// heuristic walker for the variable-length gameplay_options blob.
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
// partial parses still populate enchantments.
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment)) if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
ReadEnchantmentBlock(payload, ref pos, enchantments); 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();
try
{
if (payload.Length - pos >= 8)
{
optionFlags = (CharacterOptionDataFlag)ReadU32(payload, ref pos);
options1 = ReadU32(payload, ref pos);
}
}
catch (FormatException)
{
// Trailer corrupted — keep what we have and return.
}
return new Parsed( return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth, weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells, enchantments, bundle, positions, attributes, skills, spells, enchantments,
CharacterOptionDataFlag.None, 0u, 0u, optionFlags, options1, options2,
System.Array.Empty<ShortcutEntry>(), shortcuts, hotbarSpells, desiredComps, spellbookFilters,
System.Array.Empty<IReadOnlyList<uint>>(), gameplayOptions, inventory, equipped);
System.Array.Empty<(uint, uint)>(),
0u,
ReadOnlyMemory<byte>.Empty,
System.Array.Empty<InventoryEntry>(),
System.Array.Empty<EquippedEntry>());
} }
catch (FormatException ex) catch (FormatException ex)
{ {

View file

@ -334,4 +334,33 @@ public sealed class PlayerDescriptionParserTests
Assert.Equal(2.0f, parsed.Value.Spells[1234u]); Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
Assert.Equal(2.0f, parsed.Value.Spells[5678u]); Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
} }
[Fact]
public void TryParse_TrailerOptionFlagsAndOptions1_AreReadAfterEnchantments()
{
// ATTRIBUTE | ENCHANTMENT vector flag; empty enchantment mask (0).
// After mask, trailer adds u32 option_flags + u32 options1.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // EnchantmentMask = empty
// Trailer header: option_flags + options1
writer.Write(0u); // option_flags = None — no further sections
writer.Write(0xDEADBEEFu); // options1 sentinel
// No more bytes — spellbook_filters is optional (defaults to 0).
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed!.Value.OptionFlags);
Assert.Equal(0xDEADBEEFu, parsed.Value.Options1);
Assert.Empty(parsed.Value.Shortcuts);
Assert.Empty(parsed.Value.Inventory);
}
} }