fix(chargen): Campaign CC gate round 1 Batch F — Skills page buckets, selection, info box, cost text, arrow states

R2-4/review F1-F2 (gmCGSkillsPage): row click (and arrow click, matching
retail's own post-Increase/DecreaseSkillLevel re-select) now selects a
skill, highlights its row name, and writes the info panes' title (name +
score) and a level-gated bonus line; the description/formula halves stay
unported (SkillBase._description/_formula unreachable from this page's
current data surface, documented on RefreshInfoBox). The listbox's own
authored scrollbar link is wired to its Scroll model (live-DAT-confirmed
at 0x100003F8, matching the "+1 from the listbox" hypothesis). Cost text
now matches SetSkillText @0x00480600 exactly: Untrained's down-cost and
Specialized's up-cost are literal "0", unconditional, where the port
previously rendered blank; the 999-blank gate applies to the up-cost
only, never to a down-cost. Arrow Ghosted/Enabled state (0x1000001a/
0x1000001b) is now gated per branch, including bUntrainable/
bUnspecializable re-derived as "this row's own effective cost is
nonzero" — no new data needed since the page already resolves that cost.

R2-4b (the four-bucket sorted model) is NOT implemented — its Useable-
vs-Unuseable-Untrained split reads SkillBase.MinLevel, confirmed present
in the installed dat (SkillTable_MinLevelDistribution_NeverExceedsTrained)
but not threaded through ChargenOptions/ChargenHeritageOptions/
CharacterCreationRuntimeBindings. AP-213 row records the exact channel a
future fix needs. Also live-DAT-pinned: Templates[0]'s header-caption
child (0x100002f6) resolves as a UiButton, not UiText, in the real dat —
the same UIElement_Button-is-DynamicCast(0xc)-compatible-with-Text quirk
already ported for GF-4b's slider labels.

App suite (live-DAT env) 5321/3 -> 5328/3 (+7, zero regressions).
Runtime 1735/0 unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 14:18:13 +02:00
parent 2ad805469d
commit 8c30aa18ee
5 changed files with 615 additions and 33 deletions

File diff suppressed because one or more lines are too long

View file

@ -107,6 +107,36 @@ by construction (`SetBackdrop` throws if called without a reserved slot,
and the entity-list-assembly helper `BuildDrawEntities` degrades to and the entity-list-assembly helper `BuildDrawEntities` degrades to
exactly the main entity whenever no backdrop is configured/set). exactly the main entity whenever no backdrop is configured/set).
**Batch F (Skills page completion — R2-4 + review F1/F2) is CODE-COMPLETE
2026-08-16, pending the user's visual gate.** Four of R2-4's five
sub-items are fixed; R2-4b (the four-bucket sorted model) is NOT — see the
AP-213 register row for the exact missing data channel this batch's
investigation pinned down (`SkillBase.MinLevel`, confirmed present in the
installed dat but not threaded through `ChargenOptions`/
`CharacterCreationRuntimeBindings`). **Fixed:** R2-4a (row selection — a
row click, or an arrow click matching retail's own post-Increase/
DecreaseSkillLevel re-select, highlights the row and writes the info
panes' TITLE — name + score — and a level-gated bonus line; the
description/formula halves stay unported for the SAME missing-data reason
as R2-4b, documented on `CharacterCreationSkillsPage.RefreshInfoBox`'s own
doc rather than a new register row since no file outside the page's own
scope was needed to identify it); R2-4c (the listbox's own authored
scrollbar link, live-DAT-CONFIRMED at `0x100003F8` — exactly this batch's
own "+1 from the listbox" hypothesis — wired to the listbox's `Scroll`
model, the ordinary page-level linkage every other `UiTemplateListBox`
owner uses); review F1 (the Untrained-down/Specialized-up literal `"0"`
cost text the prior port rendered blank, and the exact per-branch 999-blank
gate — up-cost only, never down-cost); review F2 (the
`pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair, gated on
credits and a re-derived `bUntrainable`/`bUnspecializable` — the row's own
effective cost being nonzero — using cost data the page already resolves,
no new channel needed). Fixture + one live-DAT test this round (no
graphical client launch); App suite live-DAT env went from 5321/3 to
5328/3 (+7, zero regressions — one pre-existing baseline flake, the
streaming "injected dungeon enqueue failure" test, is a known standalone-
pass-only flake unrelated to this batch and did not reproduce on the full
post-fix run), Runtime 1735/0 unchanged.
User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session,
windowed). Screenshots: retail Heritage, acdream Heritage, retail windowed). Screenshots: retail Heritage, acdream Heritage, retail
Profession. The user's side-by-side retail reports are AXIOMS Profession. The user's side-by-side retail reports are AXIOMS

View file

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Numerics;
using AcDream.Core.CharGen; using AcDream.Core.CharGen;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -54,6 +55,59 @@ namespace AcDream.App.UI.Layout;
/// row's still-simplified flat-list-vs-four-bucket half is untouched and /// row's still-simplified flat-list-vs-four-bucket half is untouched and
/// stays registered). /// stays registered).
/// </para> /// </para>
///
/// <para>
/// <b>Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
/// F1/F2):</b> four of R2-4's five sub-items are fixed here; the
/// four-bucket sorted model (R2-4b) is NOT — see the batch report and the
/// AP-213 row for the exact missing data channel (retail's Useable-vs-
/// Unuseable-Untrained split reads <c>SkillBase.MinLevel</c>, which
/// <see cref="AcDream.Core.CharGen.ChargenOptions"/>/
/// <see cref="CharacterCreationRuntimeBindings"/> do not carry today).
/// <list type="bullet">
/// <item>R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel <c>SetSelectedItem(...,
/// 1)</c> re-select) now selects that skill — the row's NAME text swaps to
/// <see cref="SelectedNameColor"/> (best-derived "brighter white" per the
/// user's own report + the GF-11b precedent) and the info panes
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) get <c>ShowSkillsText
/// @0x00481250</c>'s title (name + score, <c>" (%d)\n"</c>) and bonus line
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — a
/// PARTIAL port: the description (<c>SkillBase._description</c>) and
/// <c>MakeSkillFormula @0x00480e10</c>'s computed formula text are not
/// reachable from this page's current data surface; see
/// <see cref="RefreshInfoBox"/>'s own doc.</item>
/// <item>R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (<see cref="AcDream.App.UI.UiTemplateListBox.ScrollbarElementId"/>, dat
/// property <c>0x72</c>) is now wired to
/// <see cref="AcDream.App.UI.UiTemplateListBox.Scroll"/> — the SAME
/// page-level <c>UiScrollbar.Model</c> linkage every other
/// <c>UiTemplateListBox</c> owner uses (no widget change).</item>
/// <item>Review F1 (cost text): <c>SetSkillText</c>'s Untrained down-cost
/// (<c>@0x00480877</c>) and Specialized up-cost (<c>@0x0048067f</c>) are
/// literal <c>"0"</c>, unconditional — the prior port rendered blank
/// (<c>null</c>) instead. The <c>&lt;0x3e7</c> (999) blank gate exists
/// ONLY on the up-cost at Untrained (<c>@0x00480819</c>) and Trained
/// (<c>@0x0048071f</c>); every down-cost write is unconditional
/// (<c>@0x00480877</c>/<c>@0x00480780</c>/<c>@0x004806c1</c>), including
/// Trained's raw <c>iTrainCost</c> even when it would exceed 999.</item>
/// <item>Review F2 (arrow states): <c>SetSkillText</c> ends every branch
/// driving <c>pSkillUpButton</c>/<c>pSkillDownButton</c> through its own
/// custom Ghosted/Enabled state pair (<see cref="ArrowGhostedStateId"/>/
/// <see cref="ArrowEnabledStateId"/> — raw ids via
/// <see cref="IUiDatStateful.TrySetRetailState"/>, the SAME "authored
/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
/// <c>remainingSkillCredits</c> 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 <c>bUntrainable</c>/
/// <c>bUnspecializable</c> — re-derived from <c>DoSkillRecords</c>'s own
/// tagSkillRecord build (<c>@0x00480e40</c>-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
/// <see cref="GetCosts"/> — no new data needed.</item>
/// </list>
/// </para>
/// </summary> /// </summary>
internal sealed class CharacterCreationSkillsPage : IDisposable internal sealed class CharacterCreationSkillsPage : IDisposable
{ {
@ -84,18 +138,44 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// case <c>0x10000305</c> fires <c>DecreaseSkillLevel</c>.</summary> /// case <c>0x10000305</c> fires <c>DecreaseSkillLevel</c>.</summary>
private const uint RowDownButtonId = 0x10000305u; private const uint RowDownButtonId = 0x10000305u;
/// <summary>Retail's own custom Ghosted state id for
/// <c>pSkillUpButton</c>/<c>pSkillDownButton</c> (<c>SetSkillText</c>'s
/// own <c>SetState(0x1000001a)</c> calls) — distinct from the standard
/// <c>UiButtonStateMachine.Ghosted</c> (13) numbering; the same
/// "authored custom pair, raw retail id" shape as GF-1's
/// Unselected/Selected (<c>0x10000016</c>/<c>0x10000017</c>).</summary>
private const uint ArrowGhostedStateId = 0x1000001Au;
/// <summary>Retail's own custom Enabled state id for the same two
/// buttons (<c>SetState(0x1000001b)</c>).</summary>
private const uint ArrowEnabledStateId = 0x1000001Bu;
/// <summary>R2-4a row-selection highlight: pure white. Re-derived from
/// the GF-11b precedent (list-caption color swap Normal
/// <c>(218,167,85)</c> -&gt; Highlight/white <c>(255,255,255)</c> 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 -&gt; brighter/whiter) is directly evidenced;
/// the exact target RGB is the best available derivation, not a live
/// measurement.</summary>
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// <summary>One built skill row: the resolved <c>Templates[1]</c> /// <summary>One built skill row: the resolved <c>Templates[1]</c>
/// subtree plus the child widgets <see cref="RefreshRowValues"/> needs /// subtree plus the child widgets <see cref="RefreshRowValues"/> needs
/// every tick, resolved once at build time rather than re-walked per /// every tick, resolved once at build time rather than re-walked per
/// refresh.</summary> /// refresh. <see cref="UnselectedNameColor"/> 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.</summary>
private readonly record struct SkillRow( private readonly record struct SkillRow(
UiElement Root, UiElement Root,
uint SkillId, uint SkillId,
UiText? NameText,
UiText? LevelText, UiText? LevelText,
UiText? UpCostText, UiText? UpCostText,
UiText? DownCostText, UiText? DownCostText,
UiButton? UpButton, UiButton? UpButton,
UiButton? DownButton); UiButton? DownButton,
Vector4 UnselectedNameColor);
private readonly CharacterCreationRuntimeBindings _bindings; private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list; private readonly UiTemplateListBox? _list;
@ -104,6 +184,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
private readonly UiText? _infoText; private readonly UiText? _infoText;
private readonly List<SkillRow> _rows = []; private readonly List<SkillRow> _rows = [];
private uint _lastHeritageId; private uint _lastHeritageId;
private uint? _selectedSkillId;
private bool _rowsBuilt; private bool _rowsBuilt;
private bool _disposed; private bool _disposed;
@ -116,6 +197,20 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox; _list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
if (_list is not null) if (_list is not null)
_list.TemplateResolver = templateResolver; _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 // Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw // (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
// dat CHILD of button 0x100003f9, not as a standalone descendant of // dat CHILD of button 0x100003f9, not as a standalone descendant of
@ -152,6 +247,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
foreach (SkillRow row in _rows) foreach (SkillRow row in _rows)
RefreshRowValues(row, view, snapshot); RefreshRowValues(row, view, snapshot);
RefreshInfoBox(view, snapshot);
if (_credits is { } credits) if (_credits is { } credits)
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
} }
@ -162,10 +259,16 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
{ {
if (row.UpButton is not null) row.UpButton.OnClick = null; if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null; if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRoot) datRoot.OnClick = null;
} }
_rows.Clear(); _rows.Clear();
_list?.Flush(); _list?.Flush();
// The skill list is rebuilding under a (possibly new) heritage —
// any previously selected skill id may no longer exist as a row.
_selectedSkillId = null;
ClearInfoBox();
if (_list is null if (_list is null
|| _list.Templates.Count < 2 || _list.Templates.Count < 2
|| _list.TemplateResolver is null || _list.TemplateResolver is null
@ -189,8 +292,14 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_list.AddPrebuiltRow(rowRoot); _list.AddPrebuiltRow(rowRoot);
if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText) UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); 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? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
@ -198,13 +307,33 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId; 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) if (upButton is not null)
upButton.OnClick = () => Advance(capturedSkillId); upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null) if (downButton is not null)
downButton.OnClick = () => Retreat(capturedSkillId); 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( _rows.Add(new SkillRow(
rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton)); rowRoot, skillId, nameText, levelText, upCostText, downCostText,
upButton, downButton, unselectedColor));
} }
} }
@ -220,28 +349,71 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
if (row.LevelText is { } levelText) if (row.LevelText is { } levelText)
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture)); SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
// SetSkillText @0x00480600's own per-state up/down cost pair: at // Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact
// Untrained, up=trainCost (down blank, nothing below Untrained); at // per-state cost text + arrow-enable pair — see this class's own
// Trained, up=(specCost-trainCost), down=trainCost; at Specialized, // header doc for the full byte trace of every address cited below.
// up=blank (nothing above Specialized), down=(specCost-trainCost). string upCostText;
// Retail also blanks a cost >= 999 (data_794320, an empty string downCostText;
// PStringBase) instead of showing the raw number. bool upEnabled;
(int? upCost, int? downCost) = level switch bool downEnabled;
switch (level)
{ {
ChargenSkillAdvancementClass.Specialized => case ChargenSkillAdvancementClass.Specialized:
((int?)null, (int?)(specializedCost - trainedCost)), // @0x0048067f: up = literal "0", unconditional (nothing
ChargenSkillAdvancementClass.Trained => // above Specialized). @0x004806c1: down = specCost-
((int?)(specializedCost - trainedCost), (int?)trainedCost), // trainCost, UNCONDITIONAL (no 999-blank gate).
_ => ((int?)trainedCost, (int?)null), // @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c +
}; // @0x004807f1/@0x004807f4: down arrow enabled iff
if (row.UpCostText is { } upCostText) // bUnspecializable — re-derived as specializedCost != 0
SetLine(upCostText, FormatCost(upCost)); // (a free/heritage-granted specialization, cost 0, locks
if (row.DownCostText is { } downCostText) // its own down arrow — DoSkillRecords zeroes
SetLine(downCostText, FormatCost(downCost)); // 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);
} }
private static string FormatCost(int? cost) => /// <summary>The up-cost-only 999 blank gate (<c>&lt; 0x3e7</c>,
cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty; /// <c>data_794320</c> — an empty <c>PStringBase</c>). Never applied to a
/// down-cost or a literal "0" write — see the per-branch citations in
/// <see cref="RefreshRowValues"/>.</summary>
private static string FormatGatedCost(int cost) =>
cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty;
private static void SetLine(UiText text, string content) => private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
@ -301,6 +473,92 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_bindings.UntrainSkill(skillId); _bindings.UntrainSkill(skillId);
} }
/// <summary>
/// R2-4a: row click / arrow click selection — the port's equivalent of
/// retail's listbox-level <c>SetSelectedItem</c> notification (see
/// <see cref="RebuildRows"/>'s own wiring doc). Applies the highlight
/// to every row (so the PREVIOUSLY selected row also gets restored to
/// its own <see cref="SkillRow.UnselectedNameColor"/>) and refreshes
/// the info panes for the newly selected skill. <see cref="_bindings"/>'
/// <c>View</c> is resolved fresh here, never cached, per
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>.
/// </summary>
private void SelectRow(uint skillId)
{
if (_disposed)
return;
_selectedSkillId = skillId;
foreach (SkillRow row in _rows)
{
if (row.NameText is { } nameText)
nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
}
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
}
/// <summary>
/// <c>gmCGSkillsPage::ShowSkillsText @0x00481250</c> — writes
/// <c>m_pInfoBoxTitle</c> (<c>0x100003fb</c>) and <c>m_pInfoBoxText</c>
/// (<c>0x100003fc</c>) for the currently selected skill, or clears both
/// when nothing is selected (retail's own <c>arg2==0</c>/lookup-miss
/// arms, both <c>UIElement_Text::ClearAllText</c>). Title is the skill
/// name plus its current score (<c>" (%d)\n"</c>, e.g. "Loyalty (5)").
/// Body is level-gated bonus text
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c> —
/// TWO spaces before the number, matching the compiled literal
/// verbatim) only.
///
/// <para>
/// <b>PARTIAL PORT — see the batch report:</b> retail's body ALSO
/// prepends the skill's DESCRIPTION (<c>SkillBase._description</c>,
/// read via <c>eax_2[7]</c> off the row's own cached
/// <c>tagSkillRecord</c>) and appends
/// <c>MakeSkillFormula @0x00480e10</c>'s computed "Formula : ..." text
/// (attribute names + weighted-formula arithmetic, sourced from
/// <c>SkillBase._formula</c>). Neither is reachable from this page's
/// current data surface: <see cref="AcDream.Core.CharGen.ChargenOptions"/>
/// carries per-skill COSTS only (never description/formula), and
/// <see cref="CharacterCreationRuntimeBindings"/> has no resolver for
/// either (unlike <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>,
/// which already exists for the score). Porting them needs a new
/// binding of that same shape, backed by the global SkillTable — out of
/// this file's edit contract for this batch.
/// </para>
/// </summary>
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,
};
SetLine(text, bonus);
}
}
private void ClearInfoBox()
{
if (_infoTitle is { } title) SetLine(title, string.Empty);
if (_infoText is { } text) SetLine(text, string.Empty);
}
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
@ -310,6 +568,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
{ {
if (row.UpButton is not null) row.UpButton.OnClick = null; if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null; if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRow) datRow.OnClick = null;
} }
_rows.Clear(); _rows.Clear();
_list?.Flush(); _list?.Flush();

View file

@ -1045,6 +1045,66 @@ public sealed class CharacterCreationLiveDatTests
Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId); Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId);
} }
/// <summary>
/// R2-4c (Campaign CC gate round 1 Batch F). Live-DAT probe: pins the
/// Skills listbox's own authored scrollbar link (dat property
/// <c>0x72</c>, <see cref="UiTemplateListBox.ScrollbarElementId"/>) —
/// CONFIRMED as <c>0x100003F8</c>, exactly the "+1 from the listbox"
/// hypothesis this batch's own investigation raised — and confirms a
/// real <see cref="UiScrollbar"/> resolves at that id under the Skills
/// page root, the exact fact
/// <see cref="CharacterCreationSkillsPage"/>'s constructor now wires
/// (<c>scrollbar.Model = list.Scroll</c>).
///
/// <para>
/// Also pins <c>Templates[0]</c>'s (<c>0x100002F4</c>) own header-
/// caption child id (<c>0x100002f6</c>, <c>DoSkillRecords @0x00481840</c>'s
/// own <c>GetChildRecursive</c> call) as prep evidence for whoever ports
/// the four-bucket sorted model (R2-4b, still open — see the AP-213
/// row): live-DAT-measured as a <see cref="UiButton"/>, NOT a
/// <see cref="UiText"/> — the SAME <c>UIElement_Button</c>-is-
/// <c>DynamicCast(0xc)</c>-compatible-with-<c>UIElement_Text</c> quirk
/// this campaign already ported for the six attribute-slider labels
/// (GF-4b) — retail's own <c>DynamicCast(0xc)</c> cast at
/// <c>0x00481855</c> would return null on a REAL Button object
/// otherwise, and the very next line unconditionally calls
/// <c>UIElement_Text::SetStringInfo</c> on it. A future port reads the
/// header caption through <c>UiButton.Label</c>, the same seam GF-4b
/// already established.
/// </para>
/// </summary>
[InstalledDatFact]
public void SkillsPage_Listbox_HasAScrollbarLink_AndHeaderTemplateHasACaptionChild()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement skillsRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(skillsRoot, 0x100003F7u));
Console.WriteLine(
$"[CC-Batch-F-DAT] Skills listbox ScrollbarElementId=0x{list.ScrollbarElementId:X8}");
Assert.Equal(0x100003F8u, list.ScrollbarElementId);
Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(skillsRoot, list.ScrollbarElementId));
UiTemplateListEntry headerTemplate = list.Templates[0];
Assert.Equal(0x100002F4u, headerTemplate.TemplateElementId);
UiElement? headerRow = LayoutImporter.Import(
dats,
headerTemplate.TemplateLayoutId,
headerTemplate.TemplateElementId,
_ => (0u, 0, 0),
null)?.Root;
UiElement realHeaderRow = Assert.IsAssignableFrom<UiElement>(headerRow);
Assert.IsType<UiButton>(UiElement.FindDescendant(realHeaderRow, 0x100002F6u));
}
/// <summary> /// <summary>
/// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed /// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// during the investigation: the Message dialog catalog's popup /// during the investigation: the Message dialog catalog's popup

View file

@ -24,6 +24,13 @@ public sealed class CharacterCreationUiControllerTests
private const uint SkillTrainOnly = 1u; private const uint SkillTrainOnly = 1u;
private const uint SkillSpecializable = 2u; private const uint SkillSpecializable = 2u;
/// <summary>Review fix round F2 (Batch F): a "free" skill (trained
/// cost 0 in Aluvian's own per-heritage cost list) — pins
/// <c>SetSkillText</c>'s <c>bUntrainable</c> re-derivation
/// (<c>trainedCost != 0</c>), which locks the down arrow at Trained for
/// exactly this shape.</summary>
private const uint SkillFreeTrained = 3u;
[Fact] [Fact]
public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage() public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage()
{ {
@ -329,8 +336,9 @@ public sealed class CharacterCreationUiControllerTests
.OnClick!(); .OnClick!();
IReadOnlyList<UiElement> rows = environment.SkillsList().ViewportForTest!.Children; IReadOnlyList<UiElement> rows = environment.SkillsList().ViewportForTest!.Children;
// Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2). // Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
Assert.Equal(2, rows.Count); // SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
Assert.Equal(3, rows.Count);
UiElement row = Assert.Single(rows, candidate => UiElement row = Assert.Single(rows, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
@ -341,11 +349,13 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level)); Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level));
// Default (never-touched) level: up cost = trained cost (2), down // Default (never-touched) level: up cost = trained cost (2), down
// cost blank (nothing below Untrained/Inactive). // cost = literal "0" (nothing below Untrained/Inactive, but
// SetSkillText @0x00480600's own Untrained branch writes a literal
// 0, unconditional — review fix round F1, Batch F).
UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u)); UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u)); UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("2", JoinedText(upCost)); Assert.Equal("2", JoinedText(upCost));
Assert.Equal(string.Empty, JoinedText(downCost)); Assert.Equal("0", JoinedText(downCost));
// Advancing to Trained flips the cost pair: up = specCost-trainCost // Advancing to Trained flips the cost pair: up = specCost-trainCost
// (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a // (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a
@ -361,6 +371,169 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal("2", JoinedText(downCost)); Assert.Equal("2", JoinedText(downCost));
} }
// ── Campaign CC gate round 1 Batch F: R2-4 + review F1/F2 ───────────
/// <summary>R2-4a: a plain row click selects the skill — the row's own
/// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter
/// white") and the info title
/// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix)
/// populates. Untrained/Inactive carries no bonus line, so the info
/// TEXT pane stays blank (the still-missing description/formula halves
/// — see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
/// doc).</summary>
[Fact]
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiDatElement row, UiText nameText) = environment.SkillRow(SkillTrainOnly);
Vector4 unselectedColor = nameText.DefaultColor;
// Nothing selected yet.
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoTitle()));
row.OnClick!();
Assert.Equal(Vector4.One, nameText.DefaultColor);
Assert.NotEqual(unselectedColor, nameText.DefaultColor);
// FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10.
string expectedTitle =
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText()));
}
/// <summary>R2-4a: retail re-selects the row after an arrow click too
/// (<c>ListenToElementMessage @0x004814c0</c>'s own
/// <c>SetSelectedItem(...,1)</c> call following
/// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks
/// the level-gated bonus line as the skill advances (the TWO-space
/// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
/// matching the compiled string verbatim).</summary>
[Fact]
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiButton up, _) = environment.SkillRowArrows(SkillTrainOnly);
up.OnClick!(); // Untrained/Inactive -> Trained.
BumpRevisionAndTick(environment);
Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText()));
up.OnClick!(); // Trained -> Specialized.
BumpRevisionAndTick(environment);
Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText()));
}
/// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own
/// authored (unselected) color instead of leaving it stuck
/// highlighted.</summary>
[Fact]
public void SkillsPage_RowClick_DeselectsPreviousRow_RestoresItsOwnColor()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiDatElement firstRow, UiText firstName) = environment.SkillRow(SkillTrainOnly);
Vector4 firstUnselected = firstName.DefaultColor;
(UiDatElement secondRow, UiText secondName) = environment.SkillRow(SkillSpecializable);
firstRow.OnClick!();
Assert.Equal(Vector4.One, firstName.DefaultColor);
secondRow.OnClick!();
Assert.Equal(Vector4.One, secondName.DefaultColor);
Assert.Equal(firstUnselected, firstName.DefaultColor);
}
/// <summary>Review fix round F1: <c>SetSkillText</c>'s Specialized
/// branch (<c>@0x00480679</c>) writes a literal <c>"0"</c> up-cost,
/// unconditional (nothing above Specialized needs the 999-blank gate),
/// and an UNCONDITIONAL down-cost — no gate even though this fixture's
/// value (4) happens to be well under 999.</summary>
[Fact]
public void SkillsPage_SpecializedCostText_UpCostIsLiteralZero_DownCostUnconditional()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
environment.Runtime.View.SetSkillLevel(SkillSpecializable, ChargenSkillAdvancementClass.Specialized);
BumpRevisionAndTick(environment);
(UiElement row, _) = environment.SkillRow(SkillSpecializable);
UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("0", JoinedText(upCost));
Assert.Equal("4", JoinedText(downCost)); // specCost(6) - trainCost(2).
}
/// <summary>Review fix round F2: the Up/Down arrow Ghosted
/// (<c>0x1000001a</c>)/Enabled (<c>0x1000001b</c>) state pair —
/// Untrained's Down is ALWAYS ghosted (nothing below it); Up is gated
/// on <c>remainingSkillCredits</c>; a "free" skill (trained cost 0)
/// locks its OWN down arrow at Trained
/// (<c>bUntrainable</c> re-derived as <c>trainedCost != 0</c>) and
/// unlocks it again once Specialized (specialized cost is non-zero),
/// where its own Up arrow is then ALWAYS ghosted (nothing above
/// Specialized).</summary>
[Fact]
public void SkillsPage_ArrowStates_GatedOnCreditsAndFreeSkillLocksDownArrow()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
// Untrained/Inactive: Down ALWAYS ghosted; Up enabled (credits 50
// >= trainedCost 2).
(UiButton up, UiButton down) = environment.SkillRowArrows(SkillTrainOnly);
Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
BumpRevisionAndTick(environment);
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
BumpRevisionAndTick(environment);
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
}
/// <summary>R2-4c: the listbox's own authored scrollbar link
/// (<see cref="UiTemplateListBox.ScrollbarElementId"/>) is wired to the
/// SAME listbox's <see cref="UiTemplateListBox.Scroll"/> model — the
/// ordinary page-level <c>UiScrollbar.Model</c> linkage, no widget
/// change.</summary>
[Fact]
public void SkillsPage_ListboxScrollbar_IsLinkedToTheListsOwnScroll()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
UiScrollbar scrollbar = environment.SkillsScrollbar();
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
}
[Fact] [Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex() public void TownButton_SelectsTheLiteralStartAreaIndex()
{ {
@ -1804,6 +1977,33 @@ public sealed class CharacterCreationUiControllerTests
return (up, down); return (up, down);
} }
/// <summary>R2-4a (Batch F): locates a built skill row's ROOT
/// element (<c>Templates[1]</c>'s own <c>UiDatElement</c>) by its
/// name text, for row-CLICK (not arrow-click) selection tests.
/// Also returns the row's own name <see cref="UiText"/> so a test
/// can assert its <see cref="UiText.DefaultColor"/> selection
/// highlight.</summary>
public (UiDatElement Row, UiText NameText) SkillRow(uint skillId)
{
string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId);
UiElement row = Assert.Single(
SkillsList().ViewportForTest!.Children,
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == skillName);
UiDatElement datRow = Assert.IsType<UiDatElement>(row);
UiText nameText = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000301u));
return (datRow, nameText);
}
public UiText SkillInfoTitle() =>
Assert.IsType<UiText>(Screen.FindElement(0x100003FBu));
public UiText SkillInfoText() =>
Assert.IsType<UiText>(Screen.FindElement(0x100003FCu));
public UiScrollbar SkillsScrollbar() =>
Assert.IsType<UiScrollbar>(Screen.FindElement(0x100003F8u));
public UiTemplateListBox SummaryListBox() => public UiTemplateListBox SummaryListBox() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
@ -2171,6 +2371,7 @@ public sealed class CharacterCreationUiControllerTests
{ {
[SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6), [SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6),
[SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6), [SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6),
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
}; };
var aluvian = new ChargenHeritageOptions( var aluvian = new ChargenHeritageOptions(
@ -2406,6 +2607,12 @@ public sealed class CharacterCreationUiControllerTests
Y = 40f, Y = 40f,
Width = 300f, Width = 300f,
Height = 320f, Height = 320f,
// R2-4c (Batch F): the listbox's own authored scrollbar link
// (dat property 0x72) — an arbitrary but plausible sibling id
// (retail's own "+1 from the listbox" convention, matching the
// hypothesis this batch's own investigation raised) since no
// live-DAT probe has pinned the real installed value yet.
ScrollbarElementId = 0x100003F8u,
}; };
// GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row // GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row
// (0x100002F4, unused by this port's flat-list simplification); // (0x100002F4, unused by this port's flat-list simplification);
@ -2415,6 +2622,7 @@ public sealed class CharacterCreationUiControllerTests
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu));
page.Children.Add(list); page.Children.Add(list);
page.Children.Add(ScrollbarInfo(0x100003F8u));
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu)); page.Children.Add(TextInfo(0x100003FBu));
page.Children.Add(TextInfo(0x100003FCu)); page.Children.Add(TextInfo(0x100003FCu));
@ -2552,6 +2760,24 @@ public sealed class CharacterCreationUiControllerTests
/// a bare button shape since this port's flat-list simplification never /// a bare button shape since this port's flat-list simplification never
/// resolves it. /// resolves it.
/// </summary> /// </summary>
/// <summary>Review fix round F2 (Batch F): retail's own custom
/// Ghosted(<c>0x1000001a</c>)/Enabled(<c>0x1000001b</c>) state pair for
/// <c>pSkillUpButton</c>/<c>pSkillDownButton</c> — mirrors
/// <c>CharacterCreationSkillsPage</c>'s own (private)
/// <c>ArrowGhostedStateId</c>/<c>ArrowEnabledStateId</c> consts so
/// <c>TrySetRetailState</c> has real, matching state descriptors to
/// resolve against (the raw-numeric-id lookup path,
/// <c>UiButton.TryFindState</c>) — the SAME "author arbitrary numeric
/// states directly" pattern <see cref="BuildAppearancePage"/> already
/// uses for its own custom pair.</summary>
private static ElementInfo ArrowButtonInfo(uint id)
{
ElementInfo info = ButtonInfo(id);
info.States[0x1000001Au] = new UiStateInfo { Id = 0x1000001Au, Name = "ArrowGhosted" };
info.States[0x1000001Bu] = new UiStateInfo { Id = 0x1000001Bu, Name = "ArrowEnabled" };
return info;
}
private static UiElement BuildSkillRowTemplate(uint templateElementId) private static UiElement BuildSkillRowTemplate(uint templateElementId)
{ {
if (templateElementId == 0x100002FFu) if (templateElementId == 0x100002FFu)
@ -2563,12 +2789,19 @@ public sealed class CharacterCreationUiControllerTests
Width = 280f, Width = 280f,
Height = 16f, Height = 16f,
}; };
// R2-4a (Batch F): an explicit, distinguishable unselected
// color (retail's own gold list-caption tone, the same
// 218,167,85 GF-11b measured) so selection tests can tell
// CharacterCreationSkillsPage.SelectedNameColor (pure white)
// apart from a row's own authored default.
ElementInfo nameInfo = TextInfo(0x10000301u);
nameInfo.FontColor = new System.Numerics.Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f);
row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop
row.Children.Add(TextInfo(0x10000301u)); // name row.Children.Add(nameInfo); // name
row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText
row.Children.Add(TextInfo(0x10000303u)); // pUpCostText row.Children.Add(TextInfo(0x10000303u)); // pUpCostText
row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton row.Children.Add(ArrowButtonInfo(0x10000304u)); // pSkillUpButton
row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton row.Children.Add(ArrowButtonInfo(0x10000305u)); // pSkillDownButton
row.Children.Add(TextInfo(0x10000306u)); // pDownCostText row.Children.Add(TextInfo(0x10000306u)); // pDownCostText
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
} }