using System.Globalization;
using System.Numerics;
using System.Text;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
///
/// The Skills page (gmCGSkillsPage, root 0x100003d3) — now
/// ported to retail's four-bucket sorted insertion model
/// (InsertEntrySorted/UpdateSkillEntry, Specialized/Trained/
/// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the
/// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph
/// below). Decomp
/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox
/// 0x100003f7, credits meter 0x100002f3 — imports as button
/// 0x100003f9's own consumed Label, see the ctor comment — info
/// panes 0x100003fb/0x100003fc),
/// gmCGSkillsPage::UpdateCreditsMeter
/// @ 0x004808f0 (credits display is the raw
/// remainingSkillCredits — no formula). The 16 skill ids uncostable
/// in BOTH the heritage's own list and the global SkillTable (retail's own
/// skills listbox never lists them either — CC1's
/// ChargenTableReaderInstalledDatTests) are filtered out via the
/// same two-tier presence check RuntimeCharacterCreationState's
/// TryGetSkillCost uses.
///
///
/// GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16):
/// RebuildRows used to require Templates[0]'s resolved root to
/// be a UiButton and treat its own Label as the row's whole content —
/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and
/// gmCGSkillsPage::DoSkillRecords @ 0x004817e0:
/// Templates[0] (0x100002F4, 3 children) is retail's own
/// bucket-HEADER row (Specialized/Trained/UseableUntrained/
/// UnuseableUntrained — unused by this port's flat-list simplification,
/// AP-213), and the REAL skill row is Templates[1]
/// (0x100002FF, a plain container root, 7 children). Byte-traced
/// through DoSkillRecords' own GetChildRecursive calls +
/// tagSkillRecord's copy-constructor field order
/// (acclient.h struct gmCGSkillsPage::tagSkillRecord):
/// 0x10000301 = the skill NAME (set once at row build, never
/// refreshed — retail has no per-refresh name write either),
/// 0x10000302 = pSkillLevelText (the numeric skill SCORE,
/// CharGenState::GetSkillScore), 0x10000303 =
/// pUpCostText, 0x10000306 = pDownCostText,
/// 0x10000304 = pSkillUpButton (fires
/// IncreaseSkillLevel on plain click,
/// ListenToElementMessage @0x004814c0 case 0x10000304),
/// 0x10000305 = pSkillDownButton (fires
/// DecreaseSkillLevel, same dispatcher's case 0x10000305).
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one
/// shared row — the row now wires exactly that, retiring AP-213's own
/// click-to-advance/double-click-retreat single-button substitution.
///
///
///
/// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
/// F1/F2): four of R2-4's five sub-items are fixed here; the
/// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph
/// below for where it lands.
///
/// - R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(...,
/// 1) re-select) now selects that skill — the row's NAME text swaps to
/// (best-derived "brighter white" per the
/// user's own report + the GF-11b precedent) and the info panes
/// (0x100003fb/0x100003fc) get ShowSkillsText
/// @0x00481250's title (name + score, " (%d)\n") and bonus line
/// ("Training Bonus +5"/"Specialization Bonus +10") — see the
/// closeout paragraph below for the description/formula completion.
/// - R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (, dat
/// property 0x72) is now wired to
/// — the SAME
/// page-level UiScrollbar.Model linkage every other
/// UiTemplateListBox owner uses (no widget change).
/// - Review F1 (cost text): SetSkillText's Untrained down-cost
/// (@0x00480877) and Specialized up-cost (@0x0048067f) are
/// literal "0", unconditional — the prior port rendered blank
/// (null) instead. The <0x3e7 (999) blank gate exists
/// ONLY on the up-cost at Untrained (@0x00480819) and Trained
/// (@0x0048071f); every down-cost write is unconditional
/// (@0x00480877/@0x00480780/@0x004806c1), including
/// Trained's raw iTrainCost even when it would exceed 999.
/// - Review F2 (arrow states): SetSkillText ends every branch
/// driving pSkillUpButton/pSkillDownButton through its own
/// custom Ghosted/Enabled state pair (/
/// — raw ids via
/// , the SAME "authored
/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
/// remainingSkillCredits vs. the advance cost and is ALWAYS ghosted
/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained
/// (nothing below it) and otherwise gated on bUntrainable/
/// bUnspecializable — re-derived from DoSkillRecords's own
/// tagSkillRecord build (@0x00480e40-region) as "the row's OWN
/// effective trained/specialized cost is nonzero" (a free/heritage-granted
/// skill or specialization locks its own down arrow), using the SAME
/// heritage-then-global cost this page already resolves via
/// — no new data needed.
///
///
///
///
/// Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213
/// CLOSED, R2-4b implemented:
/// threads SkillBase.MinLevel/Description/Formula from
/// the global SkillTable through
/// (Content's ChargenTableReader.Project populates it — these three
/// fields have NO per-heritage override in retail, unlike costs). Row
/// building now groups every costable skill into
/// (Specialized/Trained/UseableUntrained/UnuseableUntrained,
/// UpdateSkillEntry's own iMinlevel <= 1 useable-vs-
/// unuseable-untrained test) and sorts each bucket's rows alphabetically by
/// name (InsertEntrySorted's wcscmp compare, ported as
/// string.CompareOrdinal), inserting one Templates[0] header
/// row per bucket (caption child 0x100002f6, a
/// per the same UIElement_Button-is-DynamicCast(0xc)-compatible-
/// with-Text quirk GF-4b already used) ahead of that bucket's own
/// Templates[1] skill rows — matching DoSkillRecords's own
/// unconditional 4-header-then-populate build order exactly. Headers are
/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever
/// disappears just because it has zero rows this round). Advancing/
/// retreating a skill moves its row between buckets:
/// detects a bucket change per-row (cheap:
/// against each row's OWN cached ) rather than
/// reproducing retail's incremental InsertEntrySorted single-row
/// move — a full achieves the SAME observable
/// bucket/sort placement every tick a change is detected, with the current
/// selection explicitly preserved across that rebuild (unlike a heritage
/// change, which clears it, matching retail's own roster invalidation).
/// 's own doc covers the description/formula
/// completion.
///
///
internal sealed class CharacterCreationSkillsPage : IDisposable
{
/// Retail's four skill buckets, in DoSkillRecords's own
/// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained
/// — top to bottom in the listbox).
private enum SkillBucket
{
Specialized,
Trained,
UseableUntrained,
UnuseableUntrained,
}
/// Bucket header row string-table keys, in
/// order — DoSkillRecords'
/// compute_str_hash calls (ID_CharGen_Specialized etc.).
private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder =
[
(SkillBucket.Specialized, "ID_CharGen_Specialized"),
(SkillBucket.Trained, "ID_CharGen_Trained"),
(SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"),
(SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"),
];
/// UpdateSkillEntry @0x00480bf0's own bucket test:
/// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every
/// other value — Inactive is
/// unreachable for any row this page ever lists, since every listed
/// skill is costable and RuntimeCharacterCreationState.ResetSkillLevelsLocked
/// always seeds a costable skill's slot at Untrained-or-better, kept
/// here only for the same defensive completeness as retail's own
/// switch) split on iMinlevel <= 1.
private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch
{
ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized,
ChargenSkillAdvancementClass.Trained => SkillBucket.Trained,
_ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained,
};
/// Retail's own bucket-header caption child
/// (0x100002f6, live-DAT-measured as a —
/// the same UIElement_Button-is-Text-compatible quirk GF-4b
/// already ported).
private const uint HeaderCaptionElementId = 0x100002F6u;
/// Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — DoSkillRecords'
/// UIElement_Text::SetText(id_2, &var_138) at
/// 0x00481d5d runs OUTSIDE the per-refresh SetSkillText
/// call).
private const uint RowNameTextId = 0x10000301u;
/// tagSkillRecord::pSkillLevelText — the numeric skill
/// SCORE (SetSkillText @0x00480600's
/// CharGenState::GetSkillScore call, "%d" format).
private const uint RowLevelTextId = 0x10000302u;
/// tagSkillRecord::pUpCostText.
private const uint RowUpCostTextId = 0x10000303u;
/// tagSkillRecord::pDownCostText.
private const uint RowDownCostTextId = 0x10000306u;
/// tagSkillRecord::pSkillUpButton —
/// ListenToElementMessage's case 0x10000304 fires
/// IncreaseSkillLevel on a plain click (idMessage==1).
private const uint RowUpButtonId = 0x10000304u;
/// tagSkillRecord::pSkillDownButton — same dispatcher's
/// case 0x10000305 fires DecreaseSkillLevel.
private const uint RowDownButtonId = 0x10000305u;
/// Retail's own custom Ghosted state id for
/// pSkillUpButton/pSkillDownButton (SetSkillText's
/// own SetState(0x1000001a) calls) — distinct from the standard
/// UiButtonStateMachine.Ghosted (13) numbering; the same
/// "authored custom pair, raw retail id" shape as GF-1's
/// Unselected/Selected (0x10000016/0x10000017).
private const uint ArrowGhostedStateId = 0x1000001Au;
/// Retail's own custom Enabled state id for the same two
/// buttons (SetState(0x1000001b)).
private const uint ArrowEnabledStateId = 0x1000001Bu;
/// R2-4a row-selection highlight: pure white. Re-derived from
/// the GF-11b precedent (list-caption color swap Normal
/// (218,167,85) -> Highlight/white (255,255,255) on
/// selection) plus the user's own report ("retail selection turns the
/// row brighter white") absent a skills-row-specific cdb capture — the
/// direction (unselected -> brighter/whiter) is directly evidenced;
/// the exact target RGB is the best available derivation, not a live
/// measurement.
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// One built skill row: the resolved Templates[1]
/// subtree plus the child widgets needs
/// every tick, resolved once at build time rather than re-walked per
/// refresh. is the row's OWN authored
/// (DAT-default) name color, captured at build time so R2-4a's
/// selection highlight can restore it exactly on deselect.
/// is the bucket this row was LAST built into —
/// compares it against a fresh
/// call every tick to detect an
/// advance/retreat that needs a re-bucket.
private readonly record struct SkillRow(
UiElement Root,
uint SkillId,
SkillBucket Bucket,
UiText? NameText,
UiText? LevelText,
UiText? UpCostText,
UiText? DownCostText,
UiButton? UpButton,
UiButton? DownButton,
Vector4 UnselectedNameColor);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiButton? _credits;
private readonly UiText? _infoTitle;
private readonly UiText? _infoText;
private readonly List _rows = [];
private uint _lastHeritageId;
private uint? _selectedSkillId;
private bool _rowsBuilt;
private bool _disposed;
internal CharacterCreationSkillsPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
Func templateResolver)
{
_bindings = bindings;
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = templateResolver;
// R2-4c (Batch F): wire the listbox's own authored scrollbar (dat
// property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME
// page-level Model linkage every other UiTemplateListBox owner uses
// (ConfigOptionsPageController, SocialFriendsPageController, et
// al.) — no widget change, just resolving the id the importer
// already read and pointing its Model at this listbox's own Scroll.
if (_list is not null
&& _list.ScrollbarElementId != 0
&& UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar)
{
scrollbar.Model = _list.Scroll;
}
// Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
// dat CHILD of button 0x100003f9, not as a standalone descendant of
// the page root. UiButton.ConsumesDatChildren swallows it before it
// becomes an addressable widget (the same reason UiMeter's overlay
// text needed an explicit carve-out in LayoutImporter) — the
// faithful substitute is the button's own Label, which is exactly
// the mechanism our factory already uses to surface a consumed
// Type-12 child's text (register AD-103).
//
// GF-4a (Campaign CC gate round 1 Batch C): this used to clobber
// the button's authored "Available Skill Credits" caption (retail's
// own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the
// caption text — gmCGSkillsPage::InitializePage @0x00481e1c).
// DatWidgetFactory.BuildButton now surfaces that media-less Type-12
// child as UiButton.ValueLabel, coexisting with Label — see
// Refresh below.
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
// The two panes author no 0x15. The shared importer now applies
// retail's constructor default (raw 4 -> Top), so no page-local
// justification correction is needed.
// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
// own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT-
// measured) extends 20px PAST the bottom of the gold decorative
// frame that visually contains BOTH info panes (0x100003fa, Y=430
// H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family
// Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own
// ShowSkillsText @0x00481250 has NO code relationship between the
// text panes and this frame (SetText only; no clip/size handoff),
// and the frame's 8 children carry no dat property linking them to
// 0x100003fc either — so the frame's own geometry is the only
// authored ground truth for "the visible box," and this port's
// multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height))
// was using the WRONG (larger, unbounded) Height, letting a long
// skill's formula line draw into blank page space below the frame's
// own border instead of being contained by it. Clamped to the
// frame's own bottom edge (never grows it — additive, defensive if a
// future dat re-extract makes the frame taller than the pane).
// Scoped exactly like the VJustify.Top correction above: this is
// NOT the client-wide "does a text pane's clip account for a
// sibling decorative frame" mechanism (no evidence any other pane in
// this codebase has the SAME independently-authored-taller-than-its-
// frame shape), so a general import-time fix is unwarranted here.
if (_infoText is { } clampedInfoText
&& UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame)
{
float frameBottom = frame.Top + frame.Height;
float paneBottom = clampedInfoText.Top + clampedInfoText.Height;
if (frameBottom < paneBottom)
clampedInfoText.Height = frameBottom - clampedInfoText.Top;
}
}
///
/// The gold decorative frame (Type 12, 8 sprite children — the SAME
/// GF-12 corner/edge family) that visually contains BOTH info panes
/// (0x100003fb/0x100003fc) — see the R4-3 clamp above.
///
private const uint InfoBoxFrameElementId = 0x100003FAu;
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
bool heritageChanged = !_rowsBuilt || _lastHeritageId != snapshot.HeritageId;
// Group 2 closeout: an advance/retreat can move a row into a
// different bucket (UpdateSkillEntry's own re-bucket-on-level-
// change) — detect that cheaply against each row's own cached
// Bucket before paying for a full rebuild.
bool bucketsChanged = !heritageChanged && AnyRowBucketChanged(view);
if (heritageChanged || bucketsChanged)
{
// Only a HERITAGE change invalidates the current selection
// (retail's own roster-replace semantics) — a bucket move keeps
// the same skill selected, just relocated within the list.
uint? preservedSkillId = heritageChanged ? null : _selectedSkillId;
RebuildRows(view, snapshot.HeritageId);
_lastHeritageId = snapshot.HeritageId;
_rowsBuilt = true;
if (preservedSkillId is { } skillId)
{
foreach (SkillRow candidate in _rows)
{
if (candidate.SkillId != skillId)
continue;
_selectedSkillId = skillId;
ApplySelectionHighlight();
break;
}
}
}
foreach (SkillRow row in _rows)
RefreshRowValues(row, view, snapshot);
RefreshInfoBox(view, snapshot);
if (_credits is { } credits)
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
}
/// Group 2 closeout: true when any CURRENTLY BUILT row's live
/// bucket (recomputed from its skill's present level/MinLevel) no
/// longer matches the bucket it was last built into.
private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view)
{
foreach (SkillRow row in _rows)
{
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail)
? detail.MinLevel
: 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback.
if (ComputeBucket(level, minLevel) != row.Bucket)
return true;
}
return false;
}
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{
foreach (SkillRow row in _rows)
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRoot) datRoot.OnClick = null;
}
_rows.Clear();
_list?.Flush();
// The skill list is rebuilding — the caller (Refresh) decides
// whether to restore _selectedSkillId afterward (preserved across a
// bucket-move rebuild, cleared across a heritage change).
_selectedSkillId = null;
ClearInfoBox();
if (_list is null
|| _list.Templates.Count < 2
|| _list.TemplateResolver is null
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
return;
}
// Group 2 closeout: gather every costable skill's (id, name, bucket)
// first, group by bucket, sort each bucket alphabetically by name
// (InsertEntrySorted's own wcscmp compare), THEN build rows in
// DoSkillRecords' own header-then-rows-per-bucket order.
var byBucket = new Dictionary>(4)
{
[SkillBucket.Specialized] = [],
[SkillBucket.Trained] = [],
[SkillBucket.UseableUntrained] = [],
[SkillBucket.UnuseableUntrained] = [],
};
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (!IsCostable(heritage, view.Options, skillId))
continue;
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail)
? detail.MinLevel
: 1u;
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
byBucket[ComputeBucket(level, minLevel)].Add((skillId, name));
}
foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values)
bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
if (_list.Templates.Count < 1)
return;
UiTemplateListEntry headerTemplate = _list.Templates[0];
// Templates[1] (0x100002FF) is the REAL skill row — see this
// class's own doc comment for the full byte trace.
UiTemplateListEntry rowTemplate = _list.Templates[1];
foreach ((SkillBucket bucket, string stringKey) in BucketOrder)
{
BuildHeaderRow(headerTemplate, stringKey);
foreach ((uint skillId, _) in byBucket[bucket])
BuildSkillRow(rowTemplate, skillId, bucket);
}
}
/// Builds one Templates[0] bucket-header row and writes
/// its caption () from the string
/// table — DoSkillRecords's own unconditional 4-header build,
/// regardless of whether the bucket ends up with any rows.
private void BuildHeaderRow(UiTemplateListEntry template, string stringKey)
{
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot)
return;
_list.AddPrebuiltRow(headerRoot);
if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption
&& _bindings.ResolveText?.Invoke(stringKey) is { } text)
{
caption.Label = text;
}
}
private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket)
{
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot)
return;
_list.AddPrebuiltRow(rowRoot);
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
// Captured AFTER SetLine (which never touches DefaultColor —
// it's read lazily inside the LinesProvider closure) so this is
// the row's own DAT-authored default color, for R2-4a's
// selection highlight to restore on deselect.
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId;
// R2-4a: retail re-selects the row after an arrow click too
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
// call following IncreaseSkillLevel/DecreaseSkillLevel).
if (upButton is not null)
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null)
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
// R2-4a: the row-click equivalent of retail's listbox-level
// selection notification (idElement==0x100003f7 &&
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
// has no generic selection mechanism of its own (see its class
// doc), so this page opts the row in directly. Templates[1]
// (0x100002FF) resolves through DatWidgetFactory's Type-3
// (generic-container) fallback arm to UiDatElement, which
// already carries a page-opt-in OnClick/ClickThrough seam for
// exactly this — "generic decoration; behavioral widgets opt
// back in" (UiDatElement's own doc).
if (rowRoot is UiDatElement datRow)
{
datRow.ClickThrough = false;
datRow.OnClick = () => SelectRow(capturedSkillId);
}
_rows.Add(new SkillRow(
rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText,
upButton, downButton, unselectedColor));
}
private void RefreshRowValues(
SkillRow row,
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
(int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId);
uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u;
if (row.LevelText is { } levelText)
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
// Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact
// per-state cost text + arrow-enable pair — see this class's own
// header doc for the full byte trace of every address cited below.
string upCostText;
string downCostText;
bool upEnabled;
bool downEnabled;
switch (level)
{
case ChargenSkillAdvancementClass.Specialized:
// @0x0048067f: up = literal "0", unconditional (nothing
// above Specialized). @0x004806c1: down = specCost-
// trainCost, UNCONDITIONAL (no 999-blank gate).
// @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c +
// @0x004807f1/@0x004807f4: down arrow enabled iff
// bUnspecializable — re-derived as specializedCost != 0
// (a free/heritage-granted specialization, cost 0, locks
// its own down arrow — DoSkillRecords zeroes
// bUnspecializable exactly there, @0x00480e40 region).
upCostText = "0";
downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture);
upEnabled = false;
downEnabled = specializedCost != 0;
break;
case ChargenSkillAdvancementClass.Trained:
// @0x0048071f: up = specCost-trainCost, blank if >=999.
// @0x00480780: down = trainCost, UNCONDITIONAL (no gate,
// even past 999). @0x004807ce: up arrow enabled iff
// remainingSkillCredits >= specCost-trainCost.
// @0x004807ec + @0x004807f1/@0x004807f4: down arrow
// enabled iff bUntrainable — re-derived as trainedCost != 0
// (same free-skill-locks-the-down-arrow rule, mirrored on
// the trained cost).
upCostText = FormatGatedCost(specializedCost - trainedCost);
downCostText = trainedCost.ToString(CultureInfo.InvariantCulture);
upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost;
downEnabled = trainedCost != 0;
break;
default:
// Untrained/Inactive. @0x00480819: up = trainCost, blank if
// >=999. @0x00480877: down = literal "0", unconditional.
// @0x004808b3: down arrow ALWAYS ghosted (nothing below
// Untrained). @0x004808d1: up arrow enabled iff
// remainingSkillCredits >= trainCost.
upCostText = FormatGatedCost(trainedCost);
downCostText = "0";
upEnabled = snapshot.RemainingSkillCredits >= trainedCost;
downEnabled = false;
break;
}
if (row.UpCostText is { } upCostTextWidget)
SetLine(upCostTextWidget, upCostText);
if (row.DownCostText is { } downCostTextWidget)
SetLine(downCostTextWidget, downCostText);
row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
}
/// The up-cost-only 999 blank gate (< 0x3e7,
/// data_794320 — an empty PStringBase). Never applied to a
/// down-cost or a literal "0" write — see the per-branch citations in
/// .
private static string FormatGatedCost(int cost) =>
cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty;
private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
/// Same dictionary-presence gate as
/// RuntimeCharacterCreationState.TryGetSkillCost — heritage list
/// first, global SkillTable fallback.
private static bool IsCostable(
ChargenHeritageOptions heritage,
ChargenOptions options,
uint skillId) =>
heritage.SkillCostsBySkillId.ContainsKey(skillId)
|| options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
private static (int Trained, int Specialized) GetCosts(
IRuntimeCharacterCreationView view,
uint heritageId,
uint skillId)
{
if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
return (cost.NormalCost, cost.PrimaryCost);
}
if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global))
return (global.NormalCost, global.PrimaryCost);
return (0, 0);
}
/// pSkillUpButton click: IncreaseSkillLevel
/// @0x00480ca0 — Untrained/Inactive -> Trained,
/// Trained -> Specialized.
private void Advance(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.SpecializeSkill(skillId);
}
/// pSkillDownButton click: DecreaseSkillLevel
/// @0x00480d60 — Specialized -> Trained,
/// Trained -> Untrained.
private void Retreat(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level == ChargenSkillAdvancementClass.Specialized)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.UntrainSkill(skillId);
}
///
/// R2-4a: row click / arrow click selection — the port's equivalent of
/// retail's listbox-level SetSelectedItem notification (see
/// 's own wiring doc). Applies the highlight
/// to every row (so the PREVIOUSLY selected row also gets restored to
/// its own ) and refreshes
/// the info panes for the newly selected skill. '
/// View is resolved fresh here, never cached, per
/// feedback_resolve_deferred_funcs_per_call.md.
///
private void SelectRow(uint skillId)
{
if (_disposed)
return;
_selectedSkillId = skillId;
ApplySelectionHighlight();
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
}
/// Applies /
/// to every row's name text based on —
/// factored out of so can
/// re-apply it after a bucket-move rebuild restores a preserved
/// selection onto the NEW row objects (a rebuild discards the old ones,
/// so the highlight must be re-painted, not merely remembered).
private void ApplySelectionHighlight()
{
foreach (SkillRow row in _rows)
{
if (row.NameText is { } nameText)
nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
}
}
///
/// gmCGSkillsPage::ShowSkillsText @0x00481250 — writes
/// m_pInfoBoxTitle (0x100003fb) and m_pInfoBoxText
/// (0x100003fc) for the currently selected skill, or clears both
/// when nothing is selected (retail's own arg2==0/lookup-miss
/// arms, both UIElement_Text::ClearAllText). Title is the skill
/// name plus its current score (" (%d)\n", e.g. "Loyalty (5)").
/// Body is: DESCRIPTION, then level-gated bonus text
/// ("Training Bonus +5"/"Specialization Bonus +10" —
/// TWO spaces before the number, matching the compiled literal
/// verbatim), then 's "Formula : ..." line —
/// eax_2[7]/eax_2[8] off the row's cached
/// tagSkillRecord, byte-traced against tagSkillRecord's
/// own field order (acclient.h). Routed through
/// (escape-normalize + word-wrap, the
/// SAME composer the description pages use) since the description text
/// can run long enough to need wrapping in this box's width; composed
/// ONCE per call (not per-frame — this method itself only runs when
/// 's caller detects a revision change) and handed
/// to as a closed-over, already-built
/// list, matching the F11 no-per-frame-recompute discipline.
///
///
/// Group 2 closeout (Campaign CC gate round 1): DESCRIPTION is a
/// byte-verified port (SkillBase._description, read directly off
/// the DAT) and the ONLY segment routed through
/// 's word-wrap — description text can
/// run arbitrarily long, unlike the bonus/formula lines below. The bonus
/// line ("Training Bonus +5"/"Specialization Bonus +10",
/// TWO spaces before the number, matching the compiled literal
/// verbatim) and 's result are each added as
/// their OWN single, UNWRAPPED — deliberately
/// bypassing DatRichText.Compose for these two, since its
/// word-splitting wrap () collapses
/// consecutive spaces when it rejoins tokens, which would silently
/// mangle the bonus line's own authored double-space formatting (caught
/// by SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine
/// during this closeout — routing it through the wrapper the first time
/// produced "Training Bonus +5 ", single space, trailing artifact from
/// the wrapper's own newline-as-empty-paragraph handling). 's
/// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH
/// CONFIDENCE (every piece is a directly-read compiled string literal or
/// a field the DatReaderWriter binding already exposes by name); the
/// CONNECTOR text between a two-attribute formula's two terms is a
/// documented approximation (register AP-231) — see that method's own
/// doc.
///
///
private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
{
if (_selectedSkillId is not { } skillId)
{
ClearInfoBox();
return;
}
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u;
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
if (_infoTitle is { } title)
SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})");
if (_infoText is { } text)
{
string bonus = level switch
{
ChargenSkillAdvancementClass.Trained => "Training Bonus +5",
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
_ => string.Empty,
};
bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail);
var lines = new List();
if (hasDetail && !string.IsNullOrEmpty(detail.Description))
{
lines.AddRange(DatRichText.Compose(
text, [new DatRichText.Segment(detail.Description, text.DefaultColor)]));
}
if (bonus.Length > 0)
lines.Add(new UiText.Line(bonus, text.DefaultColor));
if (hasDetail)
lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor));
text.LinesProvider = () => lines;
}
}
///
/// gmCGSkillsPage::MakeSkillFormula @0x00480e10 — retail's
/// formula-text composition. HIGH CONFIDENCE (directly read from
/// compiled string literals plus the field layout
/// shares with
/// the DatReaderWriter binding's own SkillFormula struct): the
/// "Formula : " prefix, the per-attribute "(%u x %s)"-vs-
/// bare-name choice (a term's own multiplier > 1 gets the
/// parenthesized multiply form, else just the attribute's name — the
/// exact eax_6 <= 1/ebx_3 <= 1 gate), the
/// " / %u" divisor suffix (gated on Divisor != 1, the
/// exact __saved_ebp_11 != 1 gate), and the " +%u"
/// additive-bonus suffix (gated on AdditiveBonus != 0, the exact
/// __saved_ebp_12 != 0 gate).
///
///
/// LOWER CONFIDENCE, disclosed rather than silently guessed
/// (register AP-231): the connector text between a two-attribute
/// formula's own two terms. This port renders " + " — the
/// well-known "(Attr1 + Attr2) / N" shape most published AC skill
/// formulas use — but the decompiled function's own two candidate
/// connector literals (data_7a01a4, appended between the terms;
/// data_797584, appended again immediately after BOTH terms are
/// present) could not be recovered byte-exact by this session's
/// static-only tooling (no live cdb attach, no running Ghidra MCP
/// instance): both sit behind reference-counted PStringBase
/// appends whose actual wide-character content Binary Ninja's HLIL does
/// not surface as a literal, and the surrounding control flow (a
/// goto-based re-convergence between the single-attribute and
/// dual-attribute code paths) left data_797584's exact role
/// ambiguous enough that this port does NOT invent a second connector
/// for it — a two-attribute skill's formula therefore renders as
/// "Formula : (2 x Strength) + Endurance / 4 +2"-shaped text
/// that is very likely retail-correct in STRUCTURE but not yet
/// byte-verified against a live capture. Single-attribute formulas (the
/// majority of skills) are unaffected by this gap.
///
///
private static string ComposeFormula(ChargenSkillFormula formula)
{
bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0;
bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0;
var builder = new StringBuilder("Formula : ");
if (attribute1Active)
{
AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1);
if (attribute2Active)
builder.Append(" + ");
}
if (attribute2Active)
AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2);
if (formula.Divisor != 1)
builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}");
if (formula.AdditiveBonus != 0)
builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}");
return builder.ToString();
}
private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId)
{
string name = AttributeName((ChargenAttributeId)attributeId);
if (multiplier > 1)
builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})");
else
builder.Append(name);
}
/// Ports CharGenState::GetAttributeName @ 0x005C3A20
/// verbatim — retail hardcodes these six literals directly (not a
/// DAT/localization lookup). Duplicated locally from
/// CharacterCreationProfessionPage's own private copy rather than
/// extracted to a shared helper — six lines, two call sites, not worth
/// a new file for this closeout's scope.
private static string AttributeName(ChargenAttributeId id) => id switch
{
ChargenAttributeId.Strength => "Strength",
ChargenAttributeId.Endurance => "Endurance",
ChargenAttributeId.Quickness => "Quickness",
ChargenAttributeId.Coordination => "Coordination",
ChargenAttributeId.Focus => "Focus",
ChargenAttributeId.Self => "Self",
_ => string.Empty,
};
private void ClearInfoBox()
{
if (_infoTitle is { } title) SetLine(title, string.Empty);
if (_infoText is { } text) SetLine(text, string.Empty);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (SkillRow row in _rows)
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRow) datRow.OnClick = null;
}
_rows.Clear();
_list?.Flush();
if (_list is not null)
_list.TemplateResolver = null;
}
}