Three fixes to the Vitals HUD path:
1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
the Vitae singleton with mask bit 0x4 set, my parser read it as
"Cooldown" and tried to consume a count-prefixed list (no count
present), blowing up with FormatException, returning null from
TryParse. PlayerDescription consequently failed to parse on every
live login. Fix: swap the bit values + bucket constants to match ACE.
2. Vitae applies regardless of StatModKey. Live trace showed:
vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
per retail. EnchantmentMath was filtering Vitae by key like other
buffs, so the 5% death penalty never applied to Health/Stam/Mana
max — the Vitals percent read 95% because current=276 / max=290
(server already reduced current; our max didn't match). Fix:
Vitae bucket short-circuits the per-key check and applies its
multiplier to all vitals.
3. Absolute current/max in HUD overlay. VitalsVM exposes
HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
LocalPlayerState. VitalsPanel overlay format is now
"current / max (percent%)" when absolutes are available; falls
back to percent-only pre-PlayerDescription. Matches the retail
look the user requested ("HP 400/400" style).
Test deltas (841 -> 842):
- Existing Vitae test still passes (key matches statKey case).
- New Vitae key=0 test pins the "any vital" semantics.
- Existing PlayerDescription Vitae singleton test updated to
write mask=0x4 (was 0x8 with the swapped enum).
Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
HP : current=138, max=145×0.95≈138 → bar 100% (was 95%)
Stam : current=276, max=290×0.95≈276 → bar 100%
Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
6.7 KiB
C#
162 lines
6.7 KiB
C#
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>
|
|
/// 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>
|
|
public static VitalMod GetMod(
|
|
IEnumerable<ActiveEnchantmentRecord> 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<uint, ActiveEnchantmentRecord>();
|
|
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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|