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

@ -89,6 +89,10 @@ public sealed class CharacterSheet
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
// Issue #267: these are the EFFECTIVE (post-buff) values — retail
// CACQualities::EnchantAttribute (0x00594570). Primary attributes are
// vitae-immune in retail; only active buffs move this number away from
// AttributeBaseValues below.
public int Strength { get; init; }
public int Endurance { get; init; }
public int Quickness { get; init; }
@ -96,6 +100,17 @@ public sealed class CharacterSheet
public int Focus { get; init; }
public int Self { get; init; }
/// <summary>
/// Unenchanted base value for each of the 6 primary attributes, in
/// POSITIONAL order matching <c>CharacterStatController.AttrRows</c>
/// (Strength, Endurance, Coordination, Quickness, Focus, Self — note
/// this differs from the individual-property declaration order above).
/// Issue #267: paired with the effective values above to compute the
/// retail footer-title delta parenthetical
/// (<c>gmAttributeUI::DisplaySelectionFooter_Attribute</c> 0x0049d280).
/// </summary>
public int[] AttributeBaseValues { get; init; } = Array.Empty<int>();
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
// Character Information uses 0xB5/0xC0 for Chess/Fishing; skill credits use 0x18.
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
@ -187,9 +202,18 @@ public sealed record CharacterSkill(
uint IconDid,
CharacterSkillAdvancementClass AdvancementClass,
int BaseLevel,
// Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of
// BaseLevel; this activates the existing CharacterStatController.
// SkillValueColor buffed/debuffed row coloring.
int CurrentLevel,
bool UsableUntrained,
int TrainedCost,
int SpecializedCost,
long RaiseCost,
long Raise10Cost = 0L);
long Raise10Cost = 0L,
// Issue #267: vitae's isolated contribution to CurrentLevel (always ≤ 0),
// retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the
// footer-title vitae-specific parenthetical, separate from the buff delta
// (CurrentLevel VitaeModifier BaseLevel).
int VitaeModifier = 0);

View file

@ -141,12 +141,16 @@ public sealed class CharacterSheetProvider
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
// Issue #267: the panel's main attribute values are EFFECTIVE
// (post-buff) — retail CACQualities::EnchantAttribute. Base values
// for the footer-title delta parenthetical live in
// AttributeBaseValues below, in the same positional order.
Strength = AttrEffective(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrEffective(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrEffective(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrEffective(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrEffective(LocalPlayerState.AttributeKind.Focus),
Self = AttrEffective(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = skillCredits,
SpecializedSkillCredits = 0,
@ -163,6 +167,15 @@ public sealed class CharacterSheetProvider
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
AttributeBaseValues = new[]
{
AttrCurrent(LocalPlayerState.AttributeKind.Strength),
AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
AttrCurrent(LocalPlayerState.AttributeKind.Focus),
AttrCurrent(LocalPlayerState.AttributeKind.Self),
},
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
@ -207,6 +220,12 @@ public sealed class CharacterSheetProvider
owner._objects.Cleared += OnCleared;
owner._localPlayer.AttributeChanged += OnAttributeChanged;
owner._localPlayer.CharacterChanged += OnCharacterChanged;
// Issue #267: skills/attributes are now vitae + buff aware, so the
// sheet must refresh whenever the active-enchantment set changes
// (vitae applied/removed on death/lifestone, a buff cast/expired),
// not only on raw property/attribute updates.
if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged += OnCleared;
}
private void OnObjectChanged(ClientObject value)
@ -235,6 +254,8 @@ public sealed class CharacterSheetProvider
owner._objects.Cleared -= OnCleared;
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged -= OnCleared;
}
}
@ -349,18 +370,27 @@ public sealed class CharacterSheetProvider
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
// isolates vitae's own contribution for the footer's separate
// vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0).
int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId)
?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel));
int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
effectiveLevel,
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
raise10Cost,
vitaeModifier));
}
return result;
@ -418,9 +448,19 @@ public sealed class CharacterSheetProvider
_ => null,
};
/// <summary>Unenchanted base attribute value (Ranks + Start). Used for
/// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail
/// footer-title delta parenthetical compares this against
/// <see cref="AttrEffective"/>.</summary>
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
/// <summary>Issue #267 — effective (post-buff) attribute value shown as
/// the panel's main number. Retail primary attributes are vitae-immune;
/// see <see cref="LocalPlayerState.GetEffectiveAttribute"/>.</summary>
private int AttrEffective(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetEffectiveAttribute(kind) ?? 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;

View file

@ -1219,6 +1219,102 @@ public static class CharacterStatController
};
}
// ── Issue #267 — retail vitae/buff delta parenthetical ───────────────────
// Format cited from gmAttributeUI::DisplaySelectionFooter_Attribute
// (0x0049d280, format " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained
// (0x0049b860, vitae segment format " (%d)" via SkillInfoRegion::
// GetVitaeModifier 0x004f0fa0, buff segment " (%s%d)"). Retail colors each
// segment independently (AppendTextWithFont state 1/2/3); our UiText footer
// title element renders OneLine/single-color, so segments here share the
// title's white color rather than retail's per-run tint — a presentation
// simplification, not a value/format deviation.
/// <summary>Effective value for attribute/vital row <paramref name="index"/>
/// as an int (numeric twin of <see cref="GetRowValueString"/> for the 6
/// primary-attribute rows only — vitals use "cur/max" and have no single
/// effective int).</summary>
private static int GetRowEffectiveAttributeValue(CharacterSheet sheet, int index) => index switch
{
0 => sheet.Strength,
1 => sheet.Endurance,
2 => sheet.Coordination,
3 => sheet.Quickness,
4 => sheet.Focus,
5 => sheet.Self,
_ => 0,
};
/// <summary>Buff-only delta (effective base) for the primary-attribute
/// row at <paramref name="index"/>. Zero for vital rows (6-8) — vitals use
/// a separate "cur/max" footer format in retail
/// (<c>gmAttributeUI::DisplaySelectionFooter_Vital</c> 0x0049d6b0), not
/// this delta pattern.</summary>
internal static int GetAttributeDelta(CharacterSheet sheet, int index)
{
if ((uint)index >= (uint)AttrRows.Length) return 0;
int[] baseValues = sheet.AttributeBaseValues;
if (baseValues is null || index >= baseValues.Length) return 0;
return GetRowEffectiveAttributeValue(sheet, index) - baseValues[index];
}
/// <summary>Skill's buff-only delta, excluding vitae — retail computes
/// this as <c>(current vitaeModifier) base</c>
/// (<c>gmSkillUI::DisplaySelectionFooter_Trained</c> 0x0049b860): vitae's
/// own contribution is reported separately via
/// <see cref="FormatVitaeDelta"/>.</summary>
internal static int GetSkillBuffOnlyDelta(CharacterSkill skill) =>
(skill.CurrentLevel - skill.VitaeModifier) - skill.BaseLevel;
/// <summary>Retail " (%s%d)" buff-delta parenthetical: an explicit "+"
/// prefix on an increase (the natural %d has no leading plus), the
/// natural minus sign on a decrease (no prefix string set in that
/// branch), nothing when the delta is zero.</summary>
private static string FormatBuffDelta(int delta) => delta switch
{
0 => string.Empty,
> 0 => $" (+{delta})",
_ => $" ({delta})",
};
/// <summary>Retail " (%d)" vitae-specific parenthetical
/// (<c>SkillInfoRegion::GetVitaeModifier</c> 0x004f0fa0): shown only
/// while vitae is an active penalty (modifier &lt; 0) — vitae never
/// grants a bonus, so no "+" case exists.</summary>
private static string FormatVitaeDelta(int vitaeModifier) =>
vitaeModifier < 0 ? $" ({vitaeModifier})" : string.Empty;
/// <summary>Build the retail footer-title text for the current selection:
/// "{Name}: {value}" with the vitae + buff-delta parentheticals appended
/// for a selected trained/specialized skill or a selected attribute row.
/// Shared by both footer-state bindings (State A/B use physically
/// separate UiText elements in the imported layout) so the composition
/// logic lives in exactly one place.</summary>
private static string BuildSelectedTitleText(
CharacterStatTab tab,
Func<CharacterSheet> data,
int[] attrSel,
int[] skillSel)
{
if (tab == CharacterStatTab.Skills)
{
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null) return "Select a Skill to Improve";
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
return skill.Name;
string vitaeSuffix = FormatVitaeDelta(skill.VitaeModifier);
string buffSuffix = FormatBuffDelta(GetSkillBuffOnlyDelta(skill));
return $"{skill.Name}: {skill.CurrentLevel}{vitaeSuffix}{buffSuffix}";
}
if (attrSel[0] < 0) return "Select an Attribute to Improve";
CharacterSheet sheet = data();
string name = GetRowName(attrSel[0]);
string value = GetRowValueString(sheet, attrSel[0]);
string delta = FormatBuffDelta(GetAttributeDelta(sheet, attrSel[0]));
return $"{name}: {value}{delta}";
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
@ -1418,25 +1514,13 @@ public static class CharacterStatController
titleEl.ClickThrough = true;
titleEl.LinesProvider = () =>
{
if (activeTab[0] == CharacterStatTab.Skills)
{
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return new[] { new UiText.Line("Select a Skill to Improve", Body) };
string title = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
? $"{skill.Name}: {skill.CurrentLevel}"
: skill.Name;
return new[] { new UiText.Line(title, Vector4.One) };
}
if (attrSel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var s = data();
string name = GetRowName(attrSel[0]);
string value = GetRowValueString(s, attrSel[0]);
// State B title is WHITE (retail confirmed).
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);
bool nothingSelected = activeTab[0] == CharacterStatTab.Skills
? SkillAtDisplayIndex(data(), skillSel[0]) is null
: attrSel[0] < 0;
// State B title is WHITE (retail confirmed); the "nothing
// selected" prompt keeps the dimmer Body color.
return new[] { new UiText.Line(title, nothingSelected ? Body : Vector4.One) };
};
}
@ -1518,24 +1602,11 @@ public static class CharacterStatController
title.ClickThrough = true;
title.LinesProvider = () =>
{
if (activeTab[0] == CharacterStatTab.Skills)
{
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return new[] { new UiText.Line("Select a Skill to Improve", Body) };
string skillTitle = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
? $"{skill.Name}: {skill.CurrentLevel}"
: skill.Name;
return new[] { new UiText.Line(skillTitle, Vector4.One) };
}
if (attrSel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var sheet = data();
string name = GetRowName(attrSel[0]);
string value = GetRowValueString(sheet, attrSel[0]);
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
string titleText = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);
bool nothingSelected = activeTab[0] == CharacterStatTab.Skills
? SkillAtDisplayIndex(data(), skillSel[0]) is null
: attrSel[0] < 0;
return new[] { new UiText.Line(titleText, nothingSelected ? Body : Vector4.One) };
};
}

View file

@ -181,6 +181,76 @@ public sealed class LocalPlayerState
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>The optional wired spellbook — read-only exposure of the same
/// reference passed to the constructor. Issue #267: lets presentation
/// code (e.g. <c>CharacterSheetProvider</c>) subscribe to
/// <see cref="Spells.Spellbook.EnchantmentsChanged"/> for live character-sheet
/// refresh without duplicating the reference in its own constructor.</summary>
public Spellbook? Spellbook => _spellbook;
/// <summary>
/// Issue #267 — effective (post-buff) primary-attribute value. Retail
/// <c>CACQualities::EnchantAttribute</c> (0x00594570): primary attributes
/// do NOT receive vitae (only <see cref="GetMaxApprox"/>'s secondary
/// attributes and <see cref="GetEffectiveSkill"/>'s skills do). Returns
/// the unenchanted base when no spellbook is wired (back-compat) or the
/// attribute hasn't arrived yet.
/// </summary>
public int? GetEffectiveAttribute(AttributeKind kind)
{
AttributeSnapshot? attr = GetAttribute(kind);
if (attr is null) return null;
uint baseValue = attr.Value.Current;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetAttributeMod(AttributeKindToId(kind));
return EnchantmentMath.EnchantAttribute(mod, baseValue);
}
/// <summary>Reverse of <see cref="AttributeIdToKind"/> — the wire
/// PropertyAttribute id (1..6) for a given <see cref="AttributeKind"/>.</summary>
public static uint AttributeKindToId(AttributeKind kind) => kind switch
{
AttributeKind.Strength => 1u,
AttributeKind.Endurance => 2u,
AttributeKind.Quickness => 3u,
AttributeKind.Coordination => 4u,
AttributeKind.Focus => 5u,
AttributeKind.Self => 6u,
_ => 0u,
};
/// <summary>
/// Issue #267 — effective (post-vitae, post-buff) skill level. Retail
/// <c>CACQualities::EnchantSkill</c> (0x005947b0). Returns the
/// unenchanted base when no spellbook is wired (back-compat) or the
/// skill hasn't arrived yet.
/// </summary>
public int? GetEffectiveSkill(uint skillId)
{
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null;
uint baseValue = skill.Value.CurrentLevel;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetSkillMod(skillId);
return EnchantmentMath.EnchantSkill(mod, baseValue);
}
/// <summary>
/// Issue #267 — vitae's isolated contribution to a skill's effective
/// level (retail <c>SkillInfoRegion::GetVitaeModifier</c> 0x004f0fa0),
/// separate from any buff delta. Always ≤ 0. Returns 0 when no spellbook
/// is wired, no vitae is active, or the skill hasn't arrived yet.
/// </summary>
public int GetSkillVitaeModifier(uint skillId)
{
if (_spellbook is null) return 0;
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return 0;
return EnchantmentMath.SkillVitaeModifier(
_spellbook.ActiveEnchantments,
skill.Value.CurrentLevel);
}
/// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;

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>

View file

@ -24,6 +24,7 @@ public sealed class Spellbook
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _attributeModCache = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _skillModCache = new();
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray();
@ -104,12 +105,40 @@ public sealed class Spellbook
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — combined vitae + skill-enchantment
/// buff modifier for a Skill id (ACE Skill enum ordinal — Run=24,
/// Jump=22), matching retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// (0x005947b0). Mirrors <see cref="GetVitalMod"/>'s caching shape;
/// consumed by <c>AcDream.Runtime.Gameplay.RuntimeCharacterState</c>'s
/// run/jump skill recompute.
/// Issue #267 — combined buff modifier for a primary attribute
/// (Strength/Endurance/Coordination/Quickness/Focus/Self; ACE
/// <c>PropertyAttribute</c> id 1-6). Retail
/// <c>CEnchantmentRegistry::EnchantAttribute</c> (0x00594570) never
/// references the vitae singleton, so this excludes vitae —
/// primary attributes are vitae-immune in retail. Feed the result
/// through <see cref="EnchantmentMath.EnchantAttribute"/> with the
/// base value to get the final displayed int.
/// </summary>
public EnchantmentMath.VitalMod GetAttributeMod(uint attributeId)
{
if (_attributeModCache.TryGetValue(
attributeId,
out EnchantmentMath.VitalMod cached))
{
return cached;
}
EnchantmentMath.VitalMod calculated = EnchantmentMath.GetMod(
ActiveEnchantments,
_table,
attributeId,
EnchantmentMath.EnchantmentTypeFlag.Attribute,
includeVitae: false);
_attributeModCache.Add(attributeId, calculated);
return calculated;
}
/// <summary>
/// Issue #267 — combined (vitae-inclusive) buff modifier for a skill
/// (ACE <c>SkillId</c>). Retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// (0x005947b0) applies the vitae singleton before the mult/add buff
/// lists. Feed the result through <see cref="EnchantmentMath.EnchantSkill"/>
/// with the base skill level to get the final displayed int.
/// </summary>
public EnchantmentMath.VitalMod GetSkillMod(uint skillId)
{
@ -380,6 +409,7 @@ public sealed class Spellbook
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
_vitalModCache.Clear();
_attributeModCache.Clear();
_skillModCache.Clear();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
{
@ -469,6 +499,7 @@ public sealed class Spellbook
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
_vitalModCache.Clear();
_attributeModCache.Clear();
_skillModCache.Clear();
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
_desiredComponents.Clear();
@ -488,6 +519,7 @@ public sealed class Spellbook
private void NotifyEnchantmentsChanged()
{
_vitalModCache.Clear();
_attributeModCache.Clear();
_skillModCache.Clear();
EnchantmentsChanged?.Invoke();
StateChanged?.Invoke();