using System; 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); } /// /// Retail EnchantmentTypeFlags — the low "StatTypes" byte of the /// wire StatMod.type field, distinguishing which domain a /// mult/add enchantment record targets. Verified against /// references/ACE/Source/ACE.Entity/Enum/EnchantmentTypeFlags.cs /// and the type argument literals passed to /// CEnchantmentRegistry::CullEnchantmentsFromList inside /// EnchantAttribute (0x00594570, type=1), /// EnchantAttribute2nd (0x00594670, type=2), and /// EnchantSkill (0x005947b0, type=0x10). /// [Flags] public enum EnchantmentTypeFlag : uint { /// Primary attribute (Strength/Endurance/Coordination/ /// Quickness/Focus/Self). CEnchantmentRegistry::EnchantAttribute /// filters on this bit and does NOT reference the vitae singleton. Attribute = 0x0000001, /// Secondary attribute — vital max (MaxHealth/MaxStamina/ /// MaxMana). EnchantAttribute2nd filters on this bit and DOES /// apply vitae first. SecondAtt = 0x0000002, /// Skill. EnchantSkill filters on this bit and DOES /// apply vitae first. Skill = 0x0000010, } /// /// 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). /// When non-null, mult/add records must also /// carry this bit in their wire StatModType to contribute — this /// is what keeps a Strength buff (Attribute, key=1) from leaking into a /// MaxHealth (SecondAtt, key=1) computation just because the numeric key /// happens to collide. null preserves the original no-type-filter /// behavior (existing callers). /// Whether the vitae singleton (Bucket 4) /// contributes. Retail's EnchantAttribute never references vitae /// at all (primary attributes are vitae-immune); pass false for /// that domain. Defaults to true, matching every existing caller. public static VitalMod GetMod( IEnumerable enchantments, SpellTable table, uint statKey, EnchantmentTypeFlag? requiredType = null, bool includeVitae = true) { // 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)) { // #266 apparatus (permanent, rare): a VITAE record silently // dropped by the spell-table lookup would zero the whole // stat-chain effect — surface it. (Retail keeps _vitae on a // dedicated singleton slot that never runs family stacking; // if this line ever fires the faithful fix is hoisting the // Bucket-4 branch above the table lookup.) if (ench.Bucket == 4) { string droppedValue = ench.StatModValue is float dv ? dv.ToString("F4", System.Globalization.CultureInfo.InvariantCulture) : "NULL"; System.Console.WriteLine( System.FormattableString.Invariant( $"[stat-chain] VITAE DROPPED by spell-table lookup: spell={ench.SpellId} value={droppedValue}")); } 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 (subject only // to includeVitae — EnchantAttribute never references vitae // at all) and skip the per-key check. if (ench.Bucket == 4) { if (includeVitae) vitae *= val; continue; } // Multiplicative + Additive buffs filter by stat key — // only those targeting the requested vital/skill contribute. if (ench.StatModKey is not uint key || key != statKey) continue; // Domain filter: a numeric key can collide across Attribute / // SecondAtt / Skill (e.g. key=1 is both Strength and MaxHealth). // requiredType disambiguates using the wire StatModType bits. if (requiredType is EnchantmentTypeFlag type && (ench.StatModType.GetValueOrDefault() & (uint)type) == 0) 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 before the buff lists per retail // CEnchantmentRegistry::EnchantAttribute2nd (0x00594670) / EnchantSkill // (0x005947b0) — both apply `_vitae` to the base value first, then run // the mult/add culled list on top. Multiplication is commutative, so // folding vitae into `multiplier` here is equivalent regardless of // includeVitae being honored above. Retail's primary-attribute sibling // (EnchantAttribute, 0x00594570) never references `_vitae` at all — // callers pass includeVitae:false for that domain. multiplier *= vitae; return multiplier == 1.0f && additive == 0.0f ? VitalMod.Identity : new VitalMod(multiplier, additive); } /// /// Retail CEnchantmentRegistry::EnchantAttribute (0x00594570) final /// composition step: apply the aggregated mult/add modifier (from /// with /// and includeVitae:false) to the base value, floor the result at 1 /// when the base is below 10 or at 10 otherwise, then truncate to an int /// (retail _ftol2, C# (int) cast — both truncate toward /// zero). Primary attributes are vitae-immune in retail; only buffs move /// this number. /// public static int EnchantAttribute(VitalMod mod, uint baseValue) { float value = (baseValue * mod.Multiplier) + mod.Additive; float floor = baseValue < 10u ? 1f : 10f; if (value < floor) value = floor; return (int)value; } /// /// Retail CEnchantmentRegistry::EnchantSkill (0x005947b0) final /// composition step: apply the aggregated (vitae-inclusive) mult/add /// modifier from (with /// , includeVitae:true) to /// the base value, zero-floor when the result drops below 0.5, then /// truncate to an int. /// public static int EnchantSkill(VitalMod mod, uint baseValue) { float value = (baseValue * mod.Multiplier) + mod.Additive; if (value < 0.5f) value = 0f; return (int)value; } /// /// Isolated vitae multiplier — retail's CEnchantmentRegistry::_vitae /// singleton applied WITHOUT the mult/add buff lists. Returns 1.0 /// (no penalty) when no vitae enchantment is active. Vitae is never /// subject to family-stacking dedup in retail (there is exactly one /// _vitae field, never a list), so this scans directly rather than /// routing through 's family-stacking pass. /// public static float GetVitaeMultiplier(IEnumerable enchantments) { ArgumentNullException.ThrowIfNull(enchantments); float vitae = 1.0f; foreach (ActiveEnchantmentRecord ench in enchantments) { if (ench.Bucket == 4 && ench.StatModValue is float val) vitae *= val; } return vitae; } /// /// Retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0): the /// vitae-only contribution to a skill's effective level, reported in /// isolation from any buffs — truncate(base × vitaeMult) − base, /// always ≤ 0 (vitae is a strict penalty). This is the exact number the /// retail Character window shows in the vitae-specific footer /// parenthetical (e.g. "(-100)"), separate from any buff delta. /// public static int SkillVitaeModifier( IEnumerable enchantments, uint baseValue) { float vitae = GetVitaeMultiplier(enchantments); if (vitae == 1.0f) return 0; return (int)(baseValue * vitae) - (int)baseValue; } /// /// Campaign P Slice P1 — Skill-namespace convenience over /// (retail CEnchantmentRegistry::EnchantSkill /// 0x005947b0): vitae applies (Bucket 4), mult/add records filter on /// + the skill id. Consumed by /// RuntimeCharacterState's run/jump recompute; #267's panel path /// uses the same aggregation via . /// public static VitalMod GetSkillMod( IEnumerable enchantments, SpellTable table, uint skillId) => GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill); /// /// 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; } }