acdream/src/AcDream.Core/Spells/EnchantmentMath.cs
2026-07-30 18:14:48 +02:00

318 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Aggregates active-enchantment buffs into per-stat <c>(multiplier,
/// additive)</c> modifier pairs, mirroring retail
/// <c>CEnchantmentRegistry::EnchantAttribute</c> at PDB
/// <c>0x00594570</c> (see
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
/// line 416110).
///
/// <para>
/// <b>Retail formula:</b>
/// </para>
/// <code>
/// for each enchantment in _mult_list (after CullEnchantmentsFromList):
/// if statMod.key == requested_key &amp;&amp; mod-type is multiplicative:
/// multiplier *= statMod.val
/// for each enchantment in _add_list:
/// if statMod.key == requested_key &amp;&amp; mod-type is additive:
/// additive += statMod.val
/// apply family-stacking: only one enchantment per Family wins
/// (highest Generation; tie-broken by latest cast).
/// </code>
///
/// <para>
/// <b>Vitae</b> (death penalty) is a singleton on
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
/// the buff lists. We don't yet wire it through.
/// </para>
///
/// <para>
/// <b>Current implementation status:</b> the aggregator iterates
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
/// <see cref="SpellTable"/> family-stacking deduplication, but
/// **returns identity (1.0, 0.0) for stat modifiers** because our
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
/// <c>StatMod (type/key/val)</c> triad — that requires extending
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
/// payload (60-64 bytes per holtburger
/// <c>messages/magic/types.rs</c>) 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.
/// </para>
///
/// <para>
/// <b>Stat keys</b> (ACE <c>PropertyAttribute2nd</c>):
/// <c>MaxHealth=1</c>, <c>MaxStamina=3</c>, <c>MaxMana=5</c>.
/// Verified against
/// <c>docs/research/named-retail/acclient.h</c> line 37287-37301
/// (<c>SecondaryAttribute</c> family).
/// </para>
/// </summary>
public static class EnchantmentMath
{
/// <summary>
/// Combined multiplicative + additive modifier for a stat key.
/// </summary>
public readonly record struct VitalMod(float Multiplier, float Additive)
{
/// <summary>Identity modifier — <c>(1.0, 0.0)</c>. No active
/// buffs apply.</summary>
public static readonly VitalMod Identity = new(1.0f, 0.0f);
}
/// <summary>
/// Retail <c>EnchantmentTypeFlags</c> — the low "StatTypes" byte of the
/// wire <c>StatMod.type</c> field, distinguishing which domain a
/// mult/add enchantment record targets. Verified against
/// <c>references/ACE/Source/ACE.Entity/Enum/EnchantmentTypeFlags.cs</c>
/// and the <c>type</c> argument literals passed to
/// <c>CEnchantmentRegistry::CullEnchantmentsFromList</c> inside
/// <c>EnchantAttribute</c> (0x00594570, type=1),
/// <c>EnchantAttribute2nd</c> (0x00594670, type=2), and
/// <c>EnchantSkill</c> (0x005947b0, type=0x10).
/// </summary>
[Flags]
public enum EnchantmentTypeFlag : uint
{
/// <summary>Primary attribute (Strength/Endurance/Coordination/
/// Quickness/Focus/Self). <c>CEnchantmentRegistry::EnchantAttribute</c>
/// filters on this bit and does NOT reference the vitae singleton.</summary>
Attribute = 0x0000001,
/// <summary>Secondary attribute — vital max (MaxHealth/MaxStamina/
/// MaxMana). <c>EnchantAttribute2nd</c> filters on this bit and DOES
/// apply vitae first.</summary>
SecondAtt = 0x0000002,
/// <summary>Skill. <c>EnchantSkill</c> filters on this bit and DOES
/// apply vitae first.</summary>
Skill = 0x0000010,
}
/// <summary>
/// Compute the combined buff modifier for a given stat key from
/// the player's active enchantments. Returns <see cref="VitalMod.Identity"/>
/// when no relevant buffs are active.
/// </summary>
/// <param name="enchantments">All active enchantment layers
/// (typically <see cref="Spellbook.ActiveEnchantments"/>).</param>
/// <param name="table">Spell metadata table for family-stacking
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
/// <param name="statKey">Target stat key (ACE
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
/// 3=MaxStamina, 5=MaxMana).</param>
/// <param name="requiredType">When non-null, mult/add records must also
/// carry this bit in their wire <c>StatModType</c> 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. <c>null</c> preserves the original no-type-filter
/// behavior (existing <see cref="Spellbook.GetVitalMod"/> callers).</param>
/// <param name="includeVitae">Whether the vitae singleton (Bucket 4)
/// contributes. Retail's <c>EnchantAttribute</c> never references vitae
/// at all (primary attributes are vitae-immune); pass <c>false</c> for
/// that domain. Defaults to <c>true</c>, matching every existing caller.</param>
public static VitalMod GetMod(
IEnumerable<ActiveEnchantmentRecord> 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<uint, ActiveEnchantmentRecord>();
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);
}
/// <summary>
/// Retail <c>CEnchantmentRegistry::EnchantAttribute</c> (0x00594570) final
/// composition step: apply the aggregated mult/add modifier (from
/// <see cref="GetMod"/> with <see cref="EnchantmentTypeFlag.Attribute"/>
/// and <c>includeVitae:false</c>) 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 <c>_ftol2</c>, C# <c>(int)</c> cast — both truncate toward
/// zero). Primary attributes are vitae-immune in retail; only buffs move
/// this number.
/// </summary>
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;
}
/// <summary>
/// Retail <c>CEnchantmentRegistry::EnchantSkill</c> (0x005947b0) final
/// composition step: apply the aggregated (vitae-inclusive) mult/add
/// modifier from <see cref="GetMod"/> (with
/// <see cref="EnchantmentTypeFlag.Skill"/>, <c>includeVitae:true</c>) to
/// the base value, zero-floor when the result drops below 0.5, then
/// truncate to an int.
/// </summary>
public static int EnchantSkill(VitalMod mod, uint baseValue)
{
float value = (baseValue * mod.Multiplier) + mod.Additive;
if (value < 0.5f) value = 0f;
return (int)value;
}
/// <summary>
/// Isolated vitae multiplier — retail's <c>CEnchantmentRegistry::_vitae</c>
/// singleton applied WITHOUT the mult/add buff lists. Returns <c>1.0</c>
/// (no penalty) when no vitae enchantment is active. Vitae is never
/// subject to family-stacking dedup in retail (there is exactly one
/// <c>_vitae</c> field, never a list), so this scans directly rather than
/// routing through <see cref="GetMod"/>'s family-stacking pass.
/// </summary>
public static float GetVitaeMultiplier(IEnumerable<ActiveEnchantmentRecord> 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;
}
/// <summary>
/// Retail <c>SkillInfoRegion::GetVitaeModifier</c> (0x004f0fa0): the
/// vitae-only contribution to a skill's effective level, reported in
/// isolation from any buffs — <c>truncate(base × vitaeMult) base</c>,
/// 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. <c>"(-100)"</c>), separate from any buff delta.
/// </summary>
public static int SkillVitaeModifier(
IEnumerable<ActiveEnchantmentRecord> enchantments,
uint baseValue)
{
float vitae = GetVitaeMultiplier(enchantments);
if (vitae == 1.0f) return 0;
return (int)(baseValue * vitae) - (int)baseValue;
}
/// <summary>
/// Campaign P Slice P1 — Skill-namespace convenience over
/// <see cref="GetMod"/> (retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// 0x005947b0): vitae applies (Bucket 4), mult/add records filter on
/// <see cref="EnchantmentTypeFlag.Skill"/> + the skill id. Consumed by
/// <c>RuntimeCharacterState</c>'s run/jump recompute; #267's panel path
/// uses the same aggregation via <see cref="Spellbook.GetSkillMod"/>.
/// </summary>
public static VitalMod GetSkillMod(
IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table,
uint skillId) =>
GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill);
/// <summary>
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
/// (verified against <c>docs/research/named-retail/acclient.h</c>
/// line 37287-37301). Used by <see cref="LocalPlayerState"/> to
/// look up the right buff bucket per vital kind.
/// </summary>
public static class StatKey
{
public const uint MaxHealth = 1;
public const uint MaxStamina = 3;
public const uint MaxMana = 5;
}
}