feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow

Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
  u16 spell_id, layer, spell_category, has_spell_set_id
  u32 power_level
  f64 start_time, duration
  u32 caster_guid
  f32 degrade_modifier, degrade_limit
  f64 last_time_degraded
  u32 stat_mod_type, stat_mod_key
  f32 stat_mod_value
  [u32 spell_set_id]?
  + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)

EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.

Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:

  Bucket 1 (Multiplicative): multiplier *= val
  Bucket 2 (Additive):       additive += val
  Bucket 8 (Vitae):          multiplier *= val (applied last)
  Bucket 4 (Cooldown):       skipped (not a vital mod)

ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.

Tests: 7 new (834 -> 841):
  - PlayerDescriptionParserTests (2): enchantment block schema with
    multiplicative + additive buckets, Vitae singleton.
  - EnchantmentMathTests (5): multiplicative buffs aggregate, additive
    buffs sum, stat-key mismatch filters out, Vitae applied
    multiplicatively, family-stacking picks higher spell-id.

Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).

Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 18:01:22 +02:00
parent b153bbe5ad
commit bb5003a849
7 changed files with 447 additions and 58 deletions

View file

@ -114,48 +114,29 @@ Copy this block when adding a new issue:
---
## #12 — Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
**Status:** OPEN
**Severity:** LOW (architecture for buff aggregation is in place via #6 / Phase G; this is the wire-data extension that lights it up)
**Filed:** 2026-04-25
**Component:** net / spells
**Description:** `ParseMagicUpdateEnchantment` currently reads only the first 16 bytes of the 0x02C2 payload (`SpellId, LayerId, Duration, CasterGuid`) and ignores the trailing fields including `_smod` (the StatMod triad). `EnchantmentMath.GetMod` consequently returns `(1.0, 0.0)` for every stat key — the architecture is wired (see `Spellbook.GetVitalMod`, `LocalPlayerState.GetMaxApprox`) but no actual buff modifier flows through. Until this lands, the `+Acdream` Stam/Mana percent will continue to read ~95% (vs. retail's 100%) when buffs are active.
**Root cause / status:** Holtburger `crates/holtburger-protocol/src/messages/magic/types.rs` documents the full 60-64 byte Enchantment wire format: `u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id, u32 power_level, f64 start_time, f64 duration, u32 caster_guid, f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded, u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, [u32 spell_set_id]?`. Note the wire format starts with `u16` not `u32` for spell_id / layer / category — our existing parser may be reading garbage. Verify against actual ACE traffic via `ACDREAM_DUMP_VITALS=1`.
**Files:**
- `src/AcDream.Core.Net/Messages/GameEvents.cs` — extend `ParseMagicUpdateEnchantment` + `EnchantmentSummary`.
- `src/AcDream.Core/Spells/Spellbook.cs` — extend `ActiveEnchantmentRecord` with `StatModType`, `StatModKey`, `StatModValue`; extend `OnEnchantmentAdded` arity.
- `src/AcDream.Core/Spells/EnchantmentMath.cs` — uncomment the StatMod-aware aggregator inside `GetMod`.
**Research:** holtburger `messages/magic/types.rs:40` (`Enchantment::unpack`); `docs/research/named-retail/acclient.h` line 37406+ (`Enchantment` + `StatMod`).
**Acceptance:** With `+Acdream` logged in and standard self-buffs active, `LocalPlayer.StaminaPercent` rises from ~95% to a percent that matches retail's reading. New tests cover: parse round-trip with synthetic Enchantment payload; StatMod aggregation in `EnchantmentMath` (multiplicative + additive + family-stacking).
---
---
## #7 — PlayerDescription parser stops after spells (options/inventory/equipped not extracted)
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
**Status:** OPEN
**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest)
**Severity:** LOW (no current user-visible bug; future panels will need the data)
**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`.
**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
**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).
**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
**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.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`).
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
**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.
**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
---
---
@ -184,7 +165,27 @@ Copy this block when adding a new issue:
# Recently closed
## #6 — [DONE 2026-04-25 architecture; data follow-up #12] Vital max ignores enchantment buffs + vitae
## #7 — [DONE 2026-04-25] PlayerDescription parser stops after spells (enchantment block parsed)
**Closed:** 2026-04-25
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Extended `PlayerDescriptionParser` past the spell block to parse the Enchantment trailer per holtburger `events.rs:462-501`. Added `EnchantmentEntry` record with full wire payload (16 fields including the `StatMod` triad — type/key/val) + `EnchantmentBucket` (Multiplicative / Additive / Cooldown / Vitae per `EnchantmentMask`). `Parsed` now exposes `IReadOnlyList<EnchantmentEntry> Enchantments`. `GameEventWiring` routes each entry through the new `Spellbook.OnEnchantmentAdded(ActiveEnchantmentRecord)` overload with `StatModType` / `StatModKey` / `StatModValue` / `Bucket` populated. 2 new parser tests cover the enchantment block schema + Vitae singleton.
The remaining trailer sections (options / shortcuts / hotbars / inventory / equipped) are not yet parsed; filed as #13. Stopping after enchantments is intentional — it covers the highest-value section (issue #6 lights up) and avoids the heuristic `gameplay_options` walker that #13 needs.
---
## #12 — [DONE 2026-04-25] Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
**Closed:** 2026-04-25
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing.
---
## #6 — [DONE 2026-04-25 architecture; data flowing as of #12] Vital max ignores enchantment buffs + vitae
**Closed:** 2026-04-25
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`

View file

@ -237,6 +237,24 @@ public static class GameEventWiring
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
});
}
}

View file

@ -129,6 +129,49 @@ public static class PlayerDescriptionParser
float X, float Y, float Z,
float Qw, float Qx, float Qy, float Qz);
/// <summary>One enchantment entry from the trailer enchantment
/// block. Wire layout per holtburger
/// <c>messages/magic/types.rs:40</c> (60 or 64 bytes per record).
/// </summary>
public readonly record struct EnchantmentEntry(
ushort SpellId,
ushort Layer,
ushort SpellCategory,
ushort HasSpellSetId,
uint PowerLevel,
double StartTime,
double Duration,
uint CasterGuid,
float DegradeModifier,
float DegradeLimit,
double LastTimeDegraded,
uint StatModType,
uint StatModKey,
float StatModValue,
uint? SpellSetId,
EnchantmentBucket Bucket);
/// <summary>Bucket the enchantment came from in the
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
/// stat-mod aggregator multiplies or adds.</summary>
public enum EnchantmentBucket : uint
{
Multiplicative = 1,
Additive = 2,
Cooldown = 4,
Vitae = 8,
}
[Flags]
public enum EnchantmentMask : uint
{
None = 0,
Multiplicative = 0x01,
Additive = 0x02,
Cooldown = 0x04,
Vitae = 0x08,
}
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
@ -138,7 +181,8 @@ public static class PlayerDescriptionParser
IReadOnlyDictionary<uint, WorldPosition> Positions,
IReadOnlyList<AttributeEntry> Attributes,
IReadOnlyList<SkillEntry> Skills,
IReadOnlyDictionary<uint, float> Spells);
IReadOnlyDictionary<uint, float> Spells,
IReadOnlyList<EnchantmentEntry> Enchantments);
/// <summary>
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
@ -161,6 +205,7 @@ public static class PlayerDescriptionParser
var attributes = new List<AttributeEntry>();
var skills = new List<SkillEntry>();
var spells = new Dictionary<uint, float>();
var enchantments = new List<EnchantmentEntry>();
// ── Property hashtables (each gated on a flag bit) ──────────────
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32))
@ -198,16 +243,19 @@ public static class PlayerDescriptionParser
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.
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
// Holtburger events.rs:462-501. After this block come options /
// shortcuts / hotbars / inventory / equipped — those need a
// heuristic walker for the variable-length gameplay_options blob.
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
// partial parses still populate enchantments.
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
ReadEnchantmentBlock(payload, ref pos, enchantments);
return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells);
bundle, positions, attributes, skills, spells, enchantments);
}
catch (FormatException)
{
@ -224,7 +272,8 @@ public static class PlayerDescriptionParser
Dictionary<uint, float> spells)
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells);
bundle, positions, attributes, skills, spells,
System.Array.Empty<EnchantmentEntry>());
}
// ── Attribute block reader ──────────────────────────────────────────────
@ -413,6 +462,86 @@ public static class PlayerDescriptionParser
}
}
// ── Enchantment block reader ────────────────────────────────────────────
// Wire format per holtburger events.rs:462-501 + magic/types.rs:40:
// u32 EnchantmentMask
// if mask & MULTIPLICATIVE: u32 count, count × Enchantment(60-64 B)
// if mask & ADDITIVE: u32 count, count × Enchantment(60-64 B)
// if mask & COOLDOWN: u32 count, count × Enchantment(60-64 B)
// if mask & VITAE: single Enchantment(60-64 B)
private static void ReadEnchantmentBlock(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> enchantments)
{
if (src.Length - pos < 4) return;
EnchantmentMask mask = (EnchantmentMask)ReadU32(src, ref pos);
if (mask.HasFlag(EnchantmentMask.Multiplicative))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Multiplicative);
if (mask.HasFlag(EnchantmentMask.Additive))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Additive);
if (mask.HasFlag(EnchantmentMask.Cooldown))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Cooldown);
if (mask.HasFlag(EnchantmentMask.Vitae))
{
// Vitae is a single enchantment (no count prefix).
enchantments.Add(ReadEnchantment(src, ref pos, EnchantmentBucket.Vitae));
}
}
private static void ReadEnchantmentList(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
ReadOnlySpan<byte> src, ref int pos, EnchantmentBucket bucket)
{
// Holtburger Enchantment::unpack — 28 + 4 (Guid) + 28 + (4 if has_set_id) bytes:
// u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id,
// u32 power_level, f64 start_time, f64 duration, (28 bytes total)
// u32 caster_guid, (4)
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated u16");
ushort v = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
return v;
}
// ── Primitive readers ───────────────────────────────────────────────────
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)

View file

@ -102,22 +102,34 @@ public static class EnchantmentMath
}
}
// Aggregate StatMod values from the deduplicated set.
// Note: ActiveEnchantmentRecord doesn't currently carry
// StatMod (type/key/val). Until ISSUES.md #12 lands the wire
// extension, this loop produces no aggregation effect — we
// keep the architecture in place so the swap-in is local.
// Aggregate StatMod values from the deduplicated set. Records
// with StatModKey == statKey contribute; bucket determines
// whether the value is multiplicative or additive.
// Bucket 1 (Multiplicative): multiplier *= ench.StatModValue
// Bucket 2 (Additive): additive += ench.StatModValue
// Bucket 8 (Vitae): multiplier *= ench.StatModValue (post-pass)
// Records without StatMod data (StatModKey == null) — e.g.
// those from older MagicUpdateEnchantment events that don't
// yet parse the full payload — contribute nothing.
float multiplier = 1.0f;
float additive = 0.0f;
float vitae = 1.0f;
foreach (var ench in stronger.Values)
{
// TODO ISSUES.md #12: filter by ench.StatModKey == statKey
// and apply ench.StatModType (multiplicative vs additive)
// with ench.StatModValue. For now, stat-key filtering is
// a no-op since the data isn't on the record yet.
_ = ench;
_ = statKey;
if (ench.StatModKey is not uint key || key != statKey) continue;
if (ench.StatModValue is not float val) continue;
switch (ench.Bucket)
{
case 1: multiplier *= val; break;
case 2: additive += val; break;
case 8: vitae *= val; break;
// Bucket 4 (Cooldown) doesn't affect vital max.
}
}
// Vitae is applied multiplicatively last per retail
// CEnchantmentRegistry::EnchantAttribute behaviour.
multiplier *= vitae;
return multiplier == 1.0f && additive == 0.0f
? VitalMod.Identity
: new VitalMod(multiplier, additive);

View file

@ -104,6 +104,19 @@ public sealed class Spellbook
EnchantmentAdded?.Invoke(record);
}
/// <summary>
/// Issue #7 / #12 — accept a fully-populated record from
/// <c>PlayerDescription</c>'s enchantment block (which carries
/// the StatMod triad + bucket). Used when the wire-format extension
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{
_activeByLayer[record.LayerId] = record;
EnchantmentAdded?.Invoke(record);
}
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
public void OnEnchantmentRemoved(uint layerId, uint spellId)
{
@ -129,13 +142,27 @@ public sealed class Spellbook
}
/// <summary>
/// Summary of one active enchantment layer on the player. Richer detail
/// (stat mods, category, power) requires the full <see cref="ActiveBuff"/>
/// struct — this record is the wire-slim version surfaced by the
/// <see cref="Spellbook.EnchantmentAdded"/> event.
/// Summary of one active enchantment layer on the player. The
/// optional StatMod fields (issue #12) carry the wire-level
/// `_smod` triad <c>(type, key, val)</c> when available — only
/// `PlayerDescription`'s enchantment block currently populates these
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
/// `MagicUpdateEnchantment` events still produce records with these
/// fields null until the wire parser is extended.
///
/// <para>
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
/// the retail <c>EnchantmentMask</c> classification.
/// </para>
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);

View file

@ -207,6 +207,108 @@ public sealed class PlayerDescriptionParserTests
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
}
[Fact]
public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket()
{
// ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus
// SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty
// attribute block + empty spell table + 1 multiplicative
// enchantment + 1 additive enchantment. Verifies end-to-end
// that the enchantment record schema lands intact.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
// vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301
writer.Write(0x301u);
writer.Write(1u); // has_health
writer.Write(0u); // attribute_flags = 0 -> no entries
// Spell table: empty (count=0).
writer.Write((ushort)0);
writer.Write((ushort)0);
// EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03
writer.Write(0x03u);
// Multiplicative list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0,
powerLevel: 999, startTime: 12.5, duration: 1800.0,
casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */,
statModValue: 1.5f);
// Additive list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0,
powerLevel: 100, startTime: 13.0, duration: 1500.0,
casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */,
statModValue: 25.0f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Enchantments.Count);
var mult = parsed.Value.Enchantments[0];
Assert.Equal((ushort)1234, mult.SpellId);
Assert.Equal((ushort)5, mult.Layer);
Assert.Equal(3u, mult.StatModKey);
Assert.Equal(1.5f, mult.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket);
var add = parsed.Value.Enchantments[1];
Assert.Equal((ushort)5678, add.SpellId);
Assert.Equal(5u, add.StatModKey);
Assert.Equal(25.0f, add.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket);
}
[Fact]
public void TryParse_VitaeSingleton_AppearsInEnchantments()
{
// EnchantmentMask = VITAE only (0x08). Single Enchantment, no
// count prefix.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0x08u); // EnchantmentMask = VITAE
WriteEnchantment(writer,
spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0,
powerLevel: 0, startTime: 0.0, duration: -1.0,
casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f,
lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */,
statModValue: 0.95f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.Enchantments);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket);
Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue);
}
private static void WriteEnchantment(BinaryWriter w,
ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId,
uint powerLevel, double startTime, double duration, uint casterGuid,
float degradeMod, float degradeLimit, double lastDegraded,
uint statModType, uint statModKey, float statModValue)
{
w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId);
w.Write(powerLevel); w.Write(startTime); w.Write(duration);
w.Write(casterGuid);
w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded);
w.Write(statModType); w.Write(statModKey); w.Write(statModValue);
// Skip optional spell_set_id (only present if hasSpellSetId != 0).
}
[Fact]
public void TryParse_SpellTable_PopulatesSpellsDictionary()
{

View file

@ -109,6 +109,106 @@ public sealed class EnchantmentMathTests
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches()
{
// Two multiplicative enchantments on MaxStamina (key=3): values
// 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32.
// Different families so neither dedups the other.
var table = LoadTable(
(10u, "Buff10", 100u),
(11u, "Buff11", 200u));
var enchantments = new[]
{
MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f),
MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(1.32f, mod.Multiplier, precision: 4);
Assert.Equal(0.0f, mod.Additive);
}
[Fact]
public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches()
{
var table = LoadTable(
(20u, "Add1", 300u),
(21u, "Add2", 301u));
var enchantments = new[]
{
MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f),
MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxMana);
Assert.Equal(1.0f, mod.Multiplier);
Assert.Equal(75.0f, mod.Additive);
}
[Fact]
public void GetMod_StatKeyMismatch_DoesNotContribute()
{
var table = LoadTable((30u, "Health buff", 500u));
// Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3).
var enchantments = new[]
{
MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs()
{
// Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10
// additive from a Restoration buff. Family 0 means each is its
// own bucket.
var table = LoadTable(
(40u, "Restoration", 0u),
(41u, "Vitae", 0u));
var enchantments = new[]
{
MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f),
MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxHealth);
// Vitae multiplier 0.85, additive 10.
Assert.Equal(0.85f, mod.Multiplier, precision: 3);
Assert.Equal(10.0f, mod.Additive);
}
[Fact]
public void GetMod_FamilyStacking_PicksHigherSpellId()
{
// Two spells in the same family — only the one with the higher
// SpellId should contribute.
var table = LoadTable(
(10u, "Strength I", 1u), // Family=1
(132u, "Strength VII", 1u)); // same family
var enchantments = new[]
{
MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f),
MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
// Only the higher-id buff (1.5) applies.
Assert.Equal(1.5f, mod.Multiplier, precision: 3);
}
private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u);
private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u);
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 8u);
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
{
// Build a synthetic CSV with just enough columns for SpellTable to