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:
parent
2ad805469d
commit
8c30aa18ee
5 changed files with 615 additions and 33 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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><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> -> 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 -> 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>< 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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue