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:
parent
b153bbe5ad
commit
bb5003a849
7 changed files with 447 additions and 58 deletions
|
|
@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue