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