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); + } }