feat(net): #13 heuristic inventory locator after gameplay_options blob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 09:37:46 +02:00
parent d9a5e40203
commit 91693ea44c
2 changed files with 90 additions and 1 deletions

View file

@ -402,7 +402,17 @@ public static class PlayerDescriptionParser
if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2)) if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2))
options2 = ReadU32(payload, ref pos); 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. // Strict path: inventory + equipped follow directly.
TryUnpackInventoryStrict(payload, ref pos, inventory, equipped); TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
@ -760,6 +770,43 @@ public static class PlayerDescriptionParser
return true; 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");

View file

@ -663,4 +663,46 @@ public sealed class PlayerDescriptionParserTests
Assert.Equal(0x00000200u, parsed.Value.Equipped[0].EquipLocation); Assert.Equal(0x00000200u, parsed.Value.Equipped[0].EquipLocation);
Assert.Equal(1u, parsed.Value.Equipped[0].Priority); 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);
}
} }