acdream/tests/AcDream.Core.Net.Tests/PrivateUpdateVitalTests.cs
Erik 7da2a027d4 feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block
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>
2026-04-25 16:42:24 +02:00

121 lines
4.5 KiB
C#

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Wire-format tests for <see cref="PrivateUpdateVital.TryParseFull"/>
/// + <see cref="PrivateUpdateVital.TryParseCurrent"/>. Cross-checks
/// holtburger's published test fixture (<c>UPDATE_VITAL_CURRENT_PRIVATE</c>).
/// </summary>
public sealed class PrivateUpdateVitalTests
{
private static byte[] BuildFull(byte seq, uint vital, uint ranks, uint start, uint xp, uint current)
{
// u32 opcode (0x02E7) + u8 seq + 5 * u32 = 25 bytes
byte[] body = new byte[25];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateVital.FullOpcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), vital);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), ranks);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), start);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(21), current);
return body;
}
private static byte[] BuildCurrent(byte seq, uint vital, uint current)
{
// u32 opcode (0x02E9) + u8 seq + 2 * u32 = 13 bytes
byte[] body = new byte[13];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateVital.CurrentOpcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), vital);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), current);
return body;
}
[Fact]
public void TryParseFull_RoundTrip()
{
// Mirrors holtburger's test_private_update_vital_fixture values:
// sequence: 12, vital: 2 (Health), ranks: 100, start: 12345, xp: 67890, current: 100
var bytes = BuildFull(seq: 12, vital: 2, ranks: 100, start: 12345, xp: 67890, current: 100);
var p = PrivateUpdateVital.TryParseFull(bytes);
Assert.NotNull(p);
Assert.Equal((byte)12, p!.Value.Sequence);
Assert.Equal(2u, p.Value.VitalId);
Assert.Equal(100u, p.Value.Ranks);
Assert.Equal(12345u, p.Value.Start);
Assert.Equal(67890u, p.Value.Xp);
Assert.Equal(100u, p.Value.Current);
}
[Fact]
public void TryParseCurrent_RoundTrip()
{
// Mirrors holtburger's test_private_update_vital_current_fixture:
// sequence: 12, vital: 2 (Health), current: 100
var bytes = BuildCurrent(seq: 12, vital: 2, current: 100);
var p = PrivateUpdateVital.TryParseCurrent(bytes);
Assert.NotNull(p);
Assert.Equal((byte)12, p!.Value.Sequence);
Assert.Equal(2u, p.Value.VitalId);
Assert.Equal(100u, p.Value.Current);
}
[Fact]
public void TryParseFull_RejectsWrongOpcode()
{
var bytes = BuildFull(seq: 12, vital: 2, ranks: 100, start: 12345, xp: 67890, current: 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEAD_BEEFu);
Assert.Null(PrivateUpdateVital.TryParseFull(bytes));
}
[Fact]
public void TryParseCurrent_RejectsWrongOpcode()
{
var bytes = BuildCurrent(seq: 12, vital: 2, current: 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEAD_BEEFu);
Assert.Null(PrivateUpdateVital.TryParseCurrent(bytes));
}
[Fact]
public void TryParseFull_RejectsTruncatedBody()
{
var full = BuildFull(seq: 12, vital: 2, ranks: 100, start: 0, xp: 0, current: 100);
// 24 bytes — one short of 25.
Assert.Null(PrivateUpdateVital.TryParseFull(full[..24]));
}
[Fact]
public void TryParseCurrent_RejectsTruncatedBody()
{
var current = BuildCurrent(seq: 1, vital: 4, current: 50);
// 12 bytes — one short of 13.
Assert.Null(PrivateUpdateVital.TryParseCurrent(current[..12]));
}
[Fact]
public void TryParseFull_ParsesStaminaAndManaIds()
{
// Spot-check Stamina (vital=4) and Mana (vital=6) — same byte layout.
var stam = PrivateUpdateVital.TryParseFull(
BuildFull(seq: 7, vital: 4, ranks: 50, start: 100, xp: 0, current: 75));
Assert.NotNull(stam);
Assert.Equal(4u, stam!.Value.VitalId);
Assert.Equal(75u, stam.Value.Current);
var mana = PrivateUpdateVital.TryParseFull(
BuildFull(seq: 8, vital: 6, ranks: 30, start: 80, xp: 0, current: 110));
Assert.NotNull(mana);
Assert.Equal(6u, mana!.Value.VitalId);
Assert.Equal(110u, mana.Value.Current);
}
}