Visual-verified — Vitals window now shows three bars (HP/Stam/Mana)
with live values. Closes ISSUES.md #5; ~95% reading on Stam/Mana
traced to active buff multipliers, filed as #6.
Why the rewrite
The first attempt (commit d42bf57) routed PlayerDescription (0x0013)
through AppraiseInfoParser, trusting a misleading xmldoc claim.
Live diagnostics proved the format is wrong — ACE source
(GameEventPlayerDescription.WriteEventBody) hand-writes a body
distinct from IdentifyObjectResponse's AppraiseInfo: property
hashtables gated on DescriptionPropertyFlag, vector-flag-gated
attribute / skill / spell blocks, then a long options + inventory
trailer. Vitals only arrive via the attribute block at login.
Holtburger's events.rs:220-625 has the canonical client-side
unpacker; this commit ports the early-section walker through spells.
What landed
PlayerDescriptionParser.cs (new — 350 LOC):
Walks propertyFlags + weenieType, then property hashtables
(Int32/Int64/Bool/Double/String/Did/Iid) + Position table —
each gated on a property flag bit, header is `u16 count, u16
buckets`. Then vectorFlags + has_health + the attribute block
(primary attrs 1..6 = 12 B each, vitals 7..9 = 16 B with
`current`), then optional Skill + Spell tables. Stops cleanly
before the options/shortcuts/hotbars/inventory trailer (filed
as #7 — heuristic alignment search needed for gameplay_options).
PrivateUpdateVital.cs (new — 95 LOC):
Wire parsers for the GameMessage opcodes 0x02E7 (full snapshot)
and 0x02E9 (current-only delta), per holtburger UpdateVital +
UpdateVitalCurrent. WorldSession dispatches each to a session-
level event the GameWindow forwards into LocalPlayerState.
LocalPlayerState (full redesign):
VitalKind (Health/Stamina/Mana) + AttributeKind (six primary).
VitalSnapshot stores ranks/start/xp/current; AttributeSnapshot
stores ranks/start/xp with `Current = ranks+start` per
holtburger. GetMaxApprox computes the retail formula
vital.(ranks+start) + attribute_contribution
where the contribution is hardcoded from retail's
SecondaryAttributeTable: Endurance/2 for Health, Endurance for
Stamina, Self for Mana. Enchantment buffs not yet folded in
(filed as #6). VitalIdToKind now accepts both ID systems
(1..6 wire, 7..9 PD attribute block); AttributeIdToKind covers
primary attrs 1..6.
GameEventWiring:
PlayerDescription handler. Walks parsed.Attributes, routes
primary attrs (id 1..6) to OnAttributeUpdate and vitals
(id 7..9) to OnVitalUpdate. Player's full learned spellbook
also lands here. ACDREAM_DUMP_VITALS=1 traces every PD attribute
+ every PrivateUpdateVital(Current) opcode for diagnostics.
WorldSession:
Dispatch chain re-ordered — the diagnostic else-if for
ACDREAM_DUMP_OPCODES=1 was originally placed before
GameEventEnvelope.Opcode, which silently intercepted 0xF7B0 and
broke UpdateHealth dispatch when the env var was set. Moved to
the very end of the chain so it only fires for genuinely
unhandled opcodes. (Diagnostic-only regression; production
launches without the env var were unaffected.)
Test deltas
Added:
- PlayerDescriptionParserTests (6 — empty header, full attribute
block, partial flags, post-property-table walk, spell table)
- PrivateUpdateVitalTests (7 — fixture round-trip, vital ID
coverage, opcode rejection, truncation)
- LocalPlayerStateTests rewritten (20 — VitalIdToKind +
AttributeIdToKind theories, Endurance/Self formula coverage,
delta semantics, change events)
- GameEventWiringTests for PlayerDescription dispatch (2 —
end-to-end populate + spellbook feed)
Updated:
- VitalsVMTests rephrased onto the new OnVitalUpdate API.
Total: 765 → 817 tests passing.
Diagnostics
ACDREAM_DUMP_VITALS=1 — log every PD attribute extracted,
every 0x02E7/0x02E9 dispatch.
ACDREAM_DUMP_OPCODES=1 — log first occurrence of any unhandled
GameMessage opcode (now correctly placed at end of chain).
Visual verify
$env:ACDREAM_DEVTOOLS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug
Vitals window shows three bars; HP at 100%, Stam/Mana at ~95%
(the gap is buff enchantments — filed as #6 with the holtburger
multiplier+additive aggregator pattern as the reference for the
fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
10 KiB
C#
235 lines
10 KiB
C#
using System.Buffers.Binary;
|
||
using System.Text;
|
||
using AcDream.Core.Net.Messages;
|
||
|
||
namespace AcDream.Core.Net.Tests;
|
||
|
||
/// <summary>
|
||
/// Wire-format tests for <see cref="PlayerDescriptionParser"/>.
|
||
/// Builds synthetic payloads matching ACE
|
||
/// <c>GameEventPlayerDescription.WriteEventBody</c> and confirms the
|
||
/// walker extracts the attribute block + early sections correctly.
|
||
/// </summary>
|
||
public sealed class PlayerDescriptionParserTests
|
||
{
|
||
/// <summary>
|
||
/// Build a minimal PlayerDescription payload with empty property
|
||
/// flags + no-attribute vector flags. Just header bytes — useful
|
||
/// for testing the most basic walk.
|
||
/// </summary>
|
||
private static byte[] BuildEmpty(uint weenieType = 1u)
|
||
{
|
||
// u32 propertyFlags + u32 weenieType + u32 vectorFlags + u32 has_health
|
||
byte[] body = new byte[16];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body, 0u); // no property flags
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), weenieType);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); // no vector flags
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), 0u); // has_health=false
|
||
return body;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a payload with an Attribute-block-only body
|
||
/// (vector_flags = ATTRIBUTE) populating all 9 entries with known
|
||
/// ranks/start/xp values — primary attrs 1..6 and vitals 7..9.
|
||
/// </summary>
|
||
private static byte[] BuildWithFullAttributeBlock(
|
||
uint healthCurrent, uint stamCurrent, uint manaCurrent)
|
||
{
|
||
// No property tables, no positions.
|
||
// Header (8) + vectorFlags+has_health (8) + attribFlags (4)
|
||
// + 6 primary entries × 12 + 3 vital entries × 16 = 8+8+4+72+48 = 140.
|
||
byte[] body = new byte[140];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags = 0
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // weenieType
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health = true
|
||
// attributeFlags = AttributeCache.Full = 0x1FF (bits 0..8)
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4;
|
||
// Primary attrs 1..6 — ranks/start/xp triplets.
|
||
for (uint i = 1; i <= 6; i++)
|
||
{
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 10u * i); p += 4; // ranks
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u + i); p += 4; // start
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1000u); p += 4; // xp
|
||
}
|
||
// Vitals 7..9 — Health (id=7), Stamina (id=8), Mana (id=9).
|
||
// ranks=20, start=80 → MaxApprox = 100. Currents = test args.
|
||
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: healthCurrent);
|
||
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: stamCurrent);
|
||
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: manaCurrent);
|
||
return body;
|
||
}
|
||
|
||
private static void WriteVital(byte[] body, ref int p, uint ranks, uint start, uint xp, uint current)
|
||
{
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), current); p += 4;
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_ReturnsNull_OnTruncatedHeader()
|
||
{
|
||
byte[] tooShort = new byte[4];
|
||
Assert.Null(PlayerDescriptionParser.TryParse(tooShort));
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_EmptyBody_ParsesHeaderOnly()
|
||
{
|
||
var p = PlayerDescriptionParser.TryParse(BuildEmpty(weenieType: 0x52u));
|
||
|
||
Assert.NotNull(p);
|
||
Assert.Equal(0x52u, p!.Value.WeenieType);
|
||
Assert.Equal(PlayerDescriptionParser.DescriptionPropertyFlag.None, p.Value.PropertyFlags);
|
||
Assert.Equal(PlayerDescriptionParser.DescriptionVectorFlag.None, p.Value.VectorFlags);
|
||
Assert.False(p.Value.HasHealth);
|
||
Assert.Empty(p.Value.Attributes);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_AttributeBlock_PopulatesAllNineEntries()
|
||
{
|
||
var p = PlayerDescriptionParser.TryParse(
|
||
BuildWithFullAttributeBlock(healthCurrent: 90, stamCurrent: 75, manaCurrent: 60));
|
||
|
||
Assert.NotNull(p);
|
||
Assert.True(p!.Value.HasHealth);
|
||
Assert.Equal(9, p.Value.Attributes.Count);
|
||
|
||
// Primary attrs 1..6 have null Current.
|
||
for (uint i = 1; i <= 6; i++)
|
||
{
|
||
var attr = p.Value.Attributes.First(a => a.AtType == i);
|
||
Assert.Equal(10u * i, attr.Ranks);
|
||
Assert.Equal(50u + i, attr.Start);
|
||
Assert.Equal(1000u, attr.Xp);
|
||
Assert.Null(attr.Current);
|
||
}
|
||
|
||
// Vital 7 = Health (current = 90).
|
||
var health = p.Value.Attributes.First(a => a.AtType == 7);
|
||
Assert.Equal(20u, health.Ranks);
|
||
Assert.Equal(80u, health.Start);
|
||
Assert.Equal(90u, health.Current);
|
||
// Vital 8 = Stamina.
|
||
var stam = p.Value.Attributes.First(a => a.AtType == 8);
|
||
Assert.Equal(75u, stam.Current);
|
||
// Vital 9 = Mana.
|
||
var mana = p.Value.Attributes.First(a => a.AtType == 9);
|
||
Assert.Equal(60u, mana.Current);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_SkipsPrimaryAttribute_WhenItsAttributeFlagBitIsClear()
|
||
{
|
||
// attribute_flags only sets bits for Strength (id=1) and Health (id=7).
|
||
// Body shape: 8 header + 8 vector header + 4 attr_flags + 12 (str) + 16 (health) = 48
|
||
byte[] body = new byte[48];
|
||
int p = 0;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
|
||
// bit 0 (Strength = id 1) + bit 6 (Health = id 7) = 0x41
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x41u); p += 4;
|
||
// Strength entry (12 B):
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 100u); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 60u); p += 4;
|
||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
|
||
// Health entry (16 B):
|
||
WriteVital(body, ref p, ranks: 25u, start: 75u, xp: 0u, current: 99u);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(2, parsed!.Value.Attributes.Count);
|
||
Assert.Equal(1u, parsed.Value.Attributes[0].AtType); // Strength
|
||
Assert.Equal(7u, parsed.Value.Attributes[1].AtType); // Health
|
||
Assert.Equal(99u, parsed.Value.Attributes[1].Current);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_PropertyTablesWalked_OffsetReachesAttributeBlock()
|
||
{
|
||
// PROPERTY_INT32 + PROPERTY_STRING tables present, then ATTRIBUTE
|
||
// block. If the walker can't skip past the property tables it'll
|
||
// read garbage from the table bytes as if it were the attribute
|
||
// block — this test fails on any walking error.
|
||
// PROPERTY_INT32 = 0x0001, PROPERTY_STRING = 0x0010 → flags = 0x0011
|
||
|
||
var sb = new MemoryStream();
|
||
using var writer = new BinaryWriter(sb);
|
||
writer.Write(0x0011u); // propertyFlags = INT32 | STRING
|
||
writer.Write(0x52u); // weenieType
|
||
|
||
// INT32 table: 2 entries.
|
||
writer.Write((ushort)2);
|
||
writer.Write((ushort)32); // buckets (ignored)
|
||
writer.Write(101u); writer.Write(42);
|
||
writer.Write(102u); writer.Write(-7);
|
||
|
||
// STRING table: 1 entry "Acdream" (7 chars + 2 length + 3 padding = 12).
|
||
writer.Write((ushort)1);
|
||
writer.Write((ushort)32); // buckets
|
||
writer.Write(1u); // PropertyString.Name = 1
|
||
byte[] name = Encoding.ASCII.GetBytes("Acdream");
|
||
writer.Write((ushort)name.Length);
|
||
writer.Write(name);
|
||
writer.Write(new byte[(4 - ((2 + name.Length) & 3)) & 3]); // pad to 4
|
||
|
||
// vectorFlags = ATTRIBUTE, has_health = 1.
|
||
writer.Write(0x01u);
|
||
writer.Write(1u);
|
||
|
||
// Attribute block: only Health (bit 6 = 0x40), current=88.
|
||
writer.Write(0x40u);
|
||
WriteVitalToWriter(writer, ranks: 30u, start: 70u, xp: 0u, current: 88u);
|
||
|
||
byte[] body = sb.ToArray();
|
||
var parsed = PlayerDescriptionParser.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(2, parsed!.Value.Properties.Ints.Count);
|
||
Assert.Equal(42, parsed.Value.Properties.Ints[101]);
|
||
Assert.Equal(-7, parsed.Value.Properties.Ints[102]);
|
||
Assert.Equal("Acdream", parsed.Value.Properties.Strings[1]);
|
||
Assert.Single(parsed.Value.Attributes);
|
||
Assert.Equal(7u, parsed.Value.Attributes[0].AtType);
|
||
Assert.Equal(88u, parsed.Value.Attributes[0].Current);
|
||
}
|
||
|
||
private static void WriteVitalToWriter(BinaryWriter w, uint ranks, uint start, uint xp, uint current)
|
||
{
|
||
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_SpellTable_PopulatesSpellsDictionary()
|
||
{
|
||
// ATTRIBUTE | SPELL = 0x101. Empty attribute block (flags=0). Spell
|
||
// table with two entries.
|
||
var sb = new MemoryStream();
|
||
using var writer = new BinaryWriter(sb);
|
||
writer.Write(0u); // propertyFlags
|
||
writer.Write(0x52u); // weenieType
|
||
writer.Write(0x101u); // vectorFlags = ATTRIBUTE | SPELL
|
||
writer.Write(1u); // has_health
|
||
writer.Write(0u); // attribute_flags = 0 → no entries
|
||
|
||
writer.Write((ushort)2); // spell count
|
||
writer.Write((ushort)64); // spell buckets
|
||
writer.Write(1234u); writer.Write(2.0f);
|
||
writer.Write(5678u); writer.Write(2.0f);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(2, parsed!.Value.Spells.Count);
|
||
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
|
||
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
|
||
}
|
||
}
|