diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 927e74b..209257d 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -309,26 +309,42 @@ 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(); + + try + { + if (payload.Length - pos >= 8) + { + optionFlags = (CharacterOptionDataFlag)ReadU32(payload, ref pos); + options1 = ReadU32(payload, ref pos); + } + } + catch (FormatException) + { + // Trailer corrupted — keep what we have and return. + } + return new Parsed( 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()); + optionFlags, options1, options2, + shortcuts, hotbarSpells, desiredComps, spellbookFilters, + gameplayOptions, inventory, equipped); } catch (FormatException ex) { diff --git a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs index 4908bb8..2e2fe75 100644 --- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs @@ -334,4 +334,33 @@ 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); + } }