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

@ -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