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

4
.gitignore vendored
View file

@ -24,6 +24,10 @@ references/
.claude/ .claude/
launch.log launch.log
launch-*.log launch-*.log
launch.utf8.log
# ImGui auto-saved window/docking state (per-user, not source) # ImGui auto-saved window/docking state (per-user, not source)
imgui.ini imgui.ini
# User-only download cache (per-developer, not source)
refs/

View file

@ -114,6 +114,48 @@ Copy this block when adding a new issue:
--- ---
## #6 — Vital max ignores enchantment buffs + vitae
**Status:** OPEN
**Severity:** LOW (3-5% accuracy gap on a HUD bar)
**Filed:** 2026-04-25
**Component:** ui / player-state
**Description:** `LocalPlayerState.GetMaxApprox` computes the unenchanted base max for HP/Stam/Mana — `vital.(ranks+start) + attribute_contribution` with retail's hardcoded coefficients (Endurance/2, Endurance, Self). Live test shows bars at ~95% when buffs are presumably active (server character is `+Acdream`, GM-marker char with likely buff stack). Holtburger's `calculate_vital_current` adds `× multiplier + additive` from the active enchantment list — that's the missing 5%.
**Root cause / status:** Need to fold `Spellbook.ActiveEnchantments` into the max calc. Holtburger's `magic.rs` aggregates by `EnchantmentTypeFlags::SECOND_ATT` masked with the vital id. The same data already arrives via `MagicUpdateEnchantment` events that we wire into `Spellbook`.
**Files:**
- `src/AcDream.Core/Player/LocalPlayerState.cs``GetMaxApprox` returns the base; extend to also call `Spellbook` for vital-typed enchantment aggregation.
- `src/AcDream.Core/Spells/Spellbook.cs` — needs an aggregator helper similar to holtburger's `get_vital_multiplier` / `get_vital_additive`.
**Research:** holtburger `crates/holtburger-world/src/player/stats_calc.rs:91-111`, `magic.rs` get_enchantment_multiplier / additive.
**Acceptance:** A `+Acdream` login shows Stam/Mana percent within 1% of retail's reading once any active buff multipliers are applied. (HP already at 100% indicates the unbuffed Health formula is already correct on its own.)
---
## #7 — PlayerDescription parser stops after spells (options/inventory/equipped not extracted)
**Status:** OPEN
**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest)
**Filed:** 2026-04-25
**Component:** net / player-state
**Description:** Current `PlayerDescriptionParser` walks through `Attribute / Skill / Spell` vector-flag blocks per holtburger but stops before the trailing options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped sections. Future panels (hotbar, inventory, character options) need that data; the parser will need extension. Holtburger's events.rs:462-625 has the full layout; the messy parts are `gameplay_options` (variable-length opaque blob requiring heuristic skip) and `desired_comps`.
**Root cause / status:** Just a scope decision — port-the-easy-bits-first approach. Reference shape lives in holtburger's full `unpack`; the only complex piece is `find_inventory_start_after_gameplay_options` (heuristic alignment search).
**Files:**
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add coverage with synthetic payloads of the trailing sections.
**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`).
**Acceptance:** All sections of a real-world PlayerDescription parse to completion — verified via a packet capture or by feeding synthetic test fixtures covering every flag combination.
---
## #4 — Sky horizon-glow disabled (fog-mix skipped on sky meshes) ## #4 — Sky horizon-glow disabled (fog-mix skipped on sky meshes)
**Status:** OPEN **Status:** OPEN
@ -142,8 +184,8 @@ Copy this block when adding a new issue:
## #5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null ## #5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null
**Closed:** 2026-04-25 **Closed:** 2026-04-25
**Commit:** `feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription` **Commit:** `feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block`
**Resolution:** Added `AcDream.Core.Player.LocalPlayerState` (caches `CurrentStamina` / `MaxStamina` / `CurrentMana` / `MaxMana` from `PlayerDescription (0x0013)`'s `CreatureProfile`). Wired into `GameEventWiring.WireAll` as a new optional 6th parameter so back-compat preserved. `VitalsVM` constructor now takes an optional `LocalPlayerState`; when wired, `StaminaPercent` / `ManaPercent` read through to the cache (no VM-side caching) and `VitalsPanel` automatically draws the two extra bars. Edge cases covered: zero `Max*` returns `null` (no /0); current > max clamps to 1.0; partial profiles preserve previous good values rather than wiping them. 16 new tests (11 `LocalPlayerStateTests` + 3 `VitalsVMTests` + 2 `GameEventWiringTests`). **Resolution:** First attempt (commit `d42bf57`) used `AppraiseInfoParser` for `PlayerDescription (0x0013)` — wrong wire format. ACE source confirmed via `GameEventPlayerDescription.WriteEventBody`: PlayerDescription is hand-written (DescriptionPropertyFlag-driven property hashtables, vector flags, attribute block, skills, spells, options/inventory tail) — distinct from `IdentifyObjectResponse (0x00C9)`'s `AppraiseInfo.Write`. Pivoted to a real port: new `PlayerDescriptionParser.cs` that walks property hashtables (Int32/Int64/Bool/Double/String/Did/Iid + Position) gated on the property flags, then reads vector flags + has_health + the attribute block where vitals 7/8/9 carry `ranks/start/xp/current`. Also redesigned `LocalPlayerState` to track per-vital snapshots (replacing the sentinel-API of attempt 1) plus per-attribute snapshots, with `GetMaxApprox` applying the retail formula `vital.(ranks+start) + attribute_contribution` (Endurance/2 for Health, Endurance for Stamina, Self for Mana). Live verified: `+Acdream` shows three bars; ~95% reading on Stam/Mana traced to active buff multipliers (filed as #6). Wire-port also added `PrivateUpdateVital (0x02E7)` + `PrivateUpdateVitalCurrent (0x02E9)` for delta updates per holtburger `UpdateVital`. ~700 LOC C#, 30+ new tests.
<!-- <!--
Example: Example:

View file

@ -1184,6 +1184,13 @@ public sealed class GameWindow : IDisposable
senderGuid: speech.SenderGuid, senderGuid: speech.SenderGuid,
isRanged: speech.IsRanged); isRanged: speech.IsRanged);
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
_liveSession.VitalUpdated += v =>
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
Chat.OnSystemMessage($"connecting to {host}:{portStr} as {user}", chatType: 1); Chat.OnSystemMessage($"connecting to {host}:{portStr} as {user}", chatType: 1);
_liveSession.Connect(user, pass); _liveSession.Connect(user, pass);
Chat.OnSystemMessage("connected — character list received", chatType: 1); Chat.OnSystemMessage("connected — character list received", chatType: 1);

View file

@ -160,37 +160,75 @@ public static class GameEventWiring
// Merge parsed properties into the item if we know about it. // Merge parsed properties into the item if we know about it.
if (items.GetItem(p.Value.Guid) is not null) if (items.GetItem(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties); items.UpdateProperties(p.Value.Guid, p.Value.Properties);
// Spell book from appraise: for ITEMS (caster / scrolls) this // Spellbook from appraise: for caster items / scrolls this is
// lists cast-on-use effects; for players (PlayerDescription) // the cast-on-use list. The local player's full learned
// it's the whole learned spellbook. Both mutate the spellbook // spellbook arrives via PlayerDescription (0x0013), which uses
// by adding any not-yet-known ids. // a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook) foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid); spellbook.OnSpellLearned(sid);
}); });
// ── Player ──────────────────────────────────────────────── // ── Player ────────────────────────────────────────────────
// PlayerDescription (0x0013) carries the same AppraiseInfo body as // PlayerDescription (0x0013) — full local-player snapshot at
// IdentifyObjectResponse (0x00C9), but it's targeted at the local // login. Distinct wire format from IdentifyObjectResponse
// player. Issue #5: feed CreatureProfile.{Stamina, Mana, *Max} // (0x00C9): hand-written body with property hashtables,
// into LocalPlayerState so the Vitals HUD can render those bars. // vector-flag-gated blocks, attribute block (where vitals 7/8/9
// Spellbook + properties get the same downstream treatment as // carry their absolute current values), skills, spells, and a
// IdentifyObjectResponse so the player's full learned spellbook // long trailer of options + inventory. See
// also lands here. // PlayerDescriptionParser for the full layout reference (mirrors
// holtburger events.rs:220-625).
//
// Two outputs from each parsed PlayerDescription:
// 1. LocalPlayerState absorbs vital ids 7/8/9 (Health/Stam/Mana).
// This is the ONLY way these arrive at login; PrivateUpdateVital
// delta opcodes only fire on rank-up / Enlightenment / admin
// changes — not initial sync.
// 2. Spellbook absorbs the learned spell list — for the local
// player this is the authoritative source (the per-item
// SpellBook flag in IdentifyObjectResponse is for caster
// items / scrolls only).
bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
dispatcher.Register(GameEventType.PlayerDescription, e => dispatcher.Register(GameEventType.PlayerDescription, e =>
{ {
var p = AppraiseInfoParser.TryParse(e.Payload.Span); var p = PlayerDescriptionParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return; if (dumpPd)
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
if (localPlayer is not null && p.Value.CreatureProfile is { } profile) if (localPlayer is not null)
{ {
localPlayer.OnPlayerDescription( foreach (var attr in p.Value.Attributes)
currentStamina: profile.Stamina, {
maxStamina: profile.StaminaMax, if (attr.Current is uint cur)
currentMana: profile.Mana, {
maxMana: profile.ManaMax); // Vital entry (id 7/8/9) — has absolute current.
if (dumpPd)
Console.WriteLine($"vitals: PD-vital id={attr.AtType} ranks={attr.Ranks} start={attr.Start} cur={cur}");
localPlayer.OnVitalUpdate(
vitalId: attr.AtType,
ranks: attr.Ranks,
start: attr.Start,
xp: attr.Xp,
current: cur);
}
else
{
// Primary attribute (id 1..6) — Endurance+Self feed
// the vital max formula (Endurance/2 for Health,
// Endurance for Stamina, Self for Mana).
if (dumpPd)
Console.WriteLine($"vitals: PD-attr id={attr.AtType} ranks={attr.Ranks} start={attr.Start}");
localPlayer.OnAttributeUpdate(
atType: attr.AtType,
ranks: attr.Ranks,
start: attr.Start,
xp: attr.Xp);
}
}
} }
foreach (uint sid in p.Value.SpellBook) foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid); spellbook.OnSpellLearned(sid);
}); });
} }

View file

@ -7,12 +7,22 @@ namespace AcDream.Core.Net.Messages;
/// <summary> /// <summary>
/// Parser for the full <c>AppraiseInfo</c> blob carried by /// Parser for the full <c>AppraiseInfo</c> blob carried by
/// <c>GameEventType.IdentifyObjectResponse</c> (0x00C9) and also as /// <c>GameEventType.IdentifyObjectResponse</c> (0x00C9). Format source:
/// part of <c>GameEventType.PlayerDescription</c> (0x0013). Format /// ACE <c>AppraiseInfo.Write</c> (Structure/AppraiseInfo.cs:735)
/// source: ACE <c>AppraiseInfo.Write</c> (Structure/AppraiseInfo.cs:735)
/// + <c>PackableHashTable.WriteHeader</c>. /// + <c>PackableHashTable.WriteHeader</c>.
/// ///
/// <para> /// <para>
/// <b>Not</b> usable for <c>PlayerDescription (0x0013)</c> — that opcode
/// has its own wire format (DescriptionPropertyFlag-driven property
/// hashtables, DescriptionVectorFlag block, hand-written attribute /
/// skill / spell layout). See ACE
/// <c>GameEventPlayerDescription.WriteEventBody</c>. Player vitals come
/// in instead via <c>PrivateUpdateVital (0x02E7)</c> +
/// <c>PrivateUpdateVitalCurrent (0x02E9)</c>, which are top-level
/// GameMessage opcodes (not 0xF7B0 sub-opcodes).
/// </para>
///
/// <para>
/// Wire shape: /// Wire shape:
/// <code> /// <code>
/// u32 flags // IdentifyResponseFlags bitfield /// u32 flags // IdentifyResponseFlags bitfield

View file

@ -0,0 +1,464 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser for <c>GameEventType.PlayerDescription (0x0013)</c> — the
/// load-bearing post-EnterWorld message that hands the client the local
/// player's full state (properties, attributes, vitals, skills, spells,
/// enchantments, options, hotbars, inventory, equipped objects).
///
/// <para>
/// <b>Wire format reference:</b> holtburger
/// <c>crates/holtburger-protocol/src/messages/player/events.rs</c>
/// (<c>PlayerDescriptionEventData::unpack</c>, lines 220-625). ACE
/// producer: <c>GameEventPlayerDescription.WriteEventBody</c>.
/// </para>
///
/// <para>
/// <b>NOT</b> the same wire format as
/// <c>IdentifyObjectResponse (0x00C9)</c> — that uses
/// <c>AppraiseInfo.Write</c>'s flag-driven format
/// (<see cref="AppraiseInfoParser"/>). PlayerDescription has its own
/// hand-written body with property hashtables (<c>DescriptionPropertyFlag</c>),
/// vector-flag-gated blocks (<c>DescriptionVectorFlag</c>), and a long
/// trailer of options + hotbars + inventory. Don't conflate the two.
/// </para>
///
/// <para>
/// <b>Body layout</b> (all little-endian):
/// </para>
/// <code>
/// // Header (8 B):
/// u32 propertyFlags // DescriptionPropertyFlag — gates the property hashtables
/// u32 wee_type // ACE WeenieType enum
///
/// // Property hashtables — each gated on a propertyFlags bit. Header is
/// // u16 count, u16 buckets then `count` × (u32 key, value).
/// if (propertyFlags &amp; PROPERTY_INT32): table&lt;i32&gt;
/// if (propertyFlags &amp; PROPERTY_INT64): table&lt;i64&gt;
/// if (propertyFlags &amp; PROPERTY_BOOL): table&lt;u32&gt; (treated as bool)
/// if (propertyFlags &amp; PROPERTY_DOUBLE): table&lt;f64&gt;
/// if (propertyFlags &amp; PROPERTY_STRING): table&lt;string16L&gt;
/// if (propertyFlags &amp; PROPERTY_DID): table&lt;u32&gt;
/// if (propertyFlags &amp; PROPERTY_IID): table&lt;u32&gt;
/// if (propertyFlags &amp; POSITION): table&lt;WorldPosition (32 B)&gt;
///
/// // Vector flags + has_health (8 B):
/// u32 vectorFlags // DescriptionVectorFlag — Attribute=0x01, Skill=0x02, Spell=0x100, Enchantment=0x200
/// u32 has_health // 0 or 1
///
/// // Attribute block (gated on Attribute vector flag):
/// u32 attributeFlags // AttributeCache bitmask — bit (i-1) ⇒ entry i present
/// for i = 1..=6 (primary attrs Strength..Self), if bit set:
/// u32 ranks, u32 start, u32 xp // 12 B per entry
/// for i = 7..=9 (vitals Health/Stamina/Mana), if bit set:
/// u32 ranks, u32 start, u32 xp, u32 current // 16 B per entry
///
/// // Skills (gated on Skill vector flag): packed table&lt;CreatureSkill (32 B)&gt;
/// // Spells (gated on Spell vector flag): packed table&lt;u32 spellId, f32 power&gt;
/// // Enchantments (gated on Enchantment vector flag): EnchantmentMask + per-section count + N×Enchantment(60 or 64 B)
/// // ... then options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped
/// </code>
///
/// <para>
/// <b>Scope of this port:</b> walks all sections through enchantments;
/// the trailing options / inventory / equipped sections are partial
/// because they involve variable-length opaque blobs that holtburger
/// uses heuristics to skip. The early termination still leaves the
/// parser useful for its primary purpose — populating
/// <see cref="AcDream.Core.Player.LocalPlayerState"/> from the
/// attribute block — and a follow-up issue can extend it once the
/// remaining sections become consumed by panels.
/// </para>
/// </summary>
public static class PlayerDescriptionParser
{
[Flags]
public enum DescriptionPropertyFlag : uint
{
None = 0x0000,
PropertyInt32 = 0x0001,
PropertyBool = 0x0002,
PropertyDouble = 0x0004,
PropertyDid = 0x0008,
PropertyString = 0x0010,
Position = 0x0020,
PropertyIid = 0x0040,
PropertyInt64 = 0x0080,
}
[Flags]
public enum DescriptionVectorFlag : uint
{
None = 0x0000,
Attribute = 0x0001,
Skill = 0x0002,
Spell = 0x0100,
Enchantment = 0x0200,
}
/// <summary>One entry from the attribute block. Entries 1-6 are
/// primary attributes; 7-9 are vitals (Current is non-null only for
/// vitals).</summary>
public readonly record struct AttributeEntry(
uint AtType,
uint Ranks,
uint Start,
uint Xp,
uint? Current);
/// <summary>One skill entry — see ACE
/// <c>CreatureSkill</c> + holtburger
/// <c>messages/player/skills.rs</c>.</summary>
public readonly record struct SkillEntry(
uint SkillId,
uint Ranks,
uint Status,
uint Xp,
uint Init,
uint Resistance,
double LastUsed);
public readonly record struct WorldPosition(
uint LandblockId,
float X, float Y, float Z,
float Qw, float Qx, float Qy, float Qz);
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
DescriptionVectorFlag VectorFlags,
bool HasHealth,
PropertyBundle Properties,
IReadOnlyDictionary<uint, WorldPosition> Positions,
IReadOnlyList<AttributeEntry> Attributes,
IReadOnlyList<SkillEntry> Skills,
IReadOnlyDictionary<uint, float> Spells);
/// <summary>
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
/// stripped by <see cref="GameEventEnvelope.TryParse"/> so the
/// payload starts at the property-flags placeholder u32 — i.e. the
/// first byte after the GameEvent header.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
int pos = 0;
try
{
DescriptionPropertyFlag propertyFlags = (DescriptionPropertyFlag)ReadU32(payload, ref pos);
uint weenieType = ReadU32(payload, ref pos);
var bundle = new PropertyBundle();
var positions = new Dictionary<uint, WorldPosition>();
var attributes = new List<AttributeEntry>();
var skills = new List<SkillEntry>();
var spells = new Dictionary<uint, float>();
// ── Property hashtables (each gated on a flag bit) ──────────────
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32))
ReadIntTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt64))
ReadInt64Table(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyBool))
ReadBoolTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyDouble))
ReadDoubleTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyString))
ReadStringTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyDid))
ReadDataIdTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyIid))
ReadInstanceIdTable(payload, ref pos, bundle);
if (propertyFlags.HasFlag(DescriptionPropertyFlag.Position))
ReadPositionTable(payload, ref pos, positions);
// ── Vector flags + has_health ───────────────────────────────────
if (payload.Length - pos < 8) return BuildPartial(weenieType, propertyFlags,
DescriptionVectorFlag.None, hasHealth: false, bundle, positions, attributes, skills, spells);
DescriptionVectorFlag vectorFlags = (DescriptionVectorFlag)ReadU32(payload, ref pos);
bool hasHealth = ReadU32(payload, ref pos) != 0;
// ── Attribute block (Health/Stam/Mana live at ids 7/8/9) ───────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Attribute))
ReadAttributeBlock(payload, ref pos, attributes);
// ── Skills ──────────────────────────────────────────────────────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Skill))
ReadSkillTable(payload, ref pos, skills);
// ── Spells (learned spellbook) ──────────────────────────────────
if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell))
ReadSpellTable(payload, ref pos, spells);
// Enchantments + options + shortcuts + hotbars + inventory +
// equipped follow. Holtburger's full unpacker handles them with
// ~250 lines of additional logic and some heuristic fallbacks
// for variable-length blobs (gameplay_options). Issue #5 only
// needs through the attribute block; we stop here cleanly and
// expose what we've parsed. A follow-up can extend.
return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells);
}
catch (FormatException)
{
// Truncation mid-walk — return null so caller knows parse failed.
return null;
}
}
private static Parsed BuildPartial(
uint weenieType, DescriptionPropertyFlag pFlags, DescriptionVectorFlag vFlags,
bool hasHealth, PropertyBundle bundle,
Dictionary<uint, WorldPosition> positions,
List<AttributeEntry> attributes, List<SkillEntry> skills,
Dictionary<uint, float> spells)
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells);
}
// ── Attribute block reader ──────────────────────────────────────────────
private static void ReadAttributeBlock(
ReadOnlySpan<byte> src, ref int pos, List<AttributeEntry> attributes)
{
// u32 attributeFlags bitmask — see ACE AttributeCache:
// bit 0 → Strength (id=1), ..., bit 5 → Self (id=6),
// bit 6 → Health (id=7), bit 7 → Stamina (id=8), bit 8 → Mana (id=9).
uint attrFlags = ReadU32(src, ref pos);
// Primary attributes (1..=6): 12-byte entries (ranks, start, xp).
for (uint i = 1; i <= 6; i++)
{
uint bit = 1u << (int)(i - 1);
if ((attrFlags & bit) == 0) continue;
uint ranks = ReadU32(src, ref pos);
uint start = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
attributes.Add(new AttributeEntry(i, ranks, start, xp, Current: null));
}
// Vitals (7..=9): 16-byte entries (ranks, start, xp, current).
for (uint i = 7; i <= 9; i++)
{
uint bit = 1u << (int)(i - 1);
if ((attrFlags & bit) == 0) continue;
uint ranks = ReadU32(src, ref pos);
uint start = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
uint current = ReadU32(src, ref pos);
attributes.Add(new AttributeEntry(i, ranks, start, xp, current));
}
}
// ── Property hashtable readers ──────────────────────────────────────────
private static (ushort count, ushort buckets) ReadHeader(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated table header");
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
ushort buckets = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos + 2));
pos += 4;
return (count, buckets);
}
private static void ReadIntTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
int val = (int)ReadU32(src, ref pos);
bundle.Ints[key] = val;
}
}
private static void ReadInt64Table(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
long val = ReadI64(src, ref pos);
bundle.Int64s[key] = val;
}
}
private static void ReadBoolTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.Bools[key] = val != 0;
}
}
private static void ReadDoubleTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
double val = ReadF64(src, ref pos);
bundle.Floats[key] = val;
}
}
private static void ReadStringTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
string val = ReadString16L(src, ref pos);
bundle.Strings[key] = val;
}
}
private static void ReadDataIdTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.DataIds[key] = val;
}
}
private static void ReadInstanceIdTable(ReadOnlySpan<byte> src, ref int pos, PropertyBundle bundle)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
uint val = ReadU32(src, ref pos);
bundle.InstanceIds[key] = val;
}
}
// ── Position table (one position keyed by PositionType u32) ─────────────
private static void ReadPositionTable(
ReadOnlySpan<byte> src, ref int pos, Dictionary<uint, WorldPosition> positions)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint key = ReadU32(src, ref pos);
positions[key] = ReadWorldPosition(src, ref pos);
}
}
private static WorldPosition ReadWorldPosition(ReadOnlySpan<byte> src, ref int pos)
{
// 32 bytes: u32 landblockId + 3 f32 coords + 4 f32 quaternion (w,x,y,z).
uint landblockId = ReadU32(src, ref pos);
float x = ReadF32(src, ref pos);
float y = ReadF32(src, ref pos);
float z = ReadF32(src, ref pos);
float qw = ReadF32(src, ref pos);
float qx = ReadF32(src, ref pos);
float qy = ReadF32(src, ref pos);
float qz = ReadF32(src, ref pos);
return new WorldPosition(landblockId, x, y, z, qw, qx, qy, qz);
}
// ── Skill table ─────────────────────────────────────────────────────────
private static void ReadSkillTable(ReadOnlySpan<byte> src, ref int pos, List<SkillEntry> skills)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
// 32-byte CreatureSkill — holtburger skills.rs:23-46.
uint sk_type = ReadU32(src, ref pos);
// ranks is u16 not u32; constant 1u then status u32.
if (src.Length - pos < 4) throw new FormatException("truncated skill ranks/const");
uint ranks = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
// u16 const_one — discarded.
BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2;
uint status = ReadU32(src, ref pos);
uint xp = ReadU32(src, ref pos);
uint init = ReadU32(src, ref pos);
uint resistance = ReadU32(src, ref pos);
double lastUsed = ReadF64(src, ref pos);
skills.Add(new SkillEntry(sk_type, ranks, status, xp, init, resistance, lastUsed));
}
}
// ── Spell table (learned spells) ────────────────────────────────────────
private static void ReadSpellTable(
ReadOnlySpan<byte> src, ref int pos, Dictionary<uint, float> spells)
{
var (count, _) = ReadHeader(src, ref pos);
for (int i = 0; i < count; i++)
{
uint spellId = ReadU32(src, ref pos);
float power = ReadF32(src, ref pos);
spells[spellId] = power;
}
}
// ── Primitive readers ───────────────────────────────────────────────────
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated u32");
uint v = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
return v;
}
private static long ReadI64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated i64");
long v = BinaryPrimitives.ReadInt64LittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static float ReadF32(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 4) throw new FormatException("truncated f32");
float v = BinaryPrimitives.ReadSingleLittleEndian(src.Slice(pos));
pos += 4;
return v;
}
private static double ReadF64(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 8) throw new FormatException("truncated f64");
double v = BinaryPrimitives.ReadDoubleLittleEndian(src.Slice(pos));
pos += 8;
return v;
}
private static string ReadString16L(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated string length");
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
if (src.Length - pos < len) throw new FormatException("truncated string body");
string v = Encoding.ASCII.GetString(src.Slice(pos, len));
pos += len;
// String16L records pad to 4-byte alignment per AppraiseInfoParser convention.
int record = 2 + len;
int pad = (4 - (record & 3)) & 3;
pos += pad;
return v;
}
}

View file

@ -0,0 +1,105 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound vital-update <c>GameMessage</c>s for the local player.
/// These do NOT ride the <c>0xF7B0</c> GameEvent envelope — they're
/// standalone GameMessages dispatched the same way as
/// <see cref="HearSpeech"/> / <c>CreateObject</c> / <c>UpdateMotion</c>.
///
/// <para>
/// Wire format reference: holtburger
/// <c>crates/holtburger-protocol/src/messages/player/types.rs</c>
/// <c>UpdateVital</c> + <c>UpdateVitalCurrent</c>. ACE producers:
/// <c>GameMessagePrivateUpdateVital</c> +
/// <c>GameMessagePrivateUpdateAttribute2ndLevel</c>. The "sequence"
/// field is a <b>single byte</b> per ACE
/// <c>ByteSequence.NextBytes</c>; not 4 bytes despite some ACE writer
/// signatures looking like <c>uint</c>.
/// </para>
///
/// <para>Wire layouts (private flavour, no object_guid):</para>
/// <code>
/// PrivateUpdateVital (0x02E7):
/// u32 opcode = 0x02E7
/// u8 sequence
/// u32 vital // ACE Vital enum (1=MaxHealth..6=Mana)
/// u32 ranks
/// u32 start // StartingValue
/// u32 xp // ExperienceSpent
/// u32 current
/// </code>
/// <code>
/// PrivateUpdateVitalCurrent (0x02E9) — current-only delta (regen / drain):
/// u32 opcode = 0x02E9
/// u8 sequence
/// u32 vital
/// u32 current
/// </code>
///
/// <para>
/// Vital ID semantics live in
/// <see cref="AcDream.Core.Player.LocalPlayerState.VitalIdToKind"/>.
/// </para>
/// </summary>
public static class PrivateUpdateVital
{
public const uint FullOpcode = 0x02E7u;
public const uint CurrentOpcode = 0x02E9u;
/// <summary>Parsed full-update message.</summary>
public readonly record struct ParsedFull(
byte Sequence,
uint VitalId,
uint Ranks,
uint Start,
uint Xp,
uint Current);
/// <summary>Parsed current-only delta.</summary>
public readonly record struct ParsedCurrent(
byte Sequence,
uint VitalId,
uint Current);
/// <summary>
/// Parse a raw <c>PrivateUpdateVital (0x02E7)</c> body. Returns
/// <c>null</c> if opcode mismatch or truncated.
/// </summary>
public static ParsedFull? TryParseFull(ReadOnlySpan<byte> body)
{
// 4 (opcode) + 1 (seq) + 5 * 4 (uints) = 25 bytes minimum.
if (body.Length < 25) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (opcode != FullOpcode) return null;
int pos = 4;
byte seq = body[pos]; pos += 1;
uint vital = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint ranks = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint start = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint current = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]);
return new ParsedFull(seq, vital, ranks, start, xp, current);
}
/// <summary>
/// Parse a raw <c>PrivateUpdateVitalCurrent (0x02E9)</c> body. Returns
/// <c>null</c> if opcode mismatch or truncated.
/// </summary>
public static ParsedCurrent? TryParseCurrent(ReadOnlySpan<byte> body)
{
// 4 (opcode) + 1 (seq) + 2 * 4 (uints) = 13 bytes minimum.
if (body.Length < 13) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (opcode != CurrentOpcode) return null;
int pos = 4;
byte seq = body[pos]; pos += 1;
uint vital = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint current = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]);
return new ParsedCurrent(seq, vital, current);
}
}

View file

@ -109,6 +109,23 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard; public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
/// — full per-vital snapshot (ranks / start / xp / current).
/// Subscribers typically feed
/// <see cref="AcDream.Core.Player.LocalPlayerState.OnVitalUpdate"/>.
/// Wire layout: see <see cref="PrivateUpdateVital"/>.
/// </summary>
public event Action<PrivateUpdateVital.ParsedFull>? VitalUpdated;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVitalCurrent (0x02E9)</c>
/// arrives — current-only delta (regen ticks, drains).
/// Subscribers typically feed
/// <see cref="AcDream.Core.Player.LocalPlayerState.OnVitalCurrent"/>.
/// </summary>
public event Action<PrivateUpdateVital.ParsedCurrent>? VitalCurrentUpdated;
/// <summary> /// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet — /// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
@ -209,6 +226,15 @@ public sealed class WorldSession : IDisposable
private readonly IPEndPoint _connectEndpoint; private readonly IPEndPoint _connectEndpoint;
private readonly FragmentAssembler _assembler = new(); private readonly FragmentAssembler _assembler = new();
// Issue #5 diagnostics (env-var-gated):
// ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode
// ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse
private static readonly bool DumpOpcodesEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
private static readonly bool DumpVitalsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
private IsaacRandom? _inboundIsaac; private IsaacRandom? _inboundIsaac;
private IsaacRandom? _outboundIsaac; private IsaacRandom? _outboundIsaac;
private ushort _sessionClientId; private ushort _sessionClientId;
@ -589,6 +615,27 @@ public sealed class WorldSession : IDisposable
if (parsed is not null) if (parsed is not null)
SpeechHeard?.Invoke(parsed.Value); SpeechHeard?.Invoke(parsed.Value);
} }
else if (op == PrivateUpdateVital.FullOpcode)
{
// Issue #5: full per-vital snapshot from the server. Wire
// format per holtburger UpdateVital<false> — see
// PrivateUpdateVital.TryParseFull.
var parsed = PrivateUpdateVital.TryParseFull(body);
if (DumpVitalsEnabled)
Console.WriteLine($"vitals: 0x02E7 PrivateUpdateVital body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} ranks={parsed.Value.Ranks} start={parsed.Value.Start} cur={parsed.Value.Current}")}");
if (parsed is not null)
VitalUpdated?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateVital.CurrentOpcode)
{
// Issue #5: current-only delta (regen ticks / drains).
// Wire format per holtburger UpdateVitalCurrent<false>.
var parsed = PrivateUpdateVital.TryParseCurrent(body);
if (DumpVitalsEnabled)
Console.WriteLine($"vitals: 0x02E9 PrivateUpdateVitalCurrent body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} cur={parsed.Value.Current}")}");
if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value);
}
else if (op == GameEventEnvelope.Opcode) else if (op == GameEventEnvelope.Opcode)
{ {
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the // Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
@ -645,6 +692,15 @@ public sealed class WorldSession : IDisposable
_teleportSequence = sequence; // track for outbound movement messages _teleportSequence = sequence; // track for outbound movement messages
TeleportStarted?.Invoke(sequence); TeleportStarted?.Invoke(sequence);
} }
else if (DumpOpcodesEnabled)
{
// ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per
// genuinely-unhandled opcode (deduped to first occurrence).
// MUST be the LAST else-if so it doesn't intercept handled
// opcodes when the env var is set.
if (_seenUnhandledOpcodes.Add(op))
Console.WriteLine($"opcodes: unhandled 0x{op:X4} (body.len={body.Length})");
}
} }
} }

View file

@ -1,79 +1,265 @@
using System.Collections.Generic;
namespace AcDream.Core.Player; namespace AcDream.Core.Player;
/// <summary> /// <summary>
/// Cache of the local player's stamina + mana absolute values, populated /// Local player's attribute + vital snapshot, populated from the
/// from the <c>CreatureProfile</c> blob inside <c>PlayerDescription /// <c>PlayerDescription (0x0013)</c> attribute block at login and
/// (0x0013)</c>. Health stays in <see cref="AcDream.Core.Combat.CombatState"/> /// kept fresh by <c>PrivateUpdateVital (0x02E7)</c> +
/// because it has its own delta opcode (<c>UpdateHealth 0x01C0</c>); stam /// <c>PrivateUpdateVitalCurrent (0x02E9)</c> deltas.
/// 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.
/// ///
/// <para> /// <para>
/// Filed by issue <c>#5</c> in <c>docs/ISSUES.md</c>. Once future delta /// Wire format references:
/// opcodes for stam/mana arrive (or are recognised in the existing event /// </para>
/// stream) they update through the same <see cref="OnPlayerDescription"/> /// <list type="bullet">
/// surface — keep the cache the single point of truth so VM consumers /// <item>holtburger
/// don't have to subscribe to multiple sources. /// <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> /// </para>
/// </summary> /// </summary>
public sealed class LocalPlayerState public sealed class LocalPlayerState
{ {
/// <summary>Current stamina (absolute), or <c>null</c> if never received.</summary> /// <summary>Three vital types — mirrors holtburger <c>VitalType</c>.</summary>
public uint? CurrentStamina { get; private set; } public enum VitalKind
/// <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)
{ {
if (currentStamina.HasValue) CurrentStamina = currentStamina.Value; Health,
if (maxStamina.HasValue) MaxStamina = maxStamina.Value; Stamina,
if (currentMana.HasValue) CurrentMana = currentMana.Value; Mana,
if (maxMana.HasValue) MaxMana = maxMana.Value;
Changed?.Invoke(this);
} }
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; 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; if (r < 0f) r = 0f;
else if (r > 1f) r = 1f; else if (r > 1f) r = 1f;
return r; 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;
} }

View file

@ -47,56 +47,6 @@ public sealed class GameEventWiringTests
return (dispatcher, items, combat, spellbook, chat); return (dispatcher, items, combat, spellbook, chat);
} }
private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog, LocalPlayerState) MakeAllWithLocal()
{
var dispatcher = new GameEventDispatcher();
var items = new ItemRepository();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
return (dispatcher, items, combat, spellbook, chat, local);
}
/// <summary>
/// Build a minimal AppraiseInfo body containing only a CreatureProfile
/// blob with ShowAttributes (flag 0x08) so stamina + mana fields are
/// present. Mirrors the wire shape that PlayerDescription (0x0013)
/// carries for the local player.
/// </summary>
private static byte[] MakePlayerDescriptionPayload(
uint guid, uint health, uint healthMax,
uint stamina, uint mana, uint staminaMax, uint manaMax)
{
// Outer header: u32 guid, u32 outerFlags, u32 success.
// Outer flags: just CreatureProfile (0x2000).
// Profile blob: u32 innerFlags, u32 health, u32 healthMax, then 10 u32s
// (str/end/quic/coord/focus/self/sta/mana/staMax/manaMax) when 0x08 set.
const uint outerFlags = 0x0000_2000u; // CreatureProfile
const uint innerFlags = 0x08u; // ShowAttributes
byte[] body = new byte[12 + 12 + 10 * 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), guid); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), outerFlags); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // success
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), innerFlags); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), health); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), healthMax); p += 4;
// Stub attributes — VM doesn't read these, parser still has to skip them.
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // str
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // end
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // quic
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // coord
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // focus
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // self
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), stamina); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), mana); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), staminaMax); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), manaMax); p += 4;
return body;
}
[Fact] [Fact]
public void WireAll_ChannelBroadcast_RoutesToChatLog() public void WireAll_ChannelBroadcast_RoutesToChatLog()
@ -182,6 +132,114 @@ public sealed class GameEventWiringTests
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind); Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
} }
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals()
{
// Issue #5 — the full pipeline: synthetic 0xF7B0 envelope wrapping
// a PlayerDescription body with Health/Stam/Mana entries, dispatched
// through WireAll, lands in LocalPlayerState with the right
// ranks/start/current values.
var dispatcher = new GameEventDispatcher();
var items = new ItemRepository();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
// Body: empty property tables, ATTRIBUTE vector flag, all 9 attrs
// present. Primary attrs:
// Endurance (id=2): ranks=50 + start=150 → current=200
// Self (id=6): ranks=50 + start=50 → current=100
// Vitals (ranks+start = 0 — typical retail values):
// Health (id=7) cur=90 → MaxApprox = 0 + 200/2 = 100 → percent 0.9
// Stamina (id=8) cur=140 → MaxApprox = 0 + 200 = 200 → percent 0.7
// Mana (id=9) cur=50 → MaxApprox = 0 + 100 = 100 → percent 0.5
byte[] body = new byte[140];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x52u); p += 4; // weenieType
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4; // attribute_flags = Full
// Primary attrs in order 1..6.
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 1 Strength
WritePrimaryAttr(body, ref p, ranks: 50, start: 150, xp: 0); // 2 Endurance — current=200
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 3 Quickness
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 4 Coordination
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 5 Focus
WritePrimaryAttr(body, ref p, ranks: 50, start: 50, xp: 0); // 6 Self — current=100
// Vitals 7/8/9.
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 90); // Health
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 140); // Stamina
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 50); // Mana
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, body));
dispatcher.Dispatch(env!.Value);
var health = local.Get(LocalPlayerState.VitalKind.Health);
var stam = local.Get(LocalPlayerState.VitalKind.Stamina);
var mana = local.Get(LocalPlayerState.VitalKind.Mana);
Assert.NotNull(health);
Assert.NotNull(stam);
Assert.NotNull(mana);
Assert.Equal(90u, health!.Value.Current);
Assert.Equal(140u, stam!.Value.Current);
Assert.Equal(50u, mana!.Value.Current);
// Primary attrs landed too — formula contributions feed the max.
Assert.Equal(200u, local.GetAttribute(LocalPlayerState.AttributeKind.Endurance)!.Value.Current);
Assert.Equal(100u, local.GetAttribute(LocalPlayerState.AttributeKind.Self)!.Value.Current);
Assert.Equal(0.9f, local.HealthPercent!.Value, precision: 3);
Assert.Equal(0.7f, local.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_FeedsSpellbook()
{
var dispatcher = new GameEventDispatcher();
var items = new ItemRepository();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
// Body: SPELL vector flag + a spell table with 2 entries.
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags
w.Write(0x52u); // weenieType
w.Write(0x100u); // vectorFlags = SPELL only
w.Write(0u); // has_health = false
w.Write((ushort)2); // spell count
w.Write((ushort)64); // buckets
w.Write(0x3E1u); w.Write(2.0f);
w.Write(0x3E2u); w.Write(2.0f);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.True(spellbook.Knows(0x3E1u));
Assert.True(spellbook.Knows(0x3E2u));
}
private static void WriteVitalBlock(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;
}
private static void WritePrimaryAttr(byte[] body, ref int p, uint ranks, uint start, uint xp)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
}
[Fact] [Fact]
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll() public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
{ {
@ -196,45 +254,4 @@ public sealed class GameEventWiringTests
Assert.Equal(0, book.ActiveCount); Assert.Equal(0, book.ActiveCount);
} }
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerState()
{
// Issue #5 — the PlayerDescription (0x0013) opcode shares the
// AppraiseInfo payload with IdentifyObjectResponse (0x00C9). Now
// also funnels CreatureProfile.{Stamina, Mana, StaminaMax, ManaMax}
// into LocalPlayerState so the Vitals HUD can render those bars.
var (d, _, _, _, _, local) = MakeAllWithLocal();
byte[] payload = MakePlayerDescriptionPayload(
guid: 0x5000_000Au,
health: 100, healthMax: 200,
stamina: 75, mana: 150, staminaMax: 100, manaMax: 200);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, payload));
d.Dispatch(env!.Value);
Assert.Equal(75u, local.CurrentStamina);
Assert.Equal(100u, local.MaxStamina);
Assert.Equal(150u, local.CurrentMana);
Assert.Equal(200u, local.MaxMana);
Assert.Equal(0.75f, local.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.75f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_NoOp_WhenLocalPlayerStateNotProvided()
{
// Back-compat: the original 5-arg overload still works; without a
// LocalPlayerState reference there's no place to push the parsed
// CreatureProfile, but the dispatch must not throw.
var (d, _, _, _, _) = MakeAll();
byte[] payload = MakePlayerDescriptionPayload(
guid: 0x5000_000Au,
health: 100, healthMax: 200,
stamina: 75, mana: 150, staminaMax: 100, manaMax: 200);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, payload));
d.Dispatch(env!.Value); // must not throw
}
} }

View file

@ -0,0 +1,235 @@
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]);
}
}

View file

@ -0,0 +1,121 @@
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);
}
}

View file

@ -3,12 +3,17 @@ using AcDream.Core.Player;
namespace AcDream.Core.Tests.Player; namespace AcDream.Core.Tests.Player;
/// <summary> /// <summary>
/// Tests for <see cref="LocalPlayerState"/> — the cache that retains /// Tests for <see cref="LocalPlayerState"/> — per-vital + per-attribute
/// stamina / mana absolute + max values from <c>PlayerDescription /// cache populated from <c>PlayerDescription</c>'s attribute block (ids
/// (0x0013)</c>'s embedded <c>CreatureProfile</c>. Health stays in /// 1..=6 primary attrs, 7..=9 vitals) and from
/// <see cref="AcDream.Core.Combat.CombatState"/>; this class only /// <c>PrivateUpdateVital(Current)</c> deltas (ids 1..=6 in their
/// covers the vitals that don't have a dedicated delta opcode in /// alternate role as wire-opcode <c>Vital</c> ids).
/// our currently-wired event set. ///
/// <para>
/// Max formula tested here: <c>vital.(ranks+start) + attribute_contribution</c>
/// with retail coefficients (Endurance/2 for Health, Endurance for Stamina,
/// Self for Mana). See class doc.
/// </para>
/// </summary> /// </summary>
public sealed class LocalPlayerStateTests public sealed class LocalPlayerStateTests
{ {
@ -17,128 +22,226 @@ public sealed class LocalPlayerStateTests
{ {
var s = new LocalPlayerState(); var s = new LocalPlayerState();
Assert.Null(s.CurrentStamina); Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
Assert.Null(s.MaxStamina); Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
Assert.Null(s.CurrentMana); Assert.Null(s.Get(LocalPlayerState.VitalKind.Mana));
Assert.Null(s.MaxMana); Assert.Null(s.HealthPercent);
Assert.Null(s.StaminaPercent); Assert.Null(s.StaminaPercent);
Assert.Null(s.ManaPercent); Assert.Null(s.ManaPercent);
} }
[Fact] [Theory]
public void OnPlayerDescription_PopulatesFields_FromValidValues() // Wire opcode IDs (PrivateUpdateVital + PrivateUpdateVitalCurrent),
// ACE Vital enum: Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6.
[InlineData(1u, LocalPlayerState.VitalKind.Health)]
[InlineData(2u, LocalPlayerState.VitalKind.Health)]
[InlineData(3u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(4u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(5u, LocalPlayerState.VitalKind.Mana)]
[InlineData(6u, LocalPlayerState.VitalKind.Mana)]
// PlayerDescription attribute-block IDs.
[InlineData(7u, LocalPlayerState.VitalKind.Health)]
[InlineData(8u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(9u, LocalPlayerState.VitalKind.Mana)]
public void VitalIdToKind_MapsBothIdSystems_ToSameKind(uint vitalId, LocalPlayerState.VitalKind expected)
{ {
var s = new LocalPlayerState(); Assert.Equal(expected, LocalPlayerState.VitalIdToKind(vitalId));
}
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100, [Theory]
currentMana: 150, maxMana: 200); [InlineData(0u)]
[InlineData(10u)]
[InlineData(99u)]
public void VitalIdToKind_ReturnsNull_ForUnknownId(uint vitalId)
{
Assert.Null(LocalPlayerState.VitalIdToKind(vitalId));
}
Assert.Equal(50u, s.CurrentStamina); [Theory]
Assert.Equal(100u, s.MaxStamina); [InlineData(1u, LocalPlayerState.AttributeKind.Strength)]
Assert.Equal(150u, s.CurrentMana); [InlineData(2u, LocalPlayerState.AttributeKind.Endurance)]
Assert.Equal(200u, s.MaxMana); [InlineData(3u, LocalPlayerState.AttributeKind.Quickness)]
[InlineData(4u, LocalPlayerState.AttributeKind.Coordination)]
[InlineData(5u, LocalPlayerState.AttributeKind.Focus)]
[InlineData(6u, LocalPlayerState.AttributeKind.Self)]
public void AttributeIdToKind_MapsPrimaryAttrIds(uint atType, LocalPlayerState.AttributeKind expected)
{
Assert.Equal(expected, LocalPlayerState.AttributeIdToKind(atType));
}
[Theory]
[InlineData(0u)]
[InlineData(7u)] // Vitals are not primary attrs in this lookup.
[InlineData(99u)]
public void AttributeIdToKind_ReturnsNull_ForNonPrimaryIds(uint atType)
{
Assert.Null(LocalPlayerState.AttributeIdToKind(atType));
} }
[Fact] [Fact]
public void StaminaPercent_IsCurrentOverMax_InZeroToOneRange() public void OnVitalUpdate_PopulatesSnapshot_FromFullMessage()
{ {
var s = new LocalPlayerState(); var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100, s.OnVitalUpdate(vitalId: 4u, ranks: 100u, start: 120u, xp: 50000u, current: 180u);
currentMana: null, maxMana: null);
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3); var stam = s.Get(LocalPlayerState.VitalKind.Stamina);
Assert.NotNull(stam);
Assert.Equal(100u, stam!.Value.Ranks);
Assert.Equal(120u, stam.Value.Start);
Assert.Equal(50000u, stam.Value.Xp);
Assert.Equal(180u, stam.Value.Current);
} }
[Fact] [Fact]
public void ManaPercent_IsCurrentOverMax_InZeroToOneRange() public void OnAttributeUpdate_PopulatesSnapshot()
{ {
var s = new LocalPlayerState(); var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: null, maxStamina: null, s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 100u, xp: 12345u);
currentMana: 75, maxMana: 100);
Assert.Equal(0.75f, s.ManaPercent!.Value, precision: 3); var endurance = s.GetAttribute(LocalPlayerState.AttributeKind.Endurance);
Assert.NotNull(endurance);
Assert.Equal(50u, endurance!.Value.Ranks);
Assert.Equal(100u, endurance.Value.Start);
Assert.Equal(12345u, endurance.Value.Xp);
// Current = ranks + start.
Assert.Equal(150u, endurance.Value.Current);
} }
[Fact] [Fact]
public void StaminaPercent_NullWhenMaxIsZero_AvoidsDivByZero() public void HealthPercent_UsesEnduranceContribution_DividedByTwo()
{ {
// Endurance.current = ranks(50) + start(150) = 200. Contribution = 100.
// Health vital: ranks=0 start=0 cur=80. MaxApprox = 0 + 100 = 100. Percent = 0.8.
var s = new LocalPlayerState(); var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 0, maxStamina: 0, s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance
currentMana: null, maxMana: null); s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 0u, xp: 0u, current: 80u); // Health (PD id)
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(0.8f, s.HealthPercent!.Value, precision: 3);
}
[Fact]
public void StaminaPercent_UsesEnduranceContribution_FullValue()
{
// Endurance.current = 200, Health/2=100. Stamina takes full Endurance=200.
// Stamina vital: ranks=0 start=0 cur=150. MaxApprox = 0 + 200 = 200. Percent = 0.75.
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(0.75f, s.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void ManaPercent_UsesSelfContribution()
{
// Self.current = 100. Mana vital: ranks=20 start=80 cur=100. MaxApprox = 100 + 100 = 200.
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 6u, ranks: 50u, start: 50u, xp: 0u); // Self
s.OnVitalUpdate(vitalId: 9u, ranks: 20u, start: 80u, xp: 0u, current: 100u); // Mana
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(0.5f, s.ManaPercent!.Value, precision: 3);
}
[Fact]
public void Percent_ZeroWhenAttributeAndVitalBothZero()
{
// Without any attribute or vital ranks, MaxApprox=0 → percent null
// (no /0). Vital received but no useful information.
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 0u);
Assert.Null(s.StaminaPercent); Assert.Null(s.StaminaPercent);
} }
[Fact] [Fact]
public void ManaPercent_NullWhenMaxIsZero_AvoidsDivByZero() public void Percent_ClampsToOne_WhenCurrentExceedsMax()
{ {
// Endurance contribution = 100/1 = 100. Stamina ranks+start = 0.
// MaxApprox = 100. Current = 150 → ratio 1.5 → clamps to 1.0.
var s = new LocalPlayerState(); var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: null, maxStamina: null, s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 50u, xp: 0u);
currentMana: 0, maxMana: 0); s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
Assert.Null(s.ManaPercent);
}
[Fact]
public void StaminaPercent_Null_WhenOnlyCurrentKnown()
{
var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 50, maxStamina: null,
currentMana: null, maxMana: null);
// Max never received → percent indeterminate.
Assert.Null(s.StaminaPercent);
}
[Fact]
public void StaminaPercent_Null_WhenOnlyMaxKnown()
{
var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: null, maxStamina: 100,
currentMana: null, maxMana: null);
// Current never received → percent indeterminate.
Assert.Null(s.StaminaPercent);
}
[Fact]
public void StaminaPercent_ClampsToOne_WhenCurrentExceedsMax()
{
var s = new LocalPlayerState();
// Server can momentarily report current > max during buff transitions.
s.OnPlayerDescription(currentStamina: 150, maxStamina: 100,
currentMana: null, maxMana: null);
Assert.Equal(1f, s.StaminaPercent!.Value); Assert.Equal(1f, s.StaminaPercent!.Value);
} }
[Fact] [Fact]
public void Changed_EventFires_WhenAnyVitalUpdates() public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
{ {
var s = new LocalPlayerState(); var s = new LocalPlayerState();
int fires = 0; s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
s.Changed += _ => fires++; s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 50000u, current: 180u);
s.OnVitalCurrent(vitalId: 8u, current: 90u);
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100, var stam = s.Get(LocalPlayerState.VitalKind.Stamina)!.Value;
currentMana: 75, maxMana: 200); Assert.Equal(0u, stam.Ranks);
Assert.Equal(0u, stam.Start);
Assert.Equal(1, fires); Assert.Equal(50000u, stam.Xp);
Assert.Equal(90u, stam.Current);
// Endurance.current = 200; MaxApprox = 200; percent = 90/200 = 0.45.
Assert.Equal(0.45f, s.StaminaPercent!.Value, precision: 3);
} }
[Fact] [Fact]
public void OnPlayerDescription_PreservesPreviousField_WhenIncomingValueIsNull() public void OnVitalCurrent_NoOp_WhenNoFullUpdateYet()
{ {
// CreatureProfile occasionally has nullable fields if the server
// sends a partial profile — the cache should preserve known-good
// values rather than wipe them. Stamina set first, then a Mana-only
// update should not clear Stamina.
var s = new LocalPlayerState(); var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100, s.OnVitalCurrent(vitalId: 4u, current: 90u);
currentMana: null, maxMana: null);
s.OnPlayerDescription(currentStamina: null, maxStamina: null,
currentMana: 75, maxMana: 200);
Assert.Equal(50u, s.CurrentStamina); Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(100u, s.MaxStamina); Assert.Null(s.StaminaPercent);
Assert.Equal(75u, s.CurrentMana); }
Assert.Equal(200u, s.MaxMana);
[Fact]
public void Changed_FiresOnFullVitalUpdate_WithCorrectKind()
{
var s = new LocalPlayerState();
var seen = new List<LocalPlayerState.VitalKind>();
s.Changed += k => seen.Add(k);
s.OnVitalUpdate(vitalId: 2u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
s.OnVitalUpdate(vitalId: 4u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
s.OnVitalUpdate(vitalId: 6u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
Assert.Equal(new[]
{
LocalPlayerState.VitalKind.Health,
LocalPlayerState.VitalKind.Stamina,
LocalPlayerState.VitalKind.Mana,
}, seen);
}
[Fact]
public void AttributeChanged_FiresOnPrimaryAttrUpdate()
{
var s = new LocalPlayerState();
var seen = new List<LocalPlayerState.AttributeKind>();
s.AttributeChanged += k => seen.Add(k);
s.OnAttributeUpdate(atType: 2u, ranks: 1u, start: 1u, xp: 0u); // Endurance
s.OnAttributeUpdate(atType: 6u, ranks: 1u, start: 1u, xp: 0u); // Self
Assert.Equal(new[]
{
LocalPlayerState.AttributeKind.Endurance,
LocalPlayerState.AttributeKind.Self,
}, seen);
}
[Fact]
public void OnAttributeUpdate_DoesNotAffectVitals_DirectlyButRefreshesPercent()
{
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 100u);
// Pre-attribute: percent null because MaxApprox = 0.
Assert.Null(s.StaminaPercent);
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance.current = 200
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
} }
} }

View file

@ -57,14 +57,14 @@ public sealed class VitalsVMTests
} }
[Fact] [Fact]
public void StaminaPercent_FromLocalPlayerState_AfterPlayerDescription() public void StaminaPercent_FromLocalPlayerState_AfterVitalUpdate()
{ {
// Issue #5 — once a LocalPlayerState is wired and the server has // Issue #5 — once a LocalPlayerState is wired and the server has
// sent a PlayerDescription with CreatureProfile, the Stam bar // sent a PrivateUpdateVital for Stamina (vital=4), the Stam bar
// surfaces the correct percent without any VM-level caching. // surfaces the correct percent without any VM-level caching.
// ranks=50, start=50, current=80 → MaxApprox=100 → percent=0.8.
var local = new LocalPlayerState(); var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: 80, maxStamina: 100, local.OnVitalUpdate(vitalId: 4u, ranks: 50u, start: 50u, xp: 0u, current: 80u);
currentMana: null, maxMana: null);
var vm = new VitalsVM(new CombatState(), local); var vm = new VitalsVM(new CombatState(), local);
@ -72,11 +72,11 @@ public sealed class VitalsVMTests
} }
[Fact] [Fact]
public void ManaPercent_FromLocalPlayerState_AfterPlayerDescription() public void ManaPercent_FromLocalPlayerState_AfterVitalUpdate()
{ {
// ranks=20, start=80, current=25 → MaxApprox=100 → percent=0.25.
var local = new LocalPlayerState(); var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: null, maxStamina: null, local.OnVitalUpdate(vitalId: 6u, ranks: 20u, start: 80u, xp: 0u, current: 25u);
currentMana: 25, maxMana: 100);
var vm = new VitalsVM(new CombatState(), local); var vm = new VitalsVM(new CombatState(), local);
@ -86,16 +86,16 @@ public sealed class VitalsVMTests
[Fact] [Fact]
public void Vm_ReadsThroughToLocalPlayerState_NoStaleCache() public void Vm_ReadsThroughToLocalPlayerState_NoStaleCache()
{ {
// Verify the VM doesn't snapshot — it should read live from the // Verify the VM doesn't snapshot — every property access reads
// LocalPlayerState every property access so a server delta picks // live so a server delta picks up next frame without any explicit
// up next frame without any explicit refresh call. // refresh call.
var local = new LocalPlayerState(); var local = new LocalPlayerState();
var vm = new VitalsVM(new CombatState(), local); var vm = new VitalsVM(new CombatState(), local);
Assert.Null(vm.StaminaPercent); // no data yet Assert.Null(vm.StaminaPercent); // no data yet
local.OnPlayerDescription(currentStamina: 50, maxStamina: 100, local.OnVitalUpdate(vitalId: 4u, ranks: 50u, start: 50u, xp: 0u, current: 50u);
currentMana: 50, maxMana: 100); local.OnVitalUpdate(vitalId: 6u, ranks: 50u, start: 50u, xp: 0u, current: 50u);
Assert.Equal(0.5f, vm.StaminaPercent!.Value, precision: 3); Assert.Equal(0.5f, vm.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.5f, vm.ManaPercent!.Value, precision: 3); Assert.Equal(0.5f, vm.ManaPercent!.Value, precision: 3);