merge: #267 vitae character-panel display (attributes vitae-immune per retail; skill dual parentheticals)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:14:48 +02:00
commit 2493f24c63
12 changed files with 984 additions and 108 deletions

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
@ -65,6 +66,35 @@ public static class EnchantmentMath
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"/>
@ -76,20 +106,23 @@ public static class EnchantmentMath
/// (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 — or a Skill id when
/// <paramref name="requiredStatModTypeFlag"/> is
/// <see cref="EnchantmentTypeFlag.Skill"/>).</param>
/// <param name="requiredStatModTypeFlag">When set, a record's
/// <c>StatModType</c> must carry this flag bit to be considered a
/// candidate — disambiguates namespaces that share numeric keys (e.g.
/// vital key 5=MaxMana vs skill id 5). <c>null</c> (default) preserves
/// the original vitals behavior with no type check, unchanged from
/// before Campaign P.</param>
/// 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,
uint? requiredStatModTypeFlag = null)
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
@ -147,29 +180,24 @@ public static class EnchantmentMath
// 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.
// 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)
{
vitae *= val;
continue;
}
// Campaign P (2026-07-30): an optional type-flag gate
// disambiguates numeric-key collisions across namespaces (e.g.
// vital key 5=MaxMana vs skill id 5) — mirrors retail's
// CullEnchantmentsFromList `category` argument (2 for
// Attribute2nd, 0x10=Skill for EnchantSkill).
if (requiredStatModTypeFlag is uint typeFlag
&& (ench.StatModType is not uint recordType
|| (recordType & typeFlag) == 0))
{
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;
@ -177,8 +205,14 @@ public static class EnchantmentMath
// Bucket 8 (Cooldown) doesn't affect vital max.
}
}
// Vitae is applied multiplicatively last per retail
// CEnchantmentRegistry::EnchantAttribute behaviour.
// 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
@ -186,15 +220,82 @@ public static class EnchantmentMath
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — Skill-namespace convenience over
/// <see cref="GetMod"/>, matching retail <c>CEnchantmentRegistry::
/// EnchantSkill</c> (0x005947b0): vitae applies unconditionally (same
/// as vitals), multiplicative/additive skill buffs are filtered to
/// records whose <c>StatModType</c> carries
/// <see cref="EnchantmentTypeFlag.Skill"/> AND whose <c>StatModKey</c>
/// equals <paramref name="skillId"/> (ACE Skill enum ordinal — Run=24,
/// Jump=22). Scoped to the run/jump query path per the P1 plan; not a
/// general effective-skill engine.
/// 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,
@ -202,18 +303,6 @@ public static class EnchantmentMath
uint skillId) =>
GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill);
/// <summary>
/// Retail <c>EnchantmentTypeFlags</c> bits relevant to disambiguating
/// <see cref="GetMod"/>'s <c>statKey</c> namespace (ACE
/// <c>ACE.Entity.Enum.EnchantmentTypeFlags</c>, cross-referenced —
/// StatModType is a bitfield the wire already carries per-enchantment).
/// </summary>
public static class EnchantmentTypeFlag
{
public const uint SecondAtt = 0x0000002u;
public const uint Skill = 0x0000010u;
}
/// <summary>
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
/// (verified against <c>docs/research/named-retail/acclient.h</c>