using System.Collections.Generic; namespace AcDream.Core.Spells; /// /// Aggregates active-enchantment buffs into per-stat (multiplier, /// additive) modifier pairs, mirroring retail /// CEnchantmentRegistry::EnchantAttribute at PDB /// 0x00594570 (see /// docs/research/named-retail/acclient_2013_pseudo_c.txt /// line 416110). /// /// /// Retail formula: /// /// /// for each enchantment in _mult_list (after CullEnchantmentsFromList): /// if statMod.key == requested_key && mod-type is multiplicative: /// multiplier *= statMod.val /// for each enchantment in _add_list: /// if statMod.key == requested_key && mod-type is additive: /// additive += statMod.val /// apply family-stacking: only one enchantment per Family wins /// (highest Generation; tie-broken by latest cast). /// /// /// /// Vitae (death penalty) is a singleton on /// CEnchantmentRegistry._vitae, applied multiplicatively after /// the buff lists. We don't yet wire it through. /// /// /// /// Current implementation status: the aggregator iterates /// and applies /// family-stacking deduplication, but /// **returns identity (1.0, 0.0) for stat modifiers** because our /// doesn't yet carry the /// StatMod (type/key/val) triad — that requires extending /// ParseMagicUpdateEnchantment to read the full Enchantment /// payload (60-64 bytes per holtburger /// messages/magic/types.rs) and storing it on the record. /// Filed as ISSUES.md #12. Once that lands, the aggregator's /// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and /// the Vitals HUD percent gap closes. /// /// /// /// Stat keys (ACE PropertyAttribute2nd): /// MaxHealth=1, MaxStamina=3, MaxMana=5. /// Verified against /// docs/research/named-retail/acclient.h line 37287-37301 /// (SecondaryAttribute family). /// /// public static class EnchantmentMath { /// /// Combined multiplicative + additive modifier for a stat key. /// public readonly record struct VitalMod(float Multiplier, float Additive) { /// Identity modifier — (1.0, 0.0). No active /// buffs apply. public static readonly VitalMod Identity = new(1.0f, 0.0f); } /// /// Compute the combined buff modifier for a given stat key from /// the player's active enchantments. Returns /// when no relevant buffs are active. /// /// All active enchantment layers /// (typically ). /// Spell metadata table for family-stacking /// (only one buff per wins). /// Target stat key (ACE /// PropertyAttribute2nd enum value: 1=MaxHealth, /// 3=MaxStamina, 5=MaxMana). public static VitalMod GetMod( IEnumerable enchantments, SpellTable table, uint statKey) { // Family-stacking: bucket the active enchantments by Family and // keep the strongest one per bucket (the one with the largest // SpellId, which in retail correlates with generation level — // higher level = higher id within a family. Without the // Generation field, this is a faithful approximation.) var stronger = new Dictionary(); foreach (var ench in enchantments) { if (!table.TryGet(ench.SpellId, out var meta)) continue; // Family 0 means "no stacking bucket" — these don't dedup; // pass them through with a synthetic key per layer. uint bucket = meta.Family == 0 ? ench.LayerId | 0x80000000u : meta.Family; if (!stronger.TryGetValue(bucket, out var current) || ench.SpellId > current.SpellId) { stronger[bucket] = ench; } } // Aggregate StatMod values from the deduplicated set. Bucket // values match ACE's EnchantmentMask flag bits: // Bucket 1 (Multiplicative): multiplier *= ench.StatModValue // Bucket 2 (Additive): additive += ench.StatModValue // Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass) // Bucket 8 (Cooldown): skipped (doesn't affect vital max) // 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) { if (ench.StatModValue is not float val) continue; // Vitae (bucket 4) is a special-case singleton on // CEnchantmentRegistry._vitae and applies its multiplier // to ALL vitals regardless of StatModKey (retail uses // key=0 as "any vital"). Apply unconditionally and skip // the per-key check. if (ench.Bucket == 4) { vitae *= val; continue; } // Multiplicative + Additive buffs filter by stat key — // only those targeting the requested vital contribute. if (ench.StatModKey is not uint key || key != statKey) continue; switch (ench.Bucket) { case 1: multiplier *= val; break; case 2: additive += val; break; // Bucket 8 (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); } /// /// Stat-key constants matching ACE PropertyAttribute2nd /// (verified against docs/research/named-retail/acclient.h /// line 37287-37301). Used by to /// look up the right buff bucket per vital kind. /// public static class StatKey { public const uint MaxHealth = 1; public const uint MaxStamina = 3; public const uint MaxMana = 5; } }