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:
Erik 2026-07-30 18:08:55 +02:00
parent f6275f4501
commit cf2605fa4a
11 changed files with 1089 additions and 53 deletions

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) };
};
}