feat(player): port retail augmentation stat chain

This commit is contained in:
Erik 2026-07-31 08:08:23 +02:00
parent 0cb60d98a0
commit 461a1fb7b4
22 changed files with 953 additions and 138 deletions

View file

@ -86,6 +86,17 @@ public sealed class CharacterSheet
public int ManaCurrent { get; init; }
public int ManaMax { get; init; }
/// <summary>
/// Unenchanted max Health/Stamina/Mana in that order.
/// </summary>
public int[] VitalBaseMaxValues { get; init; } = Array.Empty<int>();
/// <summary>
/// Isolated vitae contribution to max Health/Stamina/Mana, always
/// non-positive and ordered like <see cref="VitalBaseMaxValues"/>.
/// </summary>
public int[] VitalVitaeModifiers { get; init; } = Array.Empty<int>();
// ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ────────────
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self.
@ -202,7 +213,8 @@ public sealed record CharacterSkill(
uint IconDid,
CharacterSkillAdvancementClass AdvancementClass,
int BaseLevel,
// Issue #267: CurrentLevel is now the EFFECTIVE (vitae + buff) level —
// BaseLevel is retail's pre-EnchantSkill value, including augmentation
// terms. CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). Previously an alias of
// BaseLevel; this activates the existing CharacterStatController.
// SkillValueColor buffed/debuffed row coloring.

View file

@ -140,6 +140,18 @@ public sealed class CharacterSheetProvider
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
VitalBaseMaxValues =
[
VitalBaseMax(LocalPlayerState.VitalKind.Health),
VitalBaseMax(LocalPlayerState.VitalKind.Stamina),
VitalBaseMax(LocalPlayerState.VitalKind.Mana),
],
VitalVitaeModifiers =
[
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Health),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Stamina),
_localPlayer.GetVitalVitaeModifier(LocalPlayerState.VitalKind.Mana),
],
// Issue #267: the panel's main attribute values are EFFECTIVE
// (post-buff) — retail CACQualities::EnchantAttribute. Base values
@ -176,7 +188,7 @@ public sealed class CharacterSheetProvider
AttrCurrent(LocalPlayerState.AttributeKind.Focus),
AttrCurrent(LocalPlayerState.AttributeKind.Self),
},
Skills = BuildLiveCharacterSkills(),
Skills = BuildLiveCharacterSkills(props),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u),
@ -344,7 +356,8 @@ public sealed class CharacterSheetProvider
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills(
PropertyBundle properties)
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
@ -374,23 +387,26 @@ public sealed class CharacterSheetProvider
// 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);
PlayerSkillMath.Value values =
_localPlayer.GetSkillValue(snapshot.SkillId, properties)
?? new PlayerSkillMath.Value(
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
0);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
effectiveLevel,
values.UnenchantedLevel,
values.EffectiveLevel,
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost,
vitaeModifier));
values.VitaeModifier));
}
return result;
@ -467,6 +483,11 @@ public sealed class CharacterSheetProvider
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
private int VitalBaseMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetBaseMaxApprox(kind) is { } max
? checked((int)Math.Min(int.MaxValue, max))
: 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>

View file

@ -110,7 +110,11 @@ public static class CharacterStatController
/// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f);
// LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f);
private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f);
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
// ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
@ -688,7 +692,8 @@ public static class CharacterStatController
_ => 0,
};
return v.ToString();
});
},
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
row.OnClick = () =>
{
@ -719,7 +724,8 @@ public static class CharacterStatController
2 => $"{s.ManaCurrent}/{s.ManaMax}",
_ => string.Empty,
};
});
},
valueColorProvider: () => VitalValueColor(data(), rowIndex));
row.OnClick = () =>
{
@ -872,10 +878,48 @@ public static class CharacterStatController
return null;
}
private static Vector4 SkillValueColor(CharacterSkill skill)
=> skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen
: skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f)
internal static Vector4 SkillValueColor(CharacterSkill skill)
{
int withoutVitae = skill.CurrentLevel - skill.VitaeModifier;
return withoutVitae > skill.BaseLevel ? RetailBuffGreen
: withoutVitae < skill.BaseLevel ? RetailDebuffRed
: Vector4.One;
}
internal static Vector4 AttributeValueColor(
CharacterSheet sheet,
int rowIndex)
{
int delta = GetAttributeDelta(sheet, rowIndex);
return delta > 0 ? RetailBuffGreen
: delta < 0 ? RetailDebuffRed
: Vector4.One;
}
internal static Vector4 VitalValueColor(
CharacterSheet sheet,
int vitalIndex)
{
if ((uint)vitalIndex >= 3u
|| vitalIndex >= sheet.VitalBaseMaxValues.Length
|| vitalIndex >= sheet.VitalVitaeModifiers.Length)
{
return Vector4.One;
}
int effective = vitalIndex switch
{
0 => sheet.HealthMax,
1 => sheet.StaminaMax,
2 => sheet.ManaMax,
_ => 0,
};
int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex];
int baseline = sheet.VitalBaseMaxValues[vitalIndex];
return withoutVitae > baseline ? RetailBuffGreen
: withoutVitae < baseline ? RetailDebuffRed
: Vector4.One;
}
/// <summary>
/// Handles a row click: toggle (same row → deselect), else select new row.
@ -1315,6 +1359,64 @@ public static class CharacterStatController
return $"{name}: {value}{delta}";
}
private static IReadOnlyList<UiText.TextRun> BuildSelectedTitleRuns(
UiText target,
CharacterStatTab tab,
Func<CharacterSheet> data,
int[] attrSel,
int[] skillSel)
{
Vector4 Color(int index) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: index switch
{
1 => RetailBuffGreen,
2 => RetailDebuffRed,
3 => RetailVitaeBlue,
_ => Vector4.One,
};
if (tab == CharacterStatTab.Skills)
{
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return [new("Select a Skill to Improve", Body)];
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
return [new(skill.Name, Color(0))];
var runs = new List<UiText.TextRun>
{
new($"{skill.Name}: {skill.CurrentLevel}", Color(0)),
};
if (skill.VitaeModifier < 0)
runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3)));
int buffDelta = GetSkillBuffOnlyDelta(skill);
if (buffDelta != 0)
runs.Add(new(
FormatBuffDelta(buffDelta),
Color(buffDelta > 0 ? 1 : 2)));
return runs;
}
if (attrSel[0] < 0)
return [new("Select an Attribute to Improve", Body)];
CharacterSheet sheet = data();
var attributeRuns = new List<UiText.TextRun>
{
new(
$"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}",
Color(0)),
};
int delta = GetAttributeDelta(sheet, attrSel[0]);
if (delta != 0)
attributeRuns.Add(new(
FormatBuffDelta(delta),
Color(delta > 0 ? 1 : 2)));
return attributeRuns;
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
@ -1512,6 +1614,12 @@ public static class CharacterStatController
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
// RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true;
titleEl.RunsProvider = () => BuildSelectedTitleRuns(
titleEl,
activeTab[0],
data,
attrSel,
skillSel);
titleEl.LinesProvider = () =>
{
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);

View file

@ -32,6 +32,11 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
/// <summary>
/// One inline fragment in a retail <c>AppendTextWithFont</c> line.
/// </summary>
public readonly record struct TextRun(string Text, Vector4 Color);
/// <summary>A caret position: a line index into the cached line list plus a
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col);
@ -39,6 +44,13 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>
/// Optional inline fragments for a static one-line element. When present
/// this reproduces retail's per-append font-state colors while preserving
/// the element's authored alignment as one composed line.
/// </summary>
public Func<IReadOnlyList<TextRun>>? RunsProvider { get; set; }
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
@ -381,6 +393,12 @@ public sealed class UiText : UiElement, IUiDatStateful
private void DrawClippedText(UiRenderContext ctx)
{
if (OneLine && RunsProvider is { } runsProvider)
{
DrawSingleLineRuns(ctx, runsProvider());
return;
}
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
@ -533,6 +551,54 @@ public sealed class UiText : UiElement, IUiDatStateful
}
}
private void DrawSingleLineRuns(
UiRenderContext ctx,
IReadOnlyList<TextRun> runs)
{
if (runs.Count == 0) return;
UiDatFont? datFont = DatFont;
BitmapFont? bitmapFont = datFont is null
? Font ?? ctx.DefaultFont
: null;
if (datFont is null && bitmapFont is null) return;
float totalWidth = 0f;
foreach (TextRun run in runs)
{
totalWidth += datFont is not null
? datFont.MeasureWidth(run.Text)
: bitmapFont!.MeasureWidth(run.Text);
}
float x = Centered
? Math.Max(Padding, (Width - totalWidth) * 0.5f)
: RightAligned
? Math.Max(Padding, Width - Padding - totalWidth)
: Padding;
float lineHeight = datFont?.LineHeight ?? bitmapFont!.LineHeight;
float y = VOffset(
Height,
lineHeight,
Padding,
VerticalJustify);
foreach (TextRun run in runs)
{
if (run.Text.Length == 0) continue;
if (datFont is not null)
{
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color);
x += datFont.MeasureWidth(run.Text);
}
else
{
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
x += bitmapFont!.MeasureWidth(run.Text);
}
}
}
/// <summary>
/// True when any vertical portion of a line intersects a text viewport. Retail
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.