feat(net): AppraiseInfoParser — full PropertyBundle deserializer
Closes the single biggest P0 gap from r08: the AppraiseInfo blob carried by both IdentifyObjectResponse (0x00C9) and the initial PlayerDescription (0x0013) is now parsed end-to-end for the six core property tables. Wire layer: - AppraiseInfoParser.TryParse returns a Parsed record: (Guid, Flags, Success, PropertyBundle, SpellBook[]). - IdentifyResponseFlags enum mirrors ACE's bitfield exactly. - Header reader: u16 count + u16 numBuckets (ACE PackableHashTable.WriteHeader format). - Per-table readers: IntStatsTable, Int64StatsTable, BoolStatsTable (u32 → bool), FloatStatsTable (f64 values), StringStatsTable (string16L values with 4-byte pad), DidStatsTable. - SpellBook reader: u32 count followed by count u32 spell ids, with sanity cap at 4096 entries. What's NOT yet parsed (deferred, noted in XML doc): - ArmorProfile / CreatureProfile / WeaponProfile / HookProfile blobs require porting their respective Structure classes. - Enchantment bitfields (u16 highlight + u16 color triplets). - ArmorLevels block. The parser is defensive: malformed / truncated tables raise FormatException which is caught internally; the caller gets whatever properties parsed successfully before the error. Tests (7 new): - Header-only (no tables). - IntStatsTable round-trip with mixed sign values. - BoolStatsTable (u32 ↔ bool conversion). - StringStatsTable with padded-length strings. - SpellBook parsing. - Combined flags across multiple tables. - Truncated payload → null. Build green, 628 tests pass (up from 621). This unlocks the Attributes / Skills / Paperdoll UI panels once their renderers land — every property key the server sends now gets stored on the target ItemInstance (or — for PlayerDescription — the player's own property bag once wired). Ref: ACE AppraiseInfo.Write (AppraiseInfo.cs:735), PackableHashTable. Ref: r08 §4 payload for 0x00C9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d461279207
commit
e16f3315d2
2 changed files with 438 additions and 0 deletions
185
tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs
Normal file
185
tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class AppraiseInfoParserTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
|
||||
/// with (guid, flags, success) then per-flag tables.
|
||||
/// </summary>
|
||||
private static byte[] BuildPayload(
|
||||
uint guid,
|
||||
AppraiseInfoParser.IdentifyResponseFlags flags,
|
||||
bool success,
|
||||
(uint key, int value)[]? ints = null,
|
||||
(uint key, bool value)[]? bools = null,
|
||||
(uint key, string value)[]? strings = null,
|
||||
uint[]? spellBook = null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
|
||||
bw.Write(guid);
|
||||
bw.Write((uint)flags);
|
||||
bw.Write((uint)(success ? 1 : 0));
|
||||
|
||||
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable) && ints is not null)
|
||||
{
|
||||
bw.Write((ushort)ints.Length);
|
||||
bw.Write((ushort)16); // numBuckets hint
|
||||
foreach (var (k, v) in ints)
|
||||
{
|
||||
bw.Write(k);
|
||||
bw.Write(v);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable) && bools is not null)
|
||||
{
|
||||
bw.Write((ushort)bools.Length);
|
||||
bw.Write((ushort)8);
|
||||
foreach (var (k, v) in bools)
|
||||
{
|
||||
bw.Write(k);
|
||||
bw.Write(v ? 1u : 0u);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable) && strings is not null)
|
||||
{
|
||||
bw.Write((ushort)strings.Length);
|
||||
bw.Write((ushort)8);
|
||||
foreach (var (k, v) in strings)
|
||||
{
|
||||
bw.Write(k);
|
||||
WriteString16L(bw, v);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.SpellBook) && spellBook is not null)
|
||||
{
|
||||
bw.Write((uint)spellBook.Length);
|
||||
foreach (var sid in spellBook) bw.Write(sid);
|
||||
}
|
||||
|
||||
bw.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteString16L(BinaryWriter bw, string s)
|
||||
{
|
||||
byte[] bytes = System.Text.Encoding.ASCII.GetBytes(s);
|
||||
bw.Write((ushort)bytes.Length);
|
||||
bw.Write(bytes);
|
||||
int record = 2 + bytes.Length;
|
||||
int pad = (4 - (record & 3)) & 3;
|
||||
for (int i = 0; i < pad; i++) bw.Write((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_GuidAndFlags_ExtractedCorrectly()
|
||||
{
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 0xDEADBEEFu,
|
||||
flags: AppraiseInfoParser.IdentifyResponseFlags.None,
|
||||
success: true);
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0xDEADBEEFu, parsed!.Value.Guid);
|
||||
Assert.True(parsed.Value.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_IntStatsTable_PopulatesIntProperties()
|
||||
{
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 1,
|
||||
flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
|
||||
success: true,
|
||||
ints: new[] { ((uint)1, 100), ((uint)5, -50), ((uint)9, 42) });
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(3, parsed!.Value.Properties.Ints.Count);
|
||||
Assert.Equal(100, parsed.Value.Properties.Ints[1]);
|
||||
Assert.Equal(-50, parsed.Value.Properties.Ints[5]);
|
||||
Assert.Equal(42, parsed.Value.Properties.Ints[9]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_BoolStatsTable_ConvertsU32ToBool()
|
||||
{
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 1,
|
||||
flags: AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable,
|
||||
success: true,
|
||||
bools: new[] { ((uint)1, true), ((uint)2, false) });
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(parsed!.Value.Properties.Bools[1]);
|
||||
Assert.False(parsed.Value.Properties.Bools[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_StringStatsTable_ParsesPaddedStrings()
|
||||
{
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 1,
|
||||
flags: AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable,
|
||||
success: true,
|
||||
strings: new[] { ((uint)1, "Excalibur"), ((uint)2, "Rusty Dagger") });
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("Excalibur", parsed!.Value.Properties.Strings[1]);
|
||||
Assert.Equal("Rusty Dagger", parsed.Value.Properties.Strings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_SpellBook_ReturnsSpellIdArray()
|
||||
{
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 1,
|
||||
flags: AppraiseInfoParser.IdentifyResponseFlags.SpellBook,
|
||||
success: true,
|
||||
spellBook: new uint[] { 0x3E1, 0x3E2, 0x3E3 });
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(3, parsed!.Value.SpellBook.Length);
|
||||
Assert.Equal(0x3E1u, parsed.Value.SpellBook[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_MultipleTables_AllParsed()
|
||||
{
|
||||
var flags = AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
|
||||
| AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable
|
||||
| AppraiseInfoParser.IdentifyResponseFlags.SpellBook;
|
||||
|
||||
byte[] payload = BuildPayload(
|
||||
guid: 1, flags, success: true,
|
||||
ints: new[] { ((uint)1, 100) },
|
||||
bools: new[] { ((uint)2, true) },
|
||||
spellBook: new uint[] { 42 });
|
||||
|
||||
var parsed = AppraiseInfoParser.TryParse(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Single(parsed!.Value.Properties.Ints);
|
||||
Assert.Single(parsed.Value.Properties.Bools);
|
||||
Assert.Single(parsed.Value.SpellBook);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Truncated_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AppraiseInfoParser.TryParse(new byte[4]));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue