diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 982afff..73cb9f4 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -402,7 +402,17 @@ public static class PlayerDescriptionParser if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2)) options2 = ReadU32(payload, ref pos); - if (!optionFlags.HasFlag(CharacterOptionDataFlag.GameplayOptions)) + 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); @@ -760,6 +770,43 @@ public static class PlayerDescriptionParser 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/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs index 458169e..0f3560d 100644 --- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs @@ -663,4 +663,46 @@ public sealed class PlayerDescriptionParserTests 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); + } }