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;
}
}