318 lines
15 KiB
C#
318 lines
15 KiB
C#
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 && 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).
|
||
/// </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;
|
||
}
|
||
}
|