Merge branch 'claude/bold-proskuriakova-d4fb2c' — ISSUES.md #13 PlayerDescription trailer parser + F.5a roadmap entry
Closes ISSUES.md #13. PlayerDescriptionParser now walks the full trailer (Options1 / Shortcuts / HotbarSpells / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped) ported from holtburger events.rs:503-625 + shortcuts.rs:13-34. The trickiest piece — gameplay_options — uses a 4-byte-aligned forward heuristic (TryHeuristicInventoryStart) mirroring holtburger's find_inventory_start_after_gameplay_options. Trailer walk wrapped in its own inner try/catch so a malformed trailer cannot destroy upstream attribute/skill/spell/enchantment data; new Parsed.TrailerTruncated flag distinguishes clean parse from graceful-degradation parse, with diagnostic log under ACDREAM_DUMP_VITALS=1. GameEventWiring registers parsed Inventory + Equipped into ItemRepository at login (acceptance criterion: ItemRepository.Count > 0 after login, exercised by GameEventWiringTests). 20 PD parser tests + 1 wiring acceptance test; 282/282 AcDream.Core.Net.Tests pass. Plan: docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md. Roadmap update: F.5a (visible-at-login dev panels) added as the first deliverable that actually consumes the new trailer data — ImGui dev panels under ACDREAM_DEVTOOLS=1 binding to AcDream.UI.Abstractions ViewModels. 13 task commits + 1 review-followup + 1 nit-fix + 1 roadmap = 16 commits on the branch.
This commit is contained in:
commit
8f43a58037
7 changed files with 2085 additions and 32 deletions
|
|
@ -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
|
# 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`
|
## #51 — [DONE 2026-05-09 · da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's `FSplitNESW`
|
||||||
|
|
||||||
**Closed:** 2026-05-09
|
**Closed:** 2026-05-09
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.
|
**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone.
|
||||||
|
|
||||||
|
|
|
||||||
1242
docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md
Normal file
1242
docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -395,6 +395,41 @@ public static class GameEventWiring
|
||||||
if (dumpPd)
|
if (dumpPd)
|
||||||
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,63 @@ public static class PlayerDescriptionParser
|
||||||
Cooldown = 0x08,
|
Cooldown = 0x08,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Bitmask of which optional trailer sections are present in
|
||||||
|
/// the PlayerDescription wire payload. Holtburger
|
||||||
|
/// <c>events.rs:503-607</c>; ACE <c>CharacterOptionDataFlag</c>.</summary>
|
||||||
|
[Flags]
|
||||||
|
public enum CharacterOptionDataFlag : uint
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Shortcut = 0x00000001,
|
||||||
|
SquelchList = 0x00000002,
|
||||||
|
MultiSpellList = 0x00000004,
|
||||||
|
DesiredComps = 0x00000008,
|
||||||
|
ExtendedMultiSpellLists = 0x00000010,
|
||||||
|
SpellbookFilters = 0x00000020,
|
||||||
|
CharacterOptions2 = 0x00000040,
|
||||||
|
TimestampFormat = 0x00000080,
|
||||||
|
GenericQualitiesData = 0x00000100,
|
||||||
|
GameplayOptions = 0x00000200,
|
||||||
|
SpellLists8 = 0x00000400,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One shortcut bar entry. 16 bytes wire size.
|
||||||
|
/// holtburger <c>shortcuts.rs:13-34</c>. Named <c>ShortcutEntry</c>
|
||||||
|
/// (not <c>Shortcut</c>) to avoid a homograph with the
|
||||||
|
/// <see cref="CharacterOptionDataFlag.Shortcut"/> flag bit, which is
|
||||||
|
/// referenced from the same scope as instances of this type in the
|
||||||
|
/// trailer walker.</summary>
|
||||||
|
public readonly record struct ShortcutEntry(
|
||||||
|
uint Index,
|
||||||
|
uint ObjectGuid,
|
||||||
|
ushort SpellId,
|
||||||
|
ushort Layer);
|
||||||
|
|
||||||
|
/// <summary>One inventory entry — a guid plus a ContainerType
|
||||||
|
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
|
||||||
|
/// <c>events.rs:143-168</c> validates <c>ContainerType <= 2</c>
|
||||||
|
/// in <c>unpack_inventory_and_equipped_strict</c>.</summary>
|
||||||
|
public readonly record struct InventoryEntry(
|
||||||
|
uint Guid,
|
||||||
|
uint ContainerType);
|
||||||
|
|
||||||
|
/// <summary>One equipped object entry. Holtburger
|
||||||
|
/// <c>events.rs:180-190</c>: <c>(Guid guid, u32 loc, u32 prio)</c>.
|
||||||
|
/// <paramref name="EquipLocation"/> is an <c>EquipMask</c> bitfield;
|
||||||
|
/// <paramref name="Priority"/> orders overlapping equips in the
|
||||||
|
/// same slot.</summary>
|
||||||
|
public readonly record struct EquippedEntry(
|
||||||
|
uint Guid,
|
||||||
|
uint EquipLocation,
|
||||||
|
uint Priority);
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="TryParse"/>. Trailer fields
|
||||||
|
/// (<c>OptionFlags</c> through <c>Equipped</c>) may be partially
|
||||||
|
/// populated when <see cref="TrailerTruncated"/> is <c>true</c> —
|
||||||
|
/// the parse degraded gracefully rather than discarding upstream
|
||||||
|
/// attribute / skill / spell / enchantment data. Callers that
|
||||||
|
/// require all-or-nothing trailer semantics should ignore the
|
||||||
|
/// trailer fields when this flag is set.</summary>
|
||||||
public readonly record struct Parsed(
|
public readonly record struct Parsed(
|
||||||
uint WeenieType,
|
uint WeenieType,
|
||||||
DescriptionPropertyFlag PropertyFlags,
|
DescriptionPropertyFlag PropertyFlags,
|
||||||
|
|
@ -187,7 +244,18 @@ public static class PlayerDescriptionParser
|
||||||
IReadOnlyList<AttributeEntry> Attributes,
|
IReadOnlyList<AttributeEntry> Attributes,
|
||||||
IReadOnlyList<SkillEntry> Skills,
|
IReadOnlyList<SkillEntry> Skills,
|
||||||
IReadOnlyDictionary<uint, float> Spells,
|
IReadOnlyDictionary<uint, float> Spells,
|
||||||
IReadOnlyList<EnchantmentEntry> Enchantments);
|
IReadOnlyList<EnchantmentEntry> Enchantments,
|
||||||
|
CharacterOptionDataFlag OptionFlags,
|
||||||
|
uint Options1,
|
||||||
|
uint Options2,
|
||||||
|
IReadOnlyList<ShortcutEntry> Shortcuts,
|
||||||
|
IReadOnlyList<IReadOnlyList<uint>> HotbarSpells,
|
||||||
|
IReadOnlyList<(uint Id, uint Amount)> DesiredComps,
|
||||||
|
uint SpellbookFilters,
|
||||||
|
ReadOnlyMemory<byte> GameplayOptions,
|
||||||
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
|
IReadOnlyList<EquippedEntry> Equipped,
|
||||||
|
bool TrailerTruncated);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
|
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
|
||||||
|
|
@ -249,18 +317,126 @@ public static class PlayerDescriptionParser
|
||||||
ReadSpellTable(payload, ref pos, spells);
|
ReadSpellTable(payload, ref pos, spells);
|
||||||
|
|
||||||
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
|
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
|
||||||
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
|
|
||||||
// Holtburger events.rs:462-501. After this block come options /
|
|
||||||
// shortcuts / hotbars / inventory / equipped — those need a
|
|
||||||
// heuristic walker for the variable-length gameplay_options blob.
|
|
||||||
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
|
|
||||||
// partial parses still populate enchantments.
|
|
||||||
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
|
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
|
||||||
ReadEnchantmentBlock(payload, ref pos, enchantments);
|
ReadEnchantmentBlock(payload, ref pos, enchantments);
|
||||||
|
|
||||||
|
// ── Trailer (Issue #13): options + shortcuts + hotbars + inventory ──
|
||||||
|
// Wrapped in its own try/catch — a malformed trailer must not destroy
|
||||||
|
// the attribute / skill / spell / enchantment data we already extracted.
|
||||||
|
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
|
||||||
|
uint options1 = 0;
|
||||||
|
uint options2 = 0;
|
||||||
|
uint spellbookFilters = 0;
|
||||||
|
List<ShortcutEntry> shortcuts = new();
|
||||||
|
List<IReadOnlyList<uint>> hotbarSpells = new();
|
||||||
|
List<(uint, uint)> desiredComps = new();
|
||||||
|
ReadOnlyMemory<byte> gameplayOptions = ReadOnlyMemory<byte>.Empty;
|
||||||
|
List<InventoryEntry> inventory = new();
|
||||||
|
List<EquippedEntry> equipped = new();
|
||||||
|
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<uint>((int)count);
|
||||||
|
for (uint i = 0; i < count; i++)
|
||||||
|
list.Add(ReadU32(payload, ref pos));
|
||||||
|
hotbarSpells.Add(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (payload.Length - pos >= 4)
|
||||||
|
{
|
||||||
|
// Legacy single-list fallback (holtburger events.rs:544-556).
|
||||||
|
uint count = ReadU32(payload, ref pos);
|
||||||
|
if (count > 10_000) throw new FormatException("unreasonable hotbar count");
|
||||||
|
var list = new List<uint>((int)count);
|
||||||
|
for (uint i = 0; i < count; i++)
|
||||||
|
list.Add(ReadU32(payload, ref pos));
|
||||||
|
hotbarSpells.Add(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionFlags.HasFlag(CharacterOptionDataFlag.DesiredComps))
|
||||||
|
{
|
||||||
|
// holtburger events.rs:558-574 — u16 count + u16 padding (4-byte header).
|
||||||
|
if (payload.Length - pos < 4) throw new FormatException("truncated desired_comps header");
|
||||||
|
ushort count = ReadU16(payload, ref pos);
|
||||||
|
ReadU16(payload, ref pos); // padding/buckets — discarded
|
||||||
|
if (count > 10_000) throw new FormatException("unreasonable desired_comps count");
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
uint id = ReadU32(payload, ref pos);
|
||||||
|
uint amt = ReadU32(payload, ref pos);
|
||||||
|
desiredComps.Add((id, amt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holtburger events.rs:576-582 — spellbook_filters is optional; defaults
|
||||||
|
// to 0 if EOF.
|
||||||
|
if (payload.Length - pos >= 4)
|
||||||
|
spellbookFilters = ReadU32(payload, ref pos);
|
||||||
|
|
||||||
|
if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2))
|
||||||
|
options2 = ReadU32(payload, ref pos);
|
||||||
|
|
||||||
|
if (optionFlags.HasFlag(CharacterOptionDataFlag.GameplayOptions))
|
||||||
|
{
|
||||||
|
int gameplayStart = pos;
|
||||||
|
if (TryHeuristicInventoryStart(payload, gameplayStart, out int invStart, out int end,
|
||||||
|
inventory, equipped))
|
||||||
|
{
|
||||||
|
gameplayOptions = payload.Slice(gameplayStart, invStart - gameplayStart).ToArray();
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Strict path: inventory + equipped follow directly.
|
||||||
|
TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
// Trailer corrupted — keep what we have and flag it. Once
|
||||||
|
// Tasks 3-9 add list reads inside this try block, partial
|
||||||
|
// lists may be visible to callers; TrailerTruncated tells
|
||||||
|
// them so they can ignore the trailer if they need all-or-
|
||||||
|
// nothing semantics.
|
||||||
|
trailerTruncated = true;
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1")
|
||||||
|
System.Console.WriteLine($"PlayerDescriptionParser: trailer FormatException at pos={pos}/{payload.Length}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
return new Parsed(
|
return new Parsed(
|
||||||
weenieType, propertyFlags, vectorFlags, hasHealth,
|
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)
|
catch (FormatException ex)
|
||||||
{
|
{
|
||||||
|
|
@ -281,7 +457,16 @@ public static class PlayerDescriptionParser
|
||||||
{
|
{
|
||||||
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
|
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
|
||||||
bundle, positions, attributes, skills, spells,
|
bundle, positions, attributes, skills, spells,
|
||||||
System.Array.Empty<EnchantmentEntry>());
|
System.Array.Empty<EnchantmentEntry>(),
|
||||||
|
CharacterOptionDataFlag.None, 0u, 0u,
|
||||||
|
System.Array.Empty<ShortcutEntry>(),
|
||||||
|
System.Array.Empty<IReadOnlyList<uint>>(),
|
||||||
|
System.Array.Empty<(uint, uint)>(),
|
||||||
|
0u,
|
||||||
|
ReadOnlyMemory<byte>.Empty,
|
||||||
|
System.Array.Empty<InventoryEntry>(),
|
||||||
|
System.Array.Empty<EquippedEntry>(),
|
||||||
|
TrailerTruncated: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attribute block reader ──────────────────────────────────────────────
|
// ── Attribute block reader ──────────────────────────────────────────────
|
||||||
|
|
@ -542,6 +727,86 @@ public static class PlayerDescriptionParser
|
||||||
bucket);
|
bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Strict inventory + equipped block reader. Returns true if
|
||||||
|
/// the bytes from <paramref name="pos"/> parse cleanly per holtburger
|
||||||
|
/// events.rs:143-193 (<c>unpack_inventory_and_equipped_strict</c>).
|
||||||
|
/// Counts capped at 10,000; inventory ContainerType must be 0..2
|
||||||
|
/// (NonContainer / Container / Foci).</summary>
|
||||||
|
private static bool TryUnpackInventoryStrict(
|
||||||
|
ReadOnlySpan<byte> src, ref int pos,
|
||||||
|
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
|
||||||
|
{
|
||||||
|
inventory.Clear();
|
||||||
|
equipped.Clear();
|
||||||
|
if (pos + 4 > src.Length) return false;
|
||||||
|
uint invCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
|
||||||
|
pos += 4;
|
||||||
|
if (invCount > 10_000) return false;
|
||||||
|
|
||||||
|
for (uint i = 0; i < invCount; i++)
|
||||||
|
{
|
||||||
|
if (pos + 8 > src.Length) return false;
|
||||||
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
|
||||||
|
uint wtype = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
|
||||||
|
pos += 8;
|
||||||
|
if (wtype > 2) return false;
|
||||||
|
inventory.Add(new InventoryEntry(guid, wtype));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos + 4 > src.Length) return false;
|
||||||
|
uint eqCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
|
||||||
|
pos += 4;
|
||||||
|
if (eqCount > 10_000) return false;
|
||||||
|
|
||||||
|
for (uint i = 0; i < eqCount; i++)
|
||||||
|
{
|
||||||
|
if (pos + 12 > src.Length) return false;
|
||||||
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
|
||||||
|
uint loc = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
|
||||||
|
uint prio = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 8));
|
||||||
|
pos += 12;
|
||||||
|
equipped.Add(new EquippedEntry(guid, loc, prio));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>4-byte-aligned forward scan from <paramref name="start"/>
|
||||||
|
/// looking for the first offset where <c>TryUnpackInventoryStrict</c>
|
||||||
|
/// consumes exactly to end-of-buffer. Mirrors holtburger
|
||||||
|
/// <c>find_inventory_start_after_gameplay_options</c> in events.rs:195-218.</summary>
|
||||||
|
private static bool TryHeuristicInventoryStart(
|
||||||
|
ReadOnlySpan<byte> src, int start,
|
||||||
|
out int invStart, out int end,
|
||||||
|
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
|
||||||
|
{
|
||||||
|
invStart = end = 0;
|
||||||
|
inventory.Clear();
|
||||||
|
equipped.Clear();
|
||||||
|
if (start + 8 > src.Length) return false;
|
||||||
|
|
||||||
|
int candidate = start;
|
||||||
|
int misalign = candidate & 3;
|
||||||
|
if (misalign != 0) candidate += 4 - misalign;
|
||||||
|
|
||||||
|
int last = src.Length - 8;
|
||||||
|
while (candidate <= last)
|
||||||
|
{
|
||||||
|
int tmp = candidate;
|
||||||
|
var tmpInv = new List<InventoryEntry>();
|
||||||
|
var tmpEq = new List<EquippedEntry>();
|
||||||
|
if (TryUnpackInventoryStrict(src, ref tmp, tmpInv, tmpEq) && tmp == src.Length)
|
||||||
|
{
|
||||||
|
invStart = candidate;
|
||||||
|
end = tmp;
|
||||||
|
inventory.AddRange(tmpInv);
|
||||||
|
equipped.AddRange(tmpEq);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
candidate += 4;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
|
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
|
||||||
{
|
{
|
||||||
if (src.Length - pos < 2) throw new FormatException("truncated u16");
|
if (src.Length - pos < 2) throw new FormatException("truncated u16");
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AcDream.Core.Chat;
|
using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
|
@ -328,4 +329,50 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Contains("Mana Stone", e.Text);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,4 +334,434 @@ public sealed class PlayerDescriptionParserTests
|
||||||
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
|
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
|
||||||
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
|
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_TrailerOptionFlagsAndOptions1_AreReadAfterEnchantments()
|
||||||
|
{
|
||||||
|
// ATTRIBUTE | ENCHANTMENT vector flag; empty enchantment mask (0).
|
||||||
|
// After mask, trailer adds u32 option_flags + u32 options1.
|
||||||
|
var sb = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(sb);
|
||||||
|
writer.Write(0u); // propertyFlags
|
||||||
|
writer.Write(0x52u); // weenieType
|
||||||
|
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
|
||||||
|
writer.Write(1u); // has_health
|
||||||
|
writer.Write(0u); // empty attribute_flags
|
||||||
|
|
||||||
|
writer.Write(0u); // EnchantmentMask = empty
|
||||||
|
|
||||||
|
// Trailer header: option_flags + options1
|
||||||
|
writer.Write(0u); // option_flags = None — no further sections
|
||||||
|
writer.Write(0xDEADBEEFu); // options1 sentinel
|
||||||
|
|
||||||
|
// No more bytes — spellbook_filters is optional (defaults to 0).
|
||||||
|
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed!.Value.OptionFlags);
|
||||||
|
Assert.Equal(0xDEADBEEFu, parsed.Value.Options1);
|
||||||
|
Assert.Empty(parsed.Value.Shortcuts);
|
||||||
|
Assert.Empty(parsed.Value.Inventory);
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue