fix(ui): #267 character panel reflects vitae/buffed skills and attributes
Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd (0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the three enchantment-composition functions the Character window's Attributes and Skills tabs depend on. Primary attributes never reference the vitae singleton in retail (only Attribute2nd/Skill do) — confirmed directly from the decompiled function bodies, not assumed. EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default to the prior behavior) so a numeric StatMod key collision across domains (e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the wrong computation. Spellbook.GetAttributeMod/GetSkillMod and LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/ GetSkillVitaeModifier wire the retail chain through to the panel. CharacterSheetProvider now reports the effective value as the main number and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this also activates the previously-dead SkillValueColor buffed/debuffed row coloring). CharacterStatController's footer-title parenthetical is cited from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) + SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two segments (vitae's own contribution, then the buff-only residual), while vitae-immune attributes show at most one; no parenthetical when the delta is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only raw property/attribute updates. Core goldens cover the user-reported 33% vitae example (303->203, "(-100)" exactly), buff+vitae composition, and the attribute vitae-immunity finding. Provider/controller tests cover the full row-click -> footer-title path and live refresh. Full solution suite passes with zero failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f6275f4501
commit
cf2605fa4a
11 changed files with 1089 additions and 53 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 < 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) };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
@ -77,10 +107,22 @@ public static class EnchantmentMath
|
|||
/// <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)
|
||||
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
|
||||
|
|
@ -121,17 +163,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;
|
||||
if (includeVitae) 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;
|
||||
// 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;
|
||||
|
|
@ -139,14 +188,90 @@ 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
|
||||
: 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>
|
||||
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
|
||||
/// (verified against <c>docs/research/named-retail/acclient.h</c>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ 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();
|
||||
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
||||
|
|
@ -102,6 +104,61 @@ public sealed class Spellbook
|
|||
return calculated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
if (_skillModCache.TryGetValue(
|
||||
skillId,
|
||||
out EnchantmentMath.VitalMod cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
EnchantmentMath.VitalMod calculated = EnchantmentMath.GetMod(
|
||||
ActiveEnchantments,
|
||||
_table,
|
||||
skillId,
|
||||
EnchantmentMath.EnchantmentTypeFlag.Skill,
|
||||
includeVitae: true);
|
||||
_skillModCache.Add(skillId, calculated);
|
||||
return calculated;
|
||||
}
|
||||
|
||||
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
||||
public event Action<uint>? SpellLearned;
|
||||
|
||||
|
|
@ -356,6 +413,8 @@ public sealed class Spellbook
|
|||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
_vitalModCache.Clear();
|
||||
_attributeModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
UpsertManifestEnchantment(enchantment);
|
||||
|
||||
|
|
@ -430,6 +489,8 @@ public sealed class Spellbook
|
|||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
_vitalModCache.Clear();
|
||||
_attributeModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
|
||||
_desiredComponents.Clear();
|
||||
_spellbookFilters = 0x3FFFu;
|
||||
|
|
@ -448,6 +509,8 @@ public sealed class Spellbook
|
|||
private void NotifyEnchantmentsChanged()
|
||||
{
|
||||
_vitalModCache.Clear();
|
||||
_attributeModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue