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>
This commit is contained in:
Erik 2026-04-25 16:42:24 +02:00
parent d42bf5735d
commit 7da2a027d4
14 changed files with 1660 additions and 272 deletions

View file

@ -1,79 +1,265 @@
using System.Collections.Generic;
namespace AcDream.Core.Player;
/// <summary>
/// Cache of the local player's stamina + mana absolute values, populated
/// from the <c>CreatureProfile</c> blob inside <c>PlayerDescription
/// (0x0013)</c>. Health stays in <see cref="AcDream.Core.Combat.CombatState"/>
/// because it has its own delta opcode (<c>UpdateHealth 0x01C0</c>); stam
/// and mana don't, so without this cache <c>VitalsVM</c> can't surface
/// percent-of-max bars even though the parser already decodes the
/// absolute values.
/// Local player's attribute + vital snapshot, populated from the
/// <c>PlayerDescription (0x0013)</c> attribute block at login and
/// kept fresh by <c>PrivateUpdateVital (0x02E7)</c> +
/// <c>PrivateUpdateVitalCurrent (0x02E9)</c> deltas.
///
/// <para>
/// Filed by issue <c>#5</c> in <c>docs/ISSUES.md</c>. Once future delta
/// opcodes for stam/mana arrive (or are recognised in the existing event
/// stream) they update through the same <see cref="OnPlayerDescription"/>
/// surface — keep the cache the single point of truth so VM consumers
/// don't have to subscribe to multiple sources.
/// Wire format references:
/// </para>
/// <list type="bullet">
/// <item>holtburger
/// <c>crates/holtburger-protocol/src/messages/player/events.rs</c>
/// for PlayerDescription body layout (vitals at attribute-block
/// ids 7/8/9 with <c>ranks/start/xp/current</c>).</item>
/// <item>holtburger
/// <c>crates/holtburger-world/src/player/stats_calc.rs</c>
/// <c>calculate_vital_current</c> for the max formula —
/// <c>(ranks + start + attribute_contribution) × multiplier + additive</c>.
/// We implement the unenchanted base case with retail-faithful
/// hardcoded attribute coefficients (no portal.dat
/// <c>SecondaryAttributeTable</c> port yet — see remarks).</item>
/// </list>
///
/// <para>
/// <b>Max derivation</b> (retail base case, no enchantments):
/// </para>
/// <code>
/// MaxHealth = vital.ranks + vital.start + Endurance.current / 2
/// MaxStamina = vital.ranks + vital.start + Endurance.current
/// MaxMana = vital.ranks + vital.start + Self.current
/// </code>
///
/// <para>
/// Primary attribute <c>current = ranks + start</c> per holtburger
/// <c>mutations.rs</c>. Attribute coefficients come from retail's
/// <c>SecondaryAttributeTable</c> (portal.dat 0x0E0..0x0E2). The
/// values are hardcoded here as well-known constants; a future port
/// of the dat object can replace the hardcodes if those coefficients
/// ever turn out to vary.
/// </para>
///
/// <para>
/// <b>Enchantment buffs</b> (multiplicative + additive) and the
/// 5-min-vital clamp are <b>not yet applied</b> — adding those
/// requires the <see cref="AcDream.Core.Spells.Spellbook"/>'s active
/// enchantment list. The unenchanted max is correct for clean
/// characters; buffed players will read percent slightly higher than
/// retail until enchantment integration lands.
/// </para>
/// </summary>
public sealed class LocalPlayerState
{
/// <summary>Current stamina (absolute), or <c>null</c> if never received.</summary>
public uint? CurrentStamina { get; private set; }
/// <summary>Max stamina (absolute), or <c>null</c> if never received.</summary>
public uint? MaxStamina { get; private set; }
/// <summary>Current mana (absolute), or <c>null</c> if never received.</summary>
public uint? CurrentMana { get; private set; }
/// <summary>Max mana (absolute), or <c>null</c> if never received.</summary>
public uint? MaxMana { get; private set; }
/// <summary>Fires after any field update via <see cref="OnPlayerDescription"/>.</summary>
public event Action<LocalPlayerState>? Changed;
/// <summary>
/// Stamina as a 0..1 fraction, or <c>null</c> when either current or max
/// is missing or max is zero. Clamps to 1.0 if current &gt; max (which
/// the server can briefly report during buff transitions).
/// </summary>
public float? StaminaPercent => Percent(CurrentStamina, MaxStamina);
/// <summary>
/// Mana as a 0..1 fraction, or <c>null</c> when either current or max
/// is missing or max is zero. Same clamp rules as
/// <see cref="StaminaPercent"/>.
/// </summary>
public float? ManaPercent => Percent(CurrentMana, MaxMana);
/// <summary>
/// Apply a slice of the latest <c>CreatureProfile</c>. Each field is
/// nullable: <c>null</c> means "no information" — preserve any previously
/// known good value rather than wipe it. Fires <see cref="Changed"/> once
/// after the update.
/// </summary>
public void OnPlayerDescription(
uint? currentStamina,
uint? maxStamina,
uint? currentMana,
uint? maxMana)
/// <summary>Three vital types — mirrors holtburger <c>VitalType</c>.</summary>
public enum VitalKind
{
if (currentStamina.HasValue) CurrentStamina = currentStamina.Value;
if (maxStamina.HasValue) MaxStamina = maxStamina.Value;
if (currentMana.HasValue) CurrentMana = currentMana.Value;
if (maxMana.HasValue) MaxMana = maxMana.Value;
Changed?.Invoke(this);
Health,
Stamina,
Mana,
}
private static float? Percent(uint? current, uint? max)
/// <summary>Six primary attributes — ACE <c>PropertyAttribute</c>.</summary>
public enum AttributeKind
{
if (current is not uint c) return null;
Strength,
Endurance,
Quickness,
Coordination,
Focus,
Self,
}
/// <summary>Primary-attribute snapshot. <c>Current = Ranks + Start</c>
/// per retail; we don't track an independent "current attribute"
/// because PlayerDescription doesn't expose one.</summary>
public readonly record struct AttributeSnapshot(uint Ranks, uint Start, uint Xp)
{
public uint Current => Ranks + Start;
}
/// <summary>Per-vital snapshot. Max comes from
/// <see cref="LocalPlayerState.GetMaxApprox"/> because it depends
/// on primary-attribute state held elsewhere on the cache.</summary>
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
private VitalSnapshot? _health;
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
/// <summary>Fires after any vital field changes.</summary>
public event System.Action<VitalKind>? Changed;
/// <summary>Fires after any primary-attribute field changes (rare —
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
public event System.Action<AttributeKind>? AttributeChanged;
/// <summary>
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
/// <list type="bullet">
/// <item><c>1..=6</c> — wire-opcode <c>Vital</c> enum
/// (MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6).</item>
/// <item><c>7..=9</c> — PlayerDescription attribute-block ids
/// (Health=7, Stamina=8, Mana=9).</item>
/// </list>
/// </summary>
public static VitalKind? VitalIdToKind(uint vitalId) => vitalId switch
{
1u or 2u or 7u => VitalKind.Health,
3u or 4u or 8u => VitalKind.Stamina,
5u or 6u or 9u => VitalKind.Mana,
_ => null,
};
/// <summary>
/// Map a primary-attribute id (1..=6) to <see cref="AttributeKind"/>.
/// Returns <c>null</c> for ids outside that range — vital ids 7-9
/// don't map.
/// </summary>
public static AttributeKind? AttributeIdToKind(uint atType) => atType switch
{
1u => AttributeKind.Strength,
2u => AttributeKind.Endurance,
3u => AttributeKind.Quickness,
4u => AttributeKind.Coordination,
5u => AttributeKind.Focus,
6u => AttributeKind.Self,
_ => null,
};
/// <summary>Snapshot for a vital, or <c>null</c> if never received.</summary>
public VitalSnapshot? Get(VitalKind kind) => kind switch
{
VitalKind.Health => _health,
VitalKind.Stamina => _stamina,
VitalKind.Mana => _mana,
_ => null,
};
/// <summary>Snapshot for a primary attribute, or <c>null</c> if never received.</summary>
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>
/// Compute the unenchanted max for a vital, using the retail formula:
/// <c>vital.(ranks+start) + attribute_contribution</c>. Returns
/// <c>null</c> if the vital snapshot doesn't exist yet.
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
var v = Get(kind);
if (v is null) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
return baseMax + contrib;
}
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
public float? StaminaPercent => Percent(VitalKind.Stamina);
/// <summary>Mana percent (0..1) or null when not yet received.</summary>
public float? ManaPercent => Percent(VitalKind.Mana);
/// <summary>Health percent (0..1) or null when not yet received.</summary>
public float? HealthPercent => Percent(VitalKind.Health);
private float? Percent(VitalKind kind)
{
var v = Get(kind);
if (v is null) return null;
uint? max = GetMaxApprox(kind);
if (max is not uint m || m == 0) return null;
float r = (float)c / m;
float r = (float)v.Value.Current / m;
if (r < 0f) r = 0f;
else if (r > 1f) r = 1f;
return r;
}
/// <summary>
/// Apply a full vital update — replaces ranks / start / xp / current
/// for the matching <see cref="VitalKind"/>. Accepts both wire-opcode
/// ids (1..=6) and PlayerDescription attribute-block ids (7..=9).
/// </summary>
public void OnVitalUpdate(uint vitalId, uint ranks, uint start, uint xp, uint current)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
var snap = new VitalSnapshot(ranks, start, xp, current);
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
}
/// <summary>
/// Apply a current-only delta. Silently ignored if no full update
/// has been received for this vital yet (matches holtburger's
/// <c>get_mut(&amp;kind)</c> miss-as-noop semantics).
/// </summary>
public void OnVitalCurrent(uint vitalId, uint current)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return;
var snap = prev with { Current = current };
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
}
/// <summary>
/// Apply a primary-attribute update from PlayerDescription's
/// attribute block (ids 1..=6). Vital ids (7..=9) here are silently
/// dropped — feed them through <see cref="OnVitalUpdate"/> instead.
/// </summary>
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
AttributeChanged?.Invoke(kind);
}
// ── Retail attribute contribution ──────────────────────────────────────
//
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +
// SecondaryAttributeTable (portal.dat 0x0E0..0x0E2). Coefficients are
// hardwired in retail and re-confirmed by holtburger's
// calculate_vital_attribute_contribution.
//
// MaxHealth formula = Endurance × 0.5
// MaxStamina formula = Endurance × 1.0
// MaxMana formula = Self × 1.0
//
// Unknown attribute → contribution 0 → max underestimated. Once the
// SecondaryAttributeTable port lands these can shift to dat-driven
// coefficients, but the values themselves don't change between dat
// versions in retail.
private uint AttributeContribution(VitalKind kind)
{
switch (kind)
{
case VitalKind.Health:
return GetAttrCurrent(AttributeKind.Endurance) / 2u;
case VitalKind.Stamina:
return GetAttrCurrent(AttributeKind.Endurance);
case VitalKind.Mana:
return GetAttrCurrent(AttributeKind.Self);
default:
return 0u;
}
}
private uint GetAttrCurrent(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a.Current : 0u;
}