diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 39f4723..c5adadc 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -1377,29 +1377,6 @@ one live creature case no longer use the single-cylinder fallback.
---
----
-
-## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
-
-**Status:** OPEN
-**Severity:** LOW (no current user-visible bug; future panels will need the data)
-**Filed:** 2026-04-25
-**Component:** net / player-state
-
-**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
-
-**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
-
-**Files:**
-- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
-- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
-- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
-
-**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
-
-**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
-
----
---
@@ -1700,6 +1677,62 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
+## #13 — [DONE 2026-05-10 · d3b58c9..078919c] PlayerDescription trailer past enchantments
+
+**Closed:** 2026-05-10
+**Commits:** `d3b58c9` (scaffold) → `6587034` (rename nit) → `becbde6` (OptionFlags+Options1) → `9a0dfe0` (TrailerTruncated + diag) → `f7a5eea` (Shortcuts) → `8cbb991` (HotbarSpells) → `75e8e26` (DesiredComps) → `b17dc3b` (SpellbookFilters) → `98eebef` (Options2) → `d9a5e40` (strict Inventory+Equipped) → `91693ea` (heuristic GAMEPLAY_OPTIONS walker) → `58095d8` (combined fixture test) → `078919c` (ItemRepository wiring)
+**Component:** net / player-state
+**Plan:** [`docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md`](../docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md)
+
+**Resolution.** `PlayerDescriptionParser` now walks every trailer
+section through Inventory + Equipped, ported faithfully from holtburger
+`events.rs:503-625` + `shortcuts.rs:13-34`. The trickiest piece —
+`gameplay_options` — uses a 4-byte-aligned forward heuristic
+(`TryHeuristicInventoryStart`) that probes candidate offsets with a
+strict `(inventory + equipped consume to EOF)` test, mirroring
+holtburger's `find_inventory_start_after_gameplay_options`.
+
+The trailer walk is wrapped in its own inner try/catch (separate from
+the outer parse-wide catch) so a malformed trailer cannot destroy the
+already-extracted attribute / skill / spell / enchantment data. A new
+`Parsed.TrailerTruncated` flag lets callers distinguish a clean parse
+from a graceful-degradation parse (set true if the inner catch fires;
+log under `ACDREAM_DUMP_VITALS=1`).
+
+`GameEventWiring`'s `PlayerDescription` handler now registers each
+inventory entry with `ItemRepository.AddOrUpdate(...)` and applies
+`MoveItem(...)` for equipped entries so paperdoll picks up
+`CurrentlyEquippedLocation` at login. The acceptance criterion
+"`ItemRepository.Count` after login > 0" is now exercised by
+`PlayerDescription_RegistersInventoryEntries_InItemRepository` in
+`GameEventWiringTests`.
+
+12 tasks, 13 commits, +9 PD parser tests + 1 wiring test (20 PD tests
+total, 282 Net.Tests pass). Code-review nits during the run produced
+two refactor commits: `Shortcut → ShortcutEntry` rename to avoid a
+homograph with the `CharacterOptionDataFlag.Shortcut` flag bit
+(`6587034`); `TrailerTruncated` flag + diagnostic logging
+(`9a0dfe0`).
+
+Forward-looking notes (low priority, no follow-up issues filed):
+
+- `WeenieClassId = inv.ContainerType` for inventory entries is a
+ placeholder; `CreateObject` overwrites it with the real weenie class
+ later in the login sequence.
+- The 10,000 count cap throws `FormatException` on validation failure,
+ which the inner catch treats the same as truncation. If a future
+ diagnostic UI needs to distinguish "EOF mid-section" from "garbage
+ count rejected", split `TrailerTruncated` into two flags. For now
+ the `ACDREAM_DUMP_VITALS=1` log message gives the developer enough
+ signal.
+
+Files: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`,
+`src/AcDream.Core.Net/GameEventWiring.cs`,
+`tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`,
+`tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`.
+
+---
+
## #51 — [DONE 2026-05-09 · da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's `FSplitNESW`
**Closed:** 2026-05-09
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c4c33f1..ca69f59 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -201,6 +201,7 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05.
- **F.3 — Combat math + damage flow.** Damage formula, per-body-part AL, crit, hit-chance sigmoid. Server broadcasts damage events; client displays + HP bar. See `r02-combat-system.md` + `src/AcDream.Core/Combat/`.
- **F.4 — Spell cast state machine.** `SpellCastStateMachine` + active buff tracking. Buffs + recalls first, projectile spells later. Fizzle sigmoid + mana conversion. See `r01-spell-system.md` + `src/AcDream.Core/Spells/`.
- **F.5 — Core panels.** Attributes / Skills / Paperdoll / Inventory / Spellbook — using the retail-ui framework from Phase D.2. See `05-panels.md` under retail-ui. **(Targets `AcDream.UI.Abstractions`; unblocked by D.2a — ships with ImGui widgets — and reskinned when D.2b lands.)**
+ - **F.5a — Visible-at-login dev panels.** First deliverable on top of #13 (PD trailer parser shipped 2026-05-10): wire `PlayerDescriptionParser.Parsed.{Inventory, Equipped, Shortcuts, HotbarSpells, DesiredComps, Options1, Options2, SpellbookFilters}` and `ItemRepository.Items` into minimal ImGui dev panels under `ACDREAM_DEVTOOLS=1` so the parsed data is observable in-game without a real retail-skin panel. Establishes the binding pattern (`AcDream.UI.Abstractions` ViewModels → ImGui renderer) the eventual D.2b retail-skinned panels reuse. Acceptance: log in, open dev overlay, see your inventory list / hotbars / shortcuts / character-options bitfields populated from the live PD message. **Targets:** `src/AcDream.UI.Abstractions/` (ViewModels) + `src/AcDream.App/UI/ImGui/` (panels). Spec to brainstorm before code.
**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone.
diff --git a/docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md b/docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md
new file mode 100644
index 0000000..019939c
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md
@@ -0,0 +1,1242 @@
+# Issue #13 — PlayerDescription Trailer Parser Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extend `PlayerDescriptionParser` past the enchantment block through the full trailer — Options1 / Shortcuts / HotbarSpells / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — and route the parsed `Inventory` + `Equipped` lists into `ItemRepository` so `ItemCount > 0` after login.
+
+**Architecture:** Match holtburger's `PlayerDescriptionEventData::unpack` (`references/holtburger/crates/holtburger-protocol/src/messages/player/events.rs:503-607`) structure-for-structure. The trailer reads in a single forward walk except for the `gameplay_options` blob, which is opaque variable-length and uses a 4-byte-aligned forward heuristic search (`find_inventory_start_after_gameplay_options`) to locate the inventory-count+GUID-pair that follows it. The trailer parse is wrapped in its own try/catch so a malformed trailer does not lose the attribute/skill/spell/enchantment data already extracted upstream.
+
+**Tech Stack:** C# 12 / .NET 10, `System.Buffers.Binary`, xUnit. No new dependencies.
+
+**Reference cross-walk:**
+- Holtburger trailer wire format: `references/holtburger/crates/holtburger-protocol/src/messages/player/events.rs:503-607` (the `unpack` impl after enchantments).
+- Holtburger inventory unpacker: `events.rs:143-218` (`unpack_inventory_and_equipped_strict` + `find_inventory_start_after_gameplay_options`).
+- Holtburger Shortcut format: `references/holtburger/crates/holtburger-protocol/src/messages/player/shortcuts.rs:13-34` (16 bytes: u32 index + Guid (4 B) + u16 spell_id + u16 layer).
+- Existing parser: [src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs](src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs).
+- Existing wiring: [src/AcDream.Core.Net/GameEventWiring.cs:281-398](src/AcDream.Core.Net/GameEventWiring.cs:281).
+- `ItemInstance` constructor: object-initializer with `ObjectId` + `WeenieClassId` (init-only), see [src/AcDream.Core/Items/ItemInstance.cs:128](src/AcDream.Core/Items/ItemInstance.cs:128).
+
+**Acceptance:**
+- All sections of a synthetic real-world-shaped PlayerDescription parse to completion without nulling out earlier fields.
+- New tests cover each trailer section in isolation + a combined end-to-end fixture.
+- After a PlayerDescription with non-empty Inventory is dispatched, `ItemRepository.ItemCount > 0`.
+- `dotnet build` + `dotnet test` green.
+
+---
+
+## File Structure
+
+| Path | Action | Responsibility |
+|------|--------|---------------|
+| `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` | Modify | Add `CharacterOptionDataFlag` enum + `Shortcut` record + `EquippedEntry` record + `InventoryEntry` record, extend `Parsed` with trailer fields, add trailer reader functions wrapped in their own try/catch. |
+| `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` | Modify | Add per-section trailer tests + heuristic gameplay_options test + end-to-end full-trailer fixture. |
+| `src/AcDream.Core.Net/GameEventWiring.cs` | Modify | Extend the existing `PlayerDescription` handler (~line 281) to register each `Inventory` entry as a stub `ItemInstance` in `ItemRepository`. |
+| `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (or similar — locate during Task 11) | Modify or Add | Test: dispatching a PlayerDescription event with inventory entries grows `ItemRepository.ItemCount`. |
+
+---
+
+## Wire Format (Reference)
+
+After the enchantment block, all little-endian:
+
+```
+u32 option_flags // CharacterOptionDataFlag
+u32 options1 // CharacterOptions1 bitfield (opaque uint to us)
+
+if option_flags & SHORTCUT: // 0x01
+ u32 count
+ count × Shortcut(16 B) // u32 idx + u32 guid + u16 spell + u16 layer
+
+if option_flags & SPELL_LISTS8: // 0x400
+ 8 × { u32 count, count × u32 spell_id }
+else:
+ u32 count, count × u32 spell_id // single legacy list
+
+if option_flags & DESIRED_COMPS: // 0x08
+ u16 count, u16 _padding // (4-byte header — count is u16 + u16 ignored)
+ count × { u32 id, u32 amt }
+
+u32 spellbook_filters // optional — defaults to 0 if no more bytes
+
+if option_flags & CHARACTER_OPTIONS2: // 0x40
+ u32 options2
+
+if option_flags & GAMEPLAY_OPTIONS: // 0x200
+ // opaque blob; heuristic find_inventory_start_after_gameplay_options
+ // walks forward in 4-byte steps from current pos and accepts the first
+ // candidate that parses inventory+equipped exactly to end-of-buffer.
+ blob_bytes
+ inventory + equipped (strict)
+else:
+ inventory + equipped (strict)
+
+// inventory + equipped strict format:
+u32 inv_count // <= 10000
+inv_count × { u32 guid, u32 weenieType (0..2) } // ContainerType validated
+u32 eq_count // <= 10000
+eq_count × { u32 guid, u32 loc, u32 prio }
+```
+
+---
+
+## Bite-Sized Tasks
+
+### Task 1: Extend `Parsed` record + add types
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+
+- [ ] **Step 1: Add `CharacterOptionDataFlag` enum, `Shortcut`, `InventoryEntry`, `EquippedEntry` records inside the `PlayerDescriptionParser` static class (just below the existing `EnchantmentMask` enum, ~line 178).**
+
+```csharp
+[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,
+}
+
+/// One shortcut bar entry. 16 bytes wire size.
+/// holtburger shortcuts.rs:13-34. Named ShortcutEntry
+/// (not Shortcut) to avoid a homograph with the
+/// flag bit.
+public readonly record struct ShortcutEntry(
+ uint Index,
+ uint ObjectGuid,
+ ushort SpellId,
+ ushort Layer);
+
+/// One inventory entry — a guid plus a ContainerType discriminator
+/// (0=NonContainer, 1=Container, 2=Foci).
+public readonly record struct InventoryEntry(
+ uint Guid,
+ uint ContainerType);
+
+/// One equipped object entry.
+public readonly record struct EquippedEntry(
+ uint Guid,
+ uint EquipLocation,
+ uint Priority);
+```
+
+- [ ] **Step 2: Extend the `Parsed` record. Append new fields after `Enchantments`, all defaulting to empty in `BuildPartial`.**
+
+Replace the existing `Parsed` record (~line 180) with:
+
+```csharp
+public readonly record struct Parsed(
+ uint WeenieType,
+ DescriptionPropertyFlag PropertyFlags,
+ DescriptionVectorFlag VectorFlags,
+ bool HasHealth,
+ PropertyBundle Properties,
+ IReadOnlyDictionary Positions,
+ IReadOnlyList Attributes,
+ IReadOnlyList Skills,
+ IReadOnlyDictionary Spells,
+ IReadOnlyList Enchantments,
+ CharacterOptionDataFlag OptionFlags,
+ uint Options1,
+ uint Options2,
+ IReadOnlyList Shortcuts,
+ IReadOnlyList> HotbarSpells,
+ IReadOnlyList<(uint Id, uint Amount)> DesiredComps,
+ uint SpellbookFilters,
+ ReadOnlyMemory GameplayOptions,
+ IReadOnlyList Inventory,
+ IReadOnlyList Equipped,
+ bool TrailerTruncated);
+```
+
+> **Code-review followup (added after Task 2 review):** the trailing
+> `TrailerTruncated` flag was added to let callers distinguish a clean
+> parse from one where the trailer try/catch swallowed a `FormatException`
+> mid-section (Tasks 3–9 will make this reachable). All construction sites
+> pass `TrailerTruncated: false` by default; the trailer try/catch in
+> `TryParse` flips a local to `true` on catch.
+
+- [ ] **Step 3: Update `BuildPartial` to fill the new fields with defaults.**
+
+Replace the body of `BuildPartial` (~line 275) with:
+
+```csharp
+private static Parsed BuildPartial(
+ uint weenieType, DescriptionPropertyFlag pFlags, DescriptionVectorFlag vFlags,
+ bool hasHealth, PropertyBundle bundle,
+ Dictionary positions,
+ List attributes, List skills,
+ Dictionary spells)
+{
+ return new Parsed(weenieType, pFlags, vFlags, hasHealth,
+ bundle, positions, attributes, skills, spells,
+ System.Array.Empty(),
+ CharacterOptionDataFlag.None, 0u, 0u,
+ System.Array.Empty(),
+ System.Array.Empty>(),
+ System.Array.Empty<(uint, uint)>(),
+ 0u,
+ ReadOnlyMemory.Empty,
+ System.Array.Empty(),
+ System.Array.Empty(),
+ TrailerTruncated: false);
+}
+```
+
+- [ ] **Step 4: Update the existing `return new Parsed(...)` (~line 261) at the end of `TryParse` to also include the new fields with defaults.**
+
+Replace it with:
+
+```csharp
+return new Parsed(
+ weenieType, propertyFlags, vectorFlags, hasHealth,
+ bundle, positions, attributes, skills, spells, enchantments,
+ CharacterOptionDataFlag.None, 0u, 0u,
+ System.Array.Empty(),
+ System.Array.Empty>(),
+ System.Array.Empty<(uint, uint)>(),
+ 0u,
+ ReadOnlyMemory.Empty,
+ System.Array.Empty(),
+ System.Array.Empty(),
+ TrailerTruncated: false);
+```
+
+- [ ] **Step 5: Run the build + existing tests to verify no regressions.**
+
+Run: `dotnet build` and `dotnet test --filter "FullyQualifiedName~PlayerDescriptionParserTests"`
+Expected: GREEN — all 5 existing tests still pass.
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs
+git commit -m "feat(net): #13 scaffold trailer fields on PlayerDescriptionParser.Parsed
+
+No behavior change yet — adds CharacterOptionDataFlag, Shortcut/Inventory/
+EquippedEntry records, and extends Parsed with trailer fields filled with
+empty defaults. Sets up the per-section TDD walk in subsequent commits."
+```
+
+---
+
+### Task 2: Read OptionFlags + Options1 (8 bytes after enchantments)
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.** Append to `PlayerDescriptionParserTests.cs`:
+
+```csharp
+[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);
+}
+```
+
+- [ ] **Step 2: Run the test — expect FAIL** (`OptionFlags` still default `None`, `Options1` still 0).
+
+Run: `dotnet test --filter "FullyQualifiedName~TryParse_TrailerOptionFlagsAndOptions1"`
+Expected: FAIL — assertion `0xDEADBEEFu != 0u` for `Options1`.
+
+- [ ] **Step 3: Implement.** In `PlayerDescriptionParser.TryParse`, after the existing enchantments-read block (~line 259, just before the existing `return new Parsed(...)`), insert a trailer-walk block. Replace lines 258-273 (the enchantment read + final return) with:
+
+```csharp
+// ── 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 shortcuts = new();
+List> hotbarSpells = new();
+List<(uint, uint)> desiredComps = new();
+ReadOnlyMemory gameplayOptions = ReadOnlyMemory.Empty;
+List inventory = new();
+List equipped = new();
+bool trailerTruncated = false;
+
+try
+{
+ if (payload.Length - pos >= 8)
+ {
+ optionFlags = (CharacterOptionDataFlag)ReadU32(payload, ref pos);
+ options1 = ReadU32(payload, ref pos);
+ }
+}
+catch (FormatException ex)
+{
+ // Trailer corrupted — keep what we have and flag it. Tasks 3-9
+ // can leave partial lists in scope; TrailerTruncated lets callers
+ // ignore the trailer when 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);
+```
+
+> **Tasks 3–9 note:** every `return new Parsed(...)` extension or
+> rewrite in subsequent tasks must include `trailerTruncated` as the
+> final positional argument, and any new try-blocks that read trailer
+> sections should set `trailerTruncated = true;` in their catch.
+
+- [ ] **Step 4: Run the test — expect PASS.**
+
+Run: `dotnet test --filter "FullyQualifiedName~TryParse_TrailerOptionFlagsAndOptions1"`
+Expected: PASS.
+
+- [ ] **Step 5: Run full PlayerDescription test suite to confirm no regressions.**
+
+Run: `dotnet test --filter "FullyQualifiedName~PlayerDescriptionParserTests"`
+Expected: 6 tests pass (5 original + 1 new).
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs
+git commit -m "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."
+```
+
+---
+
+### Task 3: Read Shortcuts list (gated on SHORTCUT bit)
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerShortcuts_PopulatesList()
+{
+ 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); // empty enchantment mask
+
+ writer.Write(0x01u); // option_flags = SHORTCUT
+ writer.Write(0xCAFEu); // options1 sentinel
+
+ // Shortcut count + 2 entries (16 B each).
+ writer.Write(2u);
+ writer.Write(0u); writer.Write(0xAABBCCDDu); writer.Write((ushort)0); writer.Write((ushort)0);
+ writer.Write(7u); writer.Write(0u); writer.Write((ushort)1234); writer.Write((ushort)5);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.Shortcuts.Count);
+ Assert.Equal(0u, parsed.Value.Shortcuts[0].Index);
+ Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectGuid);
+ Assert.Equal((ushort)0, parsed.Value.Shortcuts[0].SpellId);
+ Assert.Equal(7u, parsed.Value.Shortcuts[1].Index);
+ Assert.Equal((ushort)1234, parsed.Value.Shortcuts[1].SpellId);
+ Assert.Equal((ushort)5, parsed.Value.Shortcuts[1].Layer);
+}
+```
+
+- [ ] **Step 2: Run the test — expect FAIL** (`Shortcuts` still empty).
+
+- [ ] **Step 3: Implement.** Inside the trailer try-block, after the `options1 = ReadU32(...)` line, append:
+
+```csharp
+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++)
+ {
+ uint idx = ReadU32(payload, ref pos);
+ uint guid = ReadU32(payload, ref pos);
+ ushort spellId = ReadU16(payload, ref pos);
+ ushort layer = ReadU16(payload, ref pos);
+ shortcuts.Add(new ShortcutEntry(idx, guid, spellId, layer));
+ }
+}
+```
+
+- [ ] **Step 4: Run the test — expect PASS.**
+
+- [ ] **Step 5: Run full suite — expect green.**
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 read shortcuts list (SHORTCUT bit) in PD trailer"
+```
+
+---
+
+### Task 4: Read HotbarSpells with SPELL_LISTS8 path
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerHotbarSpells_SpellLists8_Reads8Lists()
+{
+ 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); // empty enchantment mask
+
+ writer.Write(0x400u); // option_flags = SPELL_LISTS8
+ writer.Write(0u); // options1
+
+ // 8 hotbars: counts {2,1,0,0,0,0,0,3} — first list has 2 spells, second has 1, last has 3.
+ writer.Write(2u); writer.Write(11u); writer.Write(12u);
+ writer.Write(1u); writer.Write(21u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(3u); writer.Write(81u); writer.Write(82u); writer.Write(83u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(8, parsed!.Value.HotbarSpells.Count);
+ Assert.Equal(new uint[] { 11u, 12u }, parsed.Value.HotbarSpells[0]);
+ Assert.Equal(new uint[] { 21u }, parsed.Value.HotbarSpells[1]);
+ Assert.Empty(parsed.Value.HotbarSpells[2]);
+ Assert.Equal(new uint[] { 81u, 82u, 83u }, parsed.Value.HotbarSpells[7]);
+}
+```
+
+- [ ] **Step 2: Run the test — expect FAIL.**
+
+- [ ] **Step 3: Implement.** After the shortcuts block in the trailer try-block, append:
+
+```csharp
+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((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((int)count);
+ for (uint i = 0; i < count; i++)
+ list.Add(ReadU32(payload, ref pos));
+ hotbarSpells.Add(list);
+}
+```
+
+- [ ] **Step 4: Run the test — expect PASS.**
+
+- [ ] **Step 5: Add a second test for the legacy single-list path.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerHotbarSpells_NoSpellLists8_ReadsSingleLegacyList()
+{
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None (no SPELL_LISTS8)
+ writer.Write(0u); // options1
+
+ // Legacy single hotbar list: count=2, two spells.
+ writer.Write(2u); writer.Write(101u); writer.Write(102u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Single(parsed!.Value.HotbarSpells);
+ Assert.Equal(new uint[] { 101u, 102u }, parsed.Value.HotbarSpells[0]);
+}
+```
+
+- [ ] **Step 6: Run both hotbar tests — expect PASS. Then full suite green.**
+
+- [ ] **Step 7: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 read hotbar spells (SPELL_LISTS8 + legacy path)"
+```
+
+---
+
+### Task 5: Read DesiredComps list
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerDesiredComps_ReadsIdAmtPairs()
+{
+ 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); // empty enchantment mask
+
+ // option_flags = DESIRED_COMPS (0x08); no SPELL_LISTS8 so legacy hotbar list (count=0).
+ writer.Write(0x08u);
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0
+ writer.Write(0u);
+
+ // DESIRED_COMPS: u16 count=2, u16 padding, then 2 (id,amt) pairs of 8 bytes each.
+ writer.Write((ushort)2);
+ writer.Write((ushort)0);
+ writer.Write(0xAAu); writer.Write(50u);
+ writer.Write(0xBBu); writer.Write(75u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.DesiredComps.Count);
+ Assert.Equal((0xAAu, 50u), parsed.Value.DesiredComps[0]);
+ Assert.Equal((0xBBu, 75u), parsed.Value.DesiredComps[1]);
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL.**
+
+- [ ] **Step 3: Implement.** After the hotbar block in the trailer try-block, append:
+
+```csharp
+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));
+ }
+}
+```
+
+- [ ] **Step 4: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 read desired_comps list in PD trailer"
+```
+
+---
+
+### Task 6: Read SpellbookFilters (optional u32, defaults to 0)
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerSpellbookFilters_ReadOptionalU32()
+{
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0
+ writer.Write(0u);
+
+ // spellbook_filters sentinel.
+ writer.Write(0xF00DBA42u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0xF00DBA42u, parsed!.Value.SpellbookFilters);
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL** (defaults to 0).
+
+- [ ] **Step 3: Implement.** After the desired_comps block in the trailer try-block, append:
+
+```csharp
+// holtburger events.rs:576-582 — spellbook_filters is optional; defaults
+// to 0 if EOF.
+if (payload.Length - pos >= 4)
+ spellbookFilters = ReadU32(payload, ref pos);
+```
+
+- [ ] **Step 4: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 read optional spellbook_filters u32"
+```
+
+---
+
+### Task 7: Read Options2 (gated on CHARACTER_OPTIONS2 bit)
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerOptions2_GatedOnCharacterOptions2Bit()
+{
+ 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); // empty enchantment mask
+
+ // option_flags = CHARACTER_OPTIONS2 (0x40)
+ writer.Write(0x40u);
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0.
+ writer.Write(0u);
+
+ // spellbook_filters
+ writer.Write(0u);
+
+ // options2 sentinel
+ writer.Write(0xC0FFEE01u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0xC0FFEE01u, parsed!.Value.Options2);
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL.**
+
+- [ ] **Step 3: Implement.** After the spellbook_filters block, append:
+
+```csharp
+if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2))
+ options2 = ReadU32(payload, ref pos);
+```
+
+- [ ] **Step 4: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 read options2 gated on CHARACTER_OPTIONS2 flag"
+```
+
+---
+
+### Task 8: Strict Inventory + Equipped reader (no GAMEPLAY_OPTIONS path)
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerInventoryEquippedStrict_NoGameplayOptionsBit()
+{
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None — no GAMEPLAY_OPTIONS
+ writer.Write(0u); // options1
+ writer.Write(0u); // legacy hotbar list count=0
+ writer.Write(0u); // spellbook_filters
+
+ // Inventory: 2 entries
+ writer.Write(2u);
+ writer.Write(0x500000A0u); writer.Write(0u); // NonContainer
+ writer.Write(0x500000A1u); writer.Write(1u); // Container
+
+ // Equipped: 1 entry
+ writer.Write(1u);
+ writer.Write(0x500000B0u); writer.Write(0x00000200u); writer.Write(1u); // ChestArmor, prio=1
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.Inventory.Count);
+ Assert.Equal(0x500000A0u, parsed.Value.Inventory[0].Guid);
+ Assert.Equal(0u, parsed.Value.Inventory[0].ContainerType);
+ Assert.Equal(1u, parsed.Value.Inventory[1].ContainerType);
+ Assert.Single(parsed.Value.Equipped);
+ Assert.Equal(0x500000B0u, parsed.Value.Equipped[0].Guid);
+ Assert.Equal(0x00000200u, parsed.Value.Equipped[0].EquipLocation);
+ Assert.Equal(1u, parsed.Value.Equipped[0].Priority);
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL.**
+
+- [ ] **Step 3: Implement.** Add a new helper method `TryUnpackInventoryStrict` near the bottom of the class, just above the primitive readers (~line 545):
+
+```csharp
+/// Strict inventory + equipped block reader. Returns true if
+/// the bytes from parse cleanly per holtburger
+/// events.rs:143-193 (unpack_inventory_and_equipped_strict).
+/// Counts capped at 10,000; inventory ContainerType must be 0..2
+/// (NonContainer / Container / Foci).
+private static bool TryUnpackInventoryStrict(
+ ReadOnlySpan src, ref int pos,
+ List inventory, List 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;
+}
+```
+
+After the options2 read in the trailer try-block, append:
+
+```csharp
+if (!optionFlags.HasFlag(CharacterOptionDataFlag.GameplayOptions))
+{
+ // Strict path: inventory + equipped follow directly.
+ TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
+}
+```
+
+- [ ] **Step 4: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 strict inventory+equipped reader (no GAMEPLAY_OPTIONS)"
+```
+
+---
+
+### Task 9: Heuristic GAMEPLAY_OPTIONS path
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write the failing test.**
+
+```csharp
+[Fact]
+public void TryParse_TrailerGameplayOptions_HeuristicLocatesInventoryStart()
+{
+ 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); // empty enchantment mask
+
+ // option_flags = GAMEPLAY_OPTIONS (0x200)
+ writer.Write(0x200u);
+ writer.Write(0u); // options1
+ writer.Write(0u); // legacy hotbar count=0
+ writer.Write(0u); // spellbook_filters
+
+ // 16 bytes of opaque gameplay_options blob — values that *almost* look
+ // like an inventory header but fail validation (wtype > 2 or count too
+ // big), forcing the heuristic to walk past them.
+ writer.Write(0xDEADBEEFu); // looks like inv_count = 0xDEADBEEF (> 10_000) — rejected
+ writer.Write(0xCAFEBABEu);
+ writer.Write(0x12345678u);
+ writer.Write(0x87654321u);
+
+ // Real inventory: 1 entry, then equipped: 1 entry — must consume to EOF.
+ writer.Write(1u);
+ writer.Write(0x50000200u); writer.Write(0u);
+ writer.Write(1u);
+ writer.Write(0x50000300u); writer.Write(0x00000200u); writer.Write(1u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Single(parsed!.Value.Inventory);
+ Assert.Equal(0x50000200u, parsed.Value.Inventory[0].Guid);
+ Assert.Single(parsed.Value.Equipped);
+ Assert.Equal(0x50000300u, parsed.Value.Equipped[0].Guid);
+ Assert.Equal(16, parsed.Value.GameplayOptions.Length);
+}
+```
+
+- [ ] **Step 2: Run — expect FAIL.**
+
+- [ ] **Step 3: Implement.** Add a new helper method just below `TryUnpackInventoryStrict`:
+
+```csharp
+/// 4-byte-aligned forward scan from
+/// looking for the first offset where TryUnpackInventoryStrict
+/// consumes exactly to end-of-buffer. Mirrors holtburger
+/// find_inventory_start_after_gameplay_options in events.rs:195-218.
+private static bool TryHeuristicInventoryStart(
+ ReadOnlySpan src, int start,
+ out int invStart, out int end,
+ List inventory, List 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();
+ var tmpEq = new List();
+ 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;
+}
+```
+
+In the trailer try-block, replace the strict-only branch from Task 8 with the full conditional:
+
+```csharp
+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
+{
+ TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
+}
+```
+
+Note: `payload.Slice(...)` returns a `ReadOnlySpan` — we capture into `byte[]` then store as `ReadOnlyMemory` (the field type). The `.ToArray()` allocation is acceptable (one per PD = once per session).
+
+- [ ] **Step 4: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 heuristic inventory locator after gameplay_options blob"
+```
+
+---
+
+### Task 10: Combined end-to-end fixture test
+
+**Files:**
+- Modify: `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`
+
+- [ ] **Step 1: Write a single test that exercises every section together — a real-shaped fixture.**
+
+```csharp
+[Fact]
+public void TryParse_FullTrailer_AllSectionsPopulated()
+{
+ 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); // empty enchantment mask
+
+ // option_flags = SHORTCUT | DESIRED_COMPS | CHARACTER_OPTIONS2 | SPELL_LISTS8
+ // = 0x01 | 0x08 | 0x40 | 0x400 = 0x449
+ writer.Write(0x449u);
+ writer.Write(0xAA000001u); // options1
+
+ // Shortcuts: count=1
+ writer.Write(1u);
+ writer.Write(3u); writer.Write(0xCAFEFACEu); writer.Write((ushort)100); writer.Write((ushort)2);
+
+ // 8 hotbars, all empty for brevity.
+ for (int i = 0; i < 8; i++) writer.Write(0u);
+
+ // Desired comps: count=1
+ writer.Write((ushort)1); writer.Write((ushort)0);
+ writer.Write(0xC1u); writer.Write(99u);
+
+ // spellbook_filters
+ writer.Write(0xF11Du);
+
+ // options2
+ writer.Write(0xBB000002u);
+
+ // Inventory + equipped (no GAMEPLAY_OPTIONS, strict path)
+ writer.Write(1u);
+ writer.Write(0x50000400u); writer.Write(0u);
+ writer.Write(1u);
+ writer.Write(0x50000500u); writer.Write(0x00000200u); writer.Write(1u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ var v = parsed!.Value;
+ Assert.Equal(0xAA000001u, v.Options1);
+ Assert.Equal(0xBB000002u, v.Options2);
+ Assert.Equal(0xF11Du, v.SpellbookFilters);
+ Assert.Single(v.Shortcuts);
+ Assert.Equal(0xCAFEFACEu, v.Shortcuts[0].ObjectGuid);
+ Assert.Equal(8, v.HotbarSpells.Count);
+ Assert.All(v.HotbarSpells, l => Assert.Empty(l));
+ Assert.Single(v.DesiredComps);
+ Assert.Equal((0xC1u, 99u), v.DesiredComps[0]);
+ Assert.Single(v.Inventory);
+ Assert.Equal(0x50000400u, v.Inventory[0].Guid);
+ Assert.Single(v.Equipped);
+ Assert.Equal(0x50000500u, v.Equipped[0].Guid);
+}
+```
+
+- [ ] **Step 2: Run — expect PASS** (no implementation change needed; this exercises the cumulative behavior).
+
+- [ ] **Step 3: Run full suite green.**
+
+- [ ] **Step 4: Commit.**
+
+```bash
+git add -u
+git commit -m "test(net): #13 end-to-end PD trailer fixture covering every section"
+```
+
+---
+
+### Task 11: Wire `Inventory` into ItemRepository in GameEventWiring
+
+**Files:**
+- Modify: `src/AcDream.Core.Net/GameEventWiring.cs`
+- Test: locate the test file referenced by the issue text — `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` if it exists, otherwise add a new file.
+
+- [ ] **Step 1: Locate (or create) the wiring test file.**
+
+```bash
+ls tests/AcDream.Core.Net.Tests/GameEventWiring*
+```
+
+If absent, create `tests/AcDream.Core.Net.Tests/GameEventWiringInventoryTests.cs`.
+
+- [ ] **Step 2: Write the failing test.**
+
+In `tests/AcDream.Core.Net.Tests/GameEventWiringInventoryTests.cs`:
+
+```csharp
+using System;
+using System.IO;
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Spells;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class GameEventWiringInventoryTests
+{
+ [Fact]
+ public void PlayerDescription_RegistersInventoryEntries_InItemRepository()
+ {
+ var dispatcher = new GameEventDispatcher();
+ var items = new ItemRepository();
+ var combat = new CombatLog();
+ var spellbook = new Spellbook();
+ var chat = new ChatLog();
+
+ GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
+
+ // Build a minimal PlayerDescription with inventory: 2 entries.
+ var sb = new MemoryStream();
+ using var w = new BinaryWriter(sb);
+ w.Write(0u); // propertyFlags
+ w.Write(0x52u); // weenieType
+ w.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
+ w.Write(1u); // has_health
+ w.Write(0u); // empty attribute_flags
+ w.Write(0u); // empty enchantment mask
+
+ w.Write(0u); // option_flags = None
+ w.Write(0u); // options1
+ w.Write(0u); // legacy hotbar count
+ w.Write(0u); // spellbook_filters
+
+ // Inventory: 2 entries, then 0 equipped.
+ w.Write(2u);
+ w.Write(0x50000A01u); w.Write(0u);
+ w.Write(0x50000A02u); w.Write(1u);
+ w.Write(0u);
+
+ // Construct an envelope-stripped GameEvent payload.
+ var evt = new GameEvent(GameEventType.PlayerDescription, sb.ToArray(), Sequence: 1);
+
+ Assert.Equal(0, items.ItemCount);
+ dispatcher.Dispatch(evt);
+ Assert.Equal(2, items.ItemCount);
+ Assert.NotNull(items.GetItem(0x50000A01u));
+ Assert.NotNull(items.GetItem(0x50000A02u));
+ }
+}
+```
+
+> **Note for the executor:** the constructor signature for `GameEventWiring.WireAll` may use named optional parameters (`localPlayer`, `turbineChat`, etc.). Inspect [src/AcDream.Core.Net/GameEventWiring.cs:39-65](src/AcDream.Core.Net/GameEventWiring.cs:39) and pass only the non-optional positional args. The exact `GameEvent` constructor name + arg order is in [src/AcDream.Core.Net/Messages/GameEvent.cs](src/AcDream.Core.Net/Messages/GameEvent.cs) — adjust if the project uses `Payload: ` instead of positional. If `Spellbook` has a different constructor (e.g. requires a `World`), use the existing test pattern from `GameEventWiringTests` if one exists, or pass `null!`-style defaults.
+
+- [ ] **Step 3: Run — expect FAIL** (current handler does not touch ItemRepository for trailer inventory).
+
+- [ ] **Step 4: Implement.** In `src/AcDream.Core.Net/GameEventWiring.cs`, inside the existing `dispatcher.Register(GameEventType.PlayerDescription, e => { ... })` lambda (right before its closing `});` at line ~398), append:
+
+```csharp
+// Issue #13 — register inventory entries with ItemRepository so panels
+// (inventory, paperdoll, hotbars) light up after login. Equipped entries
+// share the same ObjectId as inventory entries (an equipped item is
+// also in inventory) — register both, but the equipped record carries
+// the slot mask which we surface via MoveItem so paperdoll can render.
+foreach (var inv in p.Value.Inventory)
+{
+ if (items.GetItem(inv.Guid) is null)
+ {
+ items.AddOrUpdate(new ItemInstance
+ {
+ ObjectId = inv.Guid,
+ WeenieClassId = inv.ContainerType,
+ });
+ }
+}
+foreach (var eq in p.Value.Equipped)
+{
+ if (items.GetItem(eq.Guid) is null)
+ {
+ items.AddOrUpdate(new ItemInstance
+ {
+ ObjectId = eq.Guid,
+ WeenieClassId = 0,
+ });
+ }
+ // Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
+ items.MoveItem(
+ itemId: eq.Guid,
+ newContainerId: 0,
+ newSlot: -1,
+ newEquipLocation: (EquipMask)eq.EquipLocation);
+}
+```
+
+If `ItemInstance` or `EquipMask` is not already imported in this file, add the using directives:
+
+```csharp
+using AcDream.Core.Items;
+```
+
+- [ ] **Step 5: Run — expect PASS.** Run full suite green.
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add -u
+git commit -m "feat(net): #13 register PD trailer inventory+equipped in ItemRepository"
+```
+
+---
+
+### Task 12: Update issue tracker + close
+
+**Files:**
+- Modify: `docs/ISSUES.md`
+
+- [ ] **Step 1: Move #13 from OPEN to "Recently closed".** In `docs/ISSUES.md`, locate the `## #13` block (line ~1382) and the `Recently closed` section. Move the block, update its status header to `**Status:** DONE` + add a `**Closed:** 2026-05-10` + `**Commit:** ` line. The fix-summary should note: full trailer walked; ItemRepository registration of inventory + equipped wired; tests added.
+
+- [ ] **Step 2: Run the full test suite one final time.**
+
+```bash
+dotnet test
+```
+Expected: full green, no regressions.
+
+- [ ] **Step 3: Commit.**
+
+```bash
+git add docs/ISSUES.md
+git commit -m "docs: close ISSUES.md #13 — PD trailer parser shipped"
+```
+
+---
+
+## Self-Review Checklist (executed by plan author)
+
+- **Spec coverage:** Every section in the issue text is covered by a task — Options1 (Task 2), Shortcuts (Task 3), Hotbars (Task 4), DesiredComps (Task 5), SpellbookFilters (Task 6), Options2 (Task 7), strict inventory (Task 8), GameplayOptions blob + heuristic (Task 9), GameEventWiring routing (Task 11). PASS.
+
+- **Placeholder scan:** All test bodies + implementation snippets are complete C#. The one note in Task 11 about constructor signatures is annotated as a verification hint, not a placeholder. PASS.
+
+- **Type consistency:** `InventoryEntry(Guid, ContainerType)` and `EquippedEntry(Guid, EquipLocation, Priority)` are introduced in Task 1 and used consistently in Tasks 8/9/11. `Shortcut(Index, ObjectGuid, SpellId, Layer)` matches in Tasks 1+3. `CharacterOptionDataFlag` field names (`Shortcut`, `DesiredComps`, `CharacterOptions2`, `GameplayOptions`, `SpellLists8`) match the holtburger bit names. PASS.
+
+- **Edge cases addressed:** Trailer try/catch isolates trailer corruption from upstream data (Task 2); count caps at 10,000 prevent attacker-controlled allocation (Tasks 3, 5, 8); 4-byte alignment in heuristic search (Task 9); legacy single-list hotbar fallback (Task 4 step 5). PASS.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md`. Two execution options:
+
+1. **Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. Sonnet is correct per project subagent policy.
+2. **Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review.
+
+Which approach?
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index 93fd62e..c5f61e3 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -395,6 +395,41 @@ public static class GameEventWiring
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
+
+ // Issue #13 — register inventory entries with ItemRepository so
+ // panels (inventory, paperdoll, hotbars) light up after login.
+ // Equipped entries share the same ObjectId as inventory entries
+ // (an equipped item is also in inventory) — register both, but
+ // the equipped record carries the slot mask which we surface via
+ // MoveItem so paperdoll can render.
+ foreach (var inv in p.Value.Inventory)
+ {
+ if (items.GetItem(inv.Guid) is null)
+ {
+ items.AddOrUpdate(new ItemInstance
+ {
+ ObjectId = inv.Guid,
+ WeenieClassId = inv.ContainerType,
+ });
+ }
+ }
+ foreach (var eq in p.Value.Equipped)
+ {
+ if (items.GetItem(eq.Guid) is null)
+ {
+ items.AddOrUpdate(new ItemInstance
+ {
+ ObjectId = eq.Guid,
+ WeenieClassId = 0,
+ });
+ }
+ // Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
+ items.MoveItem(
+ itemId: eq.Guid,
+ newContainerId: 0,
+ newSlot: -1,
+ newEquipLocation: (EquipMask)eq.EquipLocation);
+ }
});
}
}
diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs
index 406af15..73cb9f4 100644
--- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs
+++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs
@@ -177,6 +177,63 @@ public static class PlayerDescriptionParser
Cooldown = 0x08,
}
+ /// Bitmask of which optional trailer sections are present in
+ /// the PlayerDescription wire payload. Holtburger
+ /// events.rs:503-607; ACE CharacterOptionDataFlag.
+ [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,
+ }
+
+ /// One shortcut bar entry. 16 bytes wire size.
+ /// holtburger shortcuts.rs:13-34. Named ShortcutEntry
+ /// (not Shortcut) to avoid a homograph with the
+ /// flag bit, which is
+ /// referenced from the same scope as instances of this type in the
+ /// trailer walker.
+ public readonly record struct ShortcutEntry(
+ uint Index,
+ uint ObjectGuid,
+ ushort SpellId,
+ ushort Layer);
+
+ /// One inventory entry — a guid plus a ContainerType
+ /// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
+ /// events.rs:143-168 validates ContainerType <= 2
+ /// in unpack_inventory_and_equipped_strict.
+ public readonly record struct InventoryEntry(
+ uint Guid,
+ uint ContainerType);
+
+ /// One equipped object entry. Holtburger
+ /// events.rs:180-190: (Guid guid, u32 loc, u32 prio).
+ /// is an EquipMask bitfield;
+ /// orders overlapping equips in the
+ /// same slot.
+ public readonly record struct EquippedEntry(
+ uint Guid,
+ uint EquipLocation,
+ uint Priority);
+
+ /// Result of . Trailer fields
+ /// (OptionFlags through Equipped) may be partially
+ /// populated when is true —
+ /// 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.
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
@@ -187,7 +244,18 @@ public static class PlayerDescriptionParser
IReadOnlyList Attributes,
IReadOnlyList Skills,
IReadOnlyDictionary Spells,
- IReadOnlyList Enchantments);
+ IReadOnlyList Enchantments,
+ CharacterOptionDataFlag OptionFlags,
+ uint Options1,
+ uint Options2,
+ IReadOnlyList Shortcuts,
+ IReadOnlyList> HotbarSpells,
+ IReadOnlyList<(uint Id, uint Amount)> DesiredComps,
+ uint SpellbookFilters,
+ ReadOnlyMemory GameplayOptions,
+ IReadOnlyList Inventory,
+ IReadOnlyList Equipped,
+ bool TrailerTruncated);
///
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
@@ -249,18 +317,126 @@ public static class PlayerDescriptionParser
ReadSpellTable(payload, ref pos, spells);
// ── 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))
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 shortcuts = new();
+ List> hotbarSpells = new();
+ List<(uint, uint)> desiredComps = new();
+ ReadOnlyMemory gameplayOptions = ReadOnlyMemory.Empty;
+ List inventory = new();
+ List 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++)
+ {
+ uint idx = ReadU32(payload, ref pos);
+ uint guid = ReadU32(payload, ref pos);
+ ushort spellId = ReadU16(payload, ref pos);
+ ushort layer = ReadU16(payload, ref pos);
+ shortcuts.Add(new ShortcutEntry(idx, guid, spellId, layer));
+ }
+ }
+
+ 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((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((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);
+ bundle, positions, attributes, skills, spells, enchantments,
+ optionFlags, options1, options2,
+ shortcuts, hotbarSpells, desiredComps, spellbookFilters,
+ gameplayOptions, inventory, equipped, trailerTruncated);
}
catch (FormatException ex)
{
@@ -281,7 +457,16 @@ public static class PlayerDescriptionParser
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells,
- System.Array.Empty());
+ System.Array.Empty(),
+ CharacterOptionDataFlag.None, 0u, 0u,
+ System.Array.Empty(),
+ System.Array.Empty>(),
+ System.Array.Empty<(uint, uint)>(),
+ 0u,
+ ReadOnlyMemory.Empty,
+ System.Array.Empty(),
+ System.Array.Empty(),
+ TrailerTruncated: false);
}
// ── Attribute block reader ──────────────────────────────────────────────
@@ -542,6 +727,86 @@ public static class PlayerDescriptionParser
bucket);
}
+ /// Strict inventory + equipped block reader. Returns true if
+ /// the bytes from parse cleanly per holtburger
+ /// events.rs:143-193 (unpack_inventory_and_equipped_strict).
+ /// Counts capped at 10,000; inventory ContainerType must be 0..2
+ /// (NonContainer / Container / Foci).
+ private static bool TryUnpackInventoryStrict(
+ ReadOnlySpan src, ref int pos,
+ List inventory, List 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;
+ }
+
+ /// 4-byte-aligned forward scan from
+ /// looking for the first offset where TryUnpackInventoryStrict
+ /// consumes exactly to end-of-buffer. Mirrors holtburger
+ /// find_inventory_start_after_gameplay_options in events.rs:195-218.
+ private static bool TryHeuristicInventoryStart(
+ ReadOnlySpan src, int start,
+ out int invStart, out int end,
+ List inventory, List 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();
+ var tmpEq = new List();
+ 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 src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated u16");
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index f740efb..c414ddb 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
+using System.IO;
using System.Text;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
@@ -328,4 +329,50 @@ public sealed class GameEventWiringTests
Assert.Contains("Mana Stone", e.Text);
}
+ [Fact]
+ public void PlayerDescription_RegistersInventoryEntries_InItemRepository()
+ {
+ // Issue #13 acceptance test: after a PlayerDescription with non-empty
+ // Inventory is dispatched through WireAll, ItemRepository.ItemCount > 0.
+ // Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory +
+ // equipped follow directly after spellbook_filters.
+ var dispatcher = new GameEventDispatcher();
+ var items = new ItemRepository();
+ var combat = new CombatState();
+ var spellbook = new Spellbook();
+ var chat = new ChatLog();
+ GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
+
+ Assert.Equal(0, items.ItemCount); // pre-condition
+
+ var sb = new MemoryStream();
+ using var w = new BinaryWriter(sb);
+ w.Write(0u); // propertyFlags = 0
+ w.Write(0x52u); // weenieType
+ w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
+ w.Write(1u); // has_health
+ w.Write(0u); // attribute_flags = 0 (no attrs)
+ w.Write(0u); // enchantment_mask = 0
+
+ w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path)
+ w.Write(0u); // options1
+ w.Write(0u); // legacy hotbar list count = 0
+ w.Write(0u); // spellbook_filters
+
+ // Inventory: 2 entries
+ w.Write(2u);
+ w.Write(0x50000A01u); w.Write(0u); // guid, ContainerType=NonContainer
+ w.Write(0x50000A02u); w.Write(1u); // guid, ContainerType=Container
+
+ // Equipped: 0 entries
+ w.Write(0u);
+
+ var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
+ dispatcher.Dispatch(env!.Value);
+
+ Assert.Equal(2, items.ItemCount);
+ Assert.NotNull(items.GetItem(0x50000A01u));
+ Assert.NotNull(items.GetItem(0x50000A02u));
+ }
+
}
diff --git a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs
index 4908bb8..c74df04 100644
--- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs
+++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs
@@ -334,4 +334,434 @@ public sealed class PlayerDescriptionParserTests
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
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);
+ // Defaults for the trailer fields not yet read (Tasks 3-9 will
+ // populate them). Asserting them here gives those tasks a
+ // pre-existing regression guard if they accidentally consume into
+ // the wrong field's wire bytes.
+ Assert.Equal(0u, parsed.Value.Options2);
+ Assert.Equal(0u, parsed.Value.SpellbookFilters);
+ Assert.Empty(parsed.Value.HotbarSpells);
+ Assert.Empty(parsed.Value.DesiredComps);
+ Assert.True(parsed.Value.GameplayOptions.IsEmpty);
+ Assert.Empty(parsed.Value.Equipped);
+ Assert.False(parsed.Value.TrailerTruncated);
+ }
+
+ [Fact]
+ public void TryParse_TrailerShortcuts_PopulatesList()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0x01u); // option_flags = SHORTCUT
+ writer.Write(0xCAFEu); // options1 sentinel
+
+ // Shortcut count + 2 entries (16 B each).
+ writer.Write(2u);
+ writer.Write(0u); writer.Write(0xAABBCCDDu); writer.Write((ushort)0); writer.Write((ushort)0);
+ writer.Write(7u); writer.Write(0u); writer.Write((ushort)1234); writer.Write((ushort)5);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.Shortcuts.Count);
+ Assert.Equal(0u, parsed.Value.Shortcuts[0].Index);
+ Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectGuid);
+ Assert.Equal((ushort)0, parsed.Value.Shortcuts[0].SpellId);
+ Assert.Equal(7u, parsed.Value.Shortcuts[1].Index);
+ Assert.Equal((ushort)1234, parsed.Value.Shortcuts[1].SpellId);
+ Assert.Equal((ushort)5, parsed.Value.Shortcuts[1].Layer);
+ }
+
+ [Fact]
+ public void TryParse_TrailerShortcuts_TruncatedMidList_FlagsTrailerTruncatedAndPreservesPriorEntries()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0x01u); // option_flags = SHORTCUT
+ writer.Write(0u); // options1
+ writer.Write(3u); // claimed shortcut count = 3
+ // First entry complete (16 B).
+ writer.Write(1u); writer.Write(0xAAAAu); writer.Write((ushort)10); writer.Write((ushort)1);
+ // Second entry truncated to 8 bytes — ReadU16 will throw FormatException.
+ writer.Write(2u); writer.Write(0xBBBBu);
+ // (no SpellId/Layer — payload ends here)
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ // Inner catch fired — flag set.
+ Assert.True(parsed!.Value.TrailerTruncated);
+ // First entry survives in the partial list.
+ Assert.Single(parsed.Value.Shortcuts);
+ Assert.Equal(1u, parsed.Value.Shortcuts[0].Index);
+ }
+
+ [Fact]
+ public void TryParse_TrailerHotbarSpells_SpellLists8_Reads8Lists()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0x400u); // option_flags = SPELL_LISTS8
+ writer.Write(0u); // options1
+
+ // 8 hotbars: counts {2,1,0,0,0,0,0,3}
+ writer.Write(2u); writer.Write(11u); writer.Write(12u);
+ writer.Write(1u); writer.Write(21u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(0u);
+ writer.Write(3u); writer.Write(81u); writer.Write(82u); writer.Write(83u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(8, parsed!.Value.HotbarSpells.Count);
+ Assert.Equal(new uint[] { 11u, 12u }, parsed.Value.HotbarSpells[0]);
+ Assert.Equal(new uint[] { 21u }, parsed.Value.HotbarSpells[1]);
+ Assert.Empty(parsed.Value.HotbarSpells[2]);
+ Assert.Equal(new uint[] { 81u, 82u, 83u }, parsed.Value.HotbarSpells[7]);
+ }
+
+ [Fact]
+ public void TryParse_TrailerHotbarSpells_NoSpellLists8_ReadsSingleLegacyList()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None (no SPELL_LISTS8)
+ writer.Write(0u); // options1
+
+ // Legacy single hotbar list: count=2, two spells.
+ writer.Write(2u); writer.Write(101u); writer.Write(102u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Single(parsed!.Value.HotbarSpells);
+ Assert.Equal(new uint[] { 101u, 102u }, parsed.Value.HotbarSpells[0]);
+ }
+
+ [Fact]
+ public void TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_PreservesUpstreamAndDoesNotFlagTruncation()
+ {
+ // Fewer than 8 bytes remain after the enchantment block, so the
+ // trailer header is treated as absent (no read attempted). Upstream
+ // attribute data must survive; TrailerTruncated stays false because
+ // the parser never *started* the trailer — it correctly skipped it.
+ // (Tasks 3-9 will introduce truncation-mid-section cases that flip
+ // TrailerTruncated to true.)
+ 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
+ // Attribute block: only Strength (bit 0).
+ writer.Write(0x01u);
+ writer.Write(50u); writer.Write(10u); writer.Write(0u);
+ // Empty enchantment mask.
+ writer.Write(0u);
+ // Truncated trailer: only 4 bytes (would-be option_flags) instead of 8.
+ writer.Write(0xCAFEu);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ // Upstream attribute survived.
+ Assert.Single(parsed!.Value.Attributes);
+ Assert.Equal(1u, parsed.Value.Attributes[0].AtType);
+ // Trailer was absent (< 8 bytes), so no truncation flag and all
+ // trailer fields stay at their initial defaults.
+ Assert.False(parsed.Value.TrailerTruncated);
+ Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed.Value.OptionFlags);
+ Assert.Equal(0u, parsed.Value.Options1);
+ }
+
+ [Fact]
+ public void TryParse_TrailerDesiredComps_ReadsIdAmtPairs()
+ {
+ 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); // empty enchantment mask
+
+ // option_flags = DESIRED_COMPS (0x08); no SPELL_LISTS8 so legacy hotbar list (count=0).
+ writer.Write(0x08u);
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0
+ writer.Write(0u);
+
+ // DESIRED_COMPS: u16 count=2, u16 padding, then 2 (id,amt) pairs of 8 bytes each.
+ writer.Write((ushort)2);
+ writer.Write((ushort)0);
+ writer.Write(0xAAu); writer.Write(50u);
+ writer.Write(0xBBu); writer.Write(75u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.DesiredComps.Count);
+ Assert.Equal((0xAAu, 50u), parsed.Value.DesiredComps[0]);
+ Assert.Equal((0xBBu, 75u), parsed.Value.DesiredComps[1]);
+ }
+
+ [Fact]
+ public void TryParse_TrailerSpellbookFilters_ReadOptionalU32()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0
+ writer.Write(0u);
+
+ // spellbook_filters sentinel.
+ writer.Write(0xF00DBA42u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0xF00DBA42u, parsed!.Value.SpellbookFilters);
+ }
+
+ [Fact]
+ public void TryParse_TrailerOptions2_GatedOnCharacterOptions2Bit()
+ {
+ 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); // empty enchantment mask
+
+ // option_flags = CHARACTER_OPTIONS2 (0x40)
+ writer.Write(0x40u);
+ writer.Write(0u); // options1
+
+ // Legacy hotbar list: count=0.
+ writer.Write(0u);
+
+ // spellbook_filters
+ writer.Write(0u);
+
+ // options2 sentinel
+ writer.Write(0xC0FFEE01u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0xC0FFEE01u, parsed!.Value.Options2);
+ }
+
+ [Fact]
+ public void TryParse_TrailerInventoryEquippedStrict_NoGameplayOptionsBit()
+ {
+ 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); // empty enchantment mask
+
+ writer.Write(0u); // option_flags = None — no GAMEPLAY_OPTIONS
+ writer.Write(0u); // options1
+ writer.Write(0u); // legacy hotbar list count=0
+ writer.Write(0u); // spellbook_filters
+
+ // Inventory: 2 entries
+ writer.Write(2u);
+ writer.Write(0x500000A0u); writer.Write(0u); // NonContainer
+ writer.Write(0x500000A1u); writer.Write(1u); // Container
+
+ // Equipped: 1 entry
+ writer.Write(1u);
+ writer.Write(0x500000B0u); writer.Write(0x00000200u); writer.Write(1u); // ChestArmor, prio=1
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.Inventory.Count);
+ Assert.Equal(0x500000A0u, parsed.Value.Inventory[0].Guid);
+ Assert.Equal(0u, parsed.Value.Inventory[0].ContainerType);
+ Assert.Equal(1u, parsed.Value.Inventory[1].ContainerType);
+ Assert.Single(parsed.Value.Equipped);
+ Assert.Equal(0x500000B0u, parsed.Value.Equipped[0].Guid);
+ Assert.Equal(0x00000200u, parsed.Value.Equipped[0].EquipLocation);
+ Assert.Equal(1u, parsed.Value.Equipped[0].Priority);
+ }
+
+ [Fact]
+ public void TryParse_TrailerGameplayOptions_HeuristicLocatesInventoryStart()
+ {
+ 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); // empty enchantment mask
+
+ // option_flags = GAMEPLAY_OPTIONS (0x200)
+ writer.Write(0x200u);
+ writer.Write(0u); // options1
+ writer.Write(0u); // legacy hotbar count=0
+ writer.Write(0u); // spellbook_filters
+
+ // 16 bytes of opaque gameplay_options blob — values that *almost* look
+ // like an inventory header but fail validation (wtype > 2 or count too
+ // big), forcing the heuristic to walk past them.
+ writer.Write(0xDEADBEEFu); // looks like inv_count = 0xDEADBEEF (> 10_000) — rejected
+ writer.Write(0xCAFEBABEu);
+ writer.Write(0x12345678u);
+ writer.Write(0x87654321u);
+
+ // Real inventory: 1 entry, then equipped: 1 entry — must consume to EOF.
+ writer.Write(1u);
+ writer.Write(0x50000200u); writer.Write(0u);
+ writer.Write(1u);
+ writer.Write(0x50000300u); writer.Write(0x00000200u); writer.Write(1u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.Single(parsed!.Value.Inventory);
+ Assert.Equal(0x50000200u, parsed.Value.Inventory[0].Guid);
+ Assert.Single(parsed.Value.Equipped);
+ Assert.Equal(0x50000300u, parsed.Value.Equipped[0].Guid);
+ Assert.Equal(16, parsed.Value.GameplayOptions.Length);
+ }
+
+ [Fact]
+ public void TryParse_FullTrailer_AllSectionsPopulated()
+ {
+ 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); // empty enchantment mask
+
+ // option_flags = SHORTCUT | DESIRED_COMPS | CHARACTER_OPTIONS2 | SPELL_LISTS8
+ // = 0x01 | 0x08 | 0x40 | 0x400 = 0x449
+ writer.Write(0x449u);
+ writer.Write(0xAA000001u); // options1
+
+ // Shortcuts: count=1
+ writer.Write(1u);
+ writer.Write(3u); writer.Write(0xCAFEFACEu); writer.Write((ushort)100); writer.Write((ushort)2);
+
+ // 8 hotbars, all empty for brevity.
+ for (int i = 0; i < 8; i++) writer.Write(0u);
+
+ // Desired comps: count=1
+ writer.Write((ushort)1); writer.Write((ushort)0);
+ writer.Write(0xC1u); writer.Write(99u);
+
+ // spellbook_filters
+ writer.Write(0xF11Du);
+
+ // options2
+ writer.Write(0xBB000002u);
+
+ // Inventory + equipped (no GAMEPLAY_OPTIONS, strict path)
+ writer.Write(1u);
+ writer.Write(0x50000400u); writer.Write(0u);
+ writer.Write(1u);
+ writer.Write(0x50000500u); writer.Write(0x00000200u); writer.Write(1u);
+
+ var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
+
+ Assert.NotNull(parsed);
+ var v = parsed!.Value;
+ Assert.Equal(0xAA000001u, v.Options1);
+ Assert.Equal(0xBB000002u, v.Options2);
+ Assert.Equal(0xF11Du, v.SpellbookFilters);
+ Assert.Single(v.Shortcuts);
+ Assert.Equal(0xCAFEFACEu, v.Shortcuts[0].ObjectGuid);
+ Assert.Equal(8, v.HotbarSpells.Count);
+ Assert.All(v.HotbarSpells, l => Assert.Empty(l));
+ Assert.Single(v.DesiredComps);
+ Assert.Equal((0xC1u, 99u), v.DesiredComps[0]);
+ Assert.Single(v.Inventory);
+ Assert.Equal(0x50000400u, v.Inventory[0].Guid);
+ Assert.Single(v.Equipped);
+ Assert.Equal(0x50000500u, v.Equipped[0].Guid);
+ }
}