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