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

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