fix(chargen): Campaign CC gate round 1 closeout — Group 2: Skills page four-bucket model

Ports the last remaining half of retail's Skills page: the four-bucket
sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained,
UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's
description + formula completion.

- ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/
  Description/Formula from the global SkillTable, exposed via a new
  ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so
  every pre-existing ChargenOptions call site compiles unchanged).
  ChargenTableReader.Project populates it from the same SkillTable loop
  that already builds GlobalSkillCostsBySkillId.
- CharacterCreationSkillsPage.RebuildRows now groups every costable skill
  into SkillBucket, sorts each bucket alphabetically by name
  (InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and
  builds one Templates[0] header row per bucket ahead of that bucket's
  Templates[1] skill rows — DoSkillRecords' own unconditional
  4-header-then-populate order. A level change re-buckets the row
  (detected per-refresh against each row's own cached bucket, then a
  full rebuild with the current selection explicitly preserved).
- RefreshInfoBox now composes description (word-wrapped via
  DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped
  literal — NOT routed through word-wrap, which would have collapsed its
  authored double-space formatting) + ComposeFormula's "Formula : ..."
  line (MakeSkillFormula ported with high confidence for the prefix/
  per-attribute-term/divisor/bonus-suffix shape; the two-attribute
  connector text is a disclosed approximation, register AP-231, since
  the decompiled function's own connector literals could not be
  recovered byte-exact by this session's static-only tooling).

Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed
SkillTable's MinLevel distribution matches the investigation's own
recorded finding exactly (38 entries, 23 useable-untrained / 15
trained-required). 3 new fixture tests + 1 new live-DAT test; 3
pre-existing integration tests fixed (they captured row widget
references before a bucket-changing click, which now rebuilds and
discards those references — a real, correct consequence of the new
model, not a bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 15:33:39 +02:00
parent e1d7d09599
commit 0fed5fdd91
6 changed files with 712 additions and 121 deletions

View file

@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Text;
using AcDream.Core.CharGen; using AcDream.Core.CharGen;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -7,11 +8,12 @@ using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
/// <summary> /// <summary>
/// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) — /// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) — now
/// simplified to one flat listbox rather than retail's four-bucket sorted /// ported to retail's four-bucket sorted insertion model
/// insertion model (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>, /// (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>, Specialized/Trained/
/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register /// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the
/// AP-213). Decomp /// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph
/// below). Decomp
/// anchors: <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c> (listbox /// anchors: <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c> (listbox
/// <c>0x100003f7</c>, credits meter <c>0x100002f3</c> — imports as button /// <c>0x100003f7</c>, credits meter <c>0x100002f3</c> — imports as button
/// <c>0x100003f9</c>'s own consumed Label, see the ctor comment — info /// <c>0x100003f9</c>'s own consumed Label, see the ctor comment — info
@ -51,19 +53,14 @@ namespace AcDream.App.UI.Layout;
/// <c>DecreaseSkillLevel</c>, same dispatcher's case <c>0x10000305</c>). /// <c>DecreaseSkillLevel</c>, same dispatcher's case <c>0x10000305</c>).
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one /// Both buttons fire on a PLAIN click, not click-vs-double-click on one
/// shared row — the row now wires exactly that, retiring AP-213's own /// shared row — the row now wires exactly that, retiring AP-213's own
/// click-to-advance/double-click-retreat single-button substitution (the /// click-to-advance/double-click-retreat single-button substitution.
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
/// stays registered).
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review /// <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 /// 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 /// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph
/// AP-213 row for the exact missing data channel (retail's Useable-vs- /// below for where it lands.
/// 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"> /// <list type="bullet">
/// <item>R2-4a (row selection): a row click (or an arrow click, matching /// <item>R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel <c>SetSelectedItem(..., /// retail's own post-Increase/DecreaseSkillLevel <c>SetSelectedItem(...,
@ -72,11 +69,8 @@ namespace AcDream.App.UI.Layout;
/// user's own report + the GF-11b precedent) and the info panes /// user's own report + the GF-11b precedent) and the info panes
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) get <c>ShowSkillsText /// (<c>0x100003fb</c>/<c>0x100003fc</c>) get <c>ShowSkillsText
/// @0x00481250</c>'s title (name + score, <c>" (%d)\n"</c>) and bonus line /// @0x00481250</c>'s title (name + score, <c>" (%d)\n"</c>) and bonus line
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — a /// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — see the
/// PARTIAL port: the description (<c>SkillBase._description</c>) and /// closeout paragraph below for the description/formula completion.</item>
/// <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 /// <item>R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (<see cref="AcDream.App.UI.UiTemplateListBox.ScrollbarElementId"/>, dat /// (<see cref="AcDream.App.UI.UiTemplateListBox.ScrollbarElementId"/>, dat
/// property <c>0x72</c>) is now wired to /// property <c>0x72</c>) is now wired to
@ -108,9 +102,84 @@ namespace AcDream.App.UI.Layout;
/// <see cref="GetCosts"/> — no new data needed.</item> /// <see cref="GetCosts"/> — no new data needed.</item>
/// </list> /// </list>
/// </para> /// </para>
///
/// <para>
/// <b>Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213
/// CLOSED, R2-4b implemented:</b> <see cref="AcDream.Core.CharGen.ChargenSkillDetail"/>
/// threads <c>SkillBase.MinLevel</c>/<c>Description</c>/<c>Formula</c> from
/// the global SkillTable through <see cref="AcDream.Core.CharGen.ChargenOptions.TryGetSkillDetail"/>
/// (Content's <c>ChargenTableReader.Project</c> populates it — these three
/// fields have NO per-heritage override in retail, unlike costs). Row
/// building now groups every costable skill into <see cref="SkillBucket"/>
/// (Specialized/Trained/UseableUntrained/UnuseableUntrained,
/// <c>UpdateSkillEntry</c>'s own <c>iMinlevel &lt;= 1</c> useable-vs-
/// unuseable-untrained test) and sorts each bucket's rows alphabetically by
/// name (<c>InsertEntrySorted</c>'s <c>wcscmp</c> compare, ported as
/// <c>string.CompareOrdinal</c>), inserting one <c>Templates[0]</c> header
/// row per bucket (caption child <c>0x100002f6</c>, a <see cref="UiButton"/>
/// per the same <c>UIElement_Button</c>-is-<c>DynamicCast(0xc)</c>-compatible-
/// with-Text quirk GF-4b already used) ahead of that bucket's own
/// <c>Templates[1]</c> skill rows — matching <c>DoSkillRecords</c>'s own
/// unconditional 4-header-then-populate build order exactly. Headers are
/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever
/// disappears just because it has zero rows this round). Advancing/
/// retreating a skill moves its row between buckets: <see cref="Refresh"/>
/// detects a bucket change per-row (cheap: <see cref="ComputeBucket"/>
/// against each row's OWN cached <see cref="SkillRow.Bucket"/>) rather than
/// reproducing retail's incremental <c>InsertEntrySorted</c> single-row
/// move — a full <see cref="RebuildRows"/> achieves the SAME observable
/// bucket/sort placement every tick a change is detected, with the current
/// selection explicitly preserved across that rebuild (unlike a heritage
/// change, which clears it, matching retail's own roster invalidation).
/// <see cref="RefreshInfoBox"/>'s own doc covers the description/formula
/// completion.
/// </para>
/// </summary> /// </summary>
internal sealed class CharacterCreationSkillsPage : IDisposable internal sealed class CharacterCreationSkillsPage : IDisposable
{ {
/// <summary>Retail's four skill buckets, in <c>DoSkillRecords</c>'s own
/// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained
/// — top to bottom in the listbox).</summary>
private enum SkillBucket
{
Specialized,
Trained,
UseableUntrained,
UnuseableUntrained,
}
/// <summary>Bucket header row string-table keys, in
/// <see cref="SkillBucket"/> order — <c>DoSkillRecords</c>'
/// <c>compute_str_hash</c> calls (<c>ID_CharGen_Specialized</c> etc.).</summary>
private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder =
[
(SkillBucket.Specialized, "ID_CharGen_Specialized"),
(SkillBucket.Trained, "ID_CharGen_Trained"),
(SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"),
(SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"),
];
/// <summary><c>UpdateSkillEntry @0x00480bf0</c>'s own bucket test:
/// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every
/// other <see cref="ChargenSkillAdvancementClass"/> value — Inactive is
/// unreachable for any row this page ever lists, since every listed
/// skill is costable and <c>RuntimeCharacterCreationState.ResetSkillLevelsLocked</c>
/// always seeds a costable skill's slot at Untrained-or-better, kept
/// here only for the same defensive completeness as retail's own
/// switch) split on <c>iMinlevel &lt;= 1</c>.</summary>
private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch
{
ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized,
ChargenSkillAdvancementClass.Trained => SkillBucket.Trained,
_ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained,
};
/// <summary>Retail's own bucket-header caption child
/// (<c>0x100002f6</c>, live-DAT-measured as a <see cref="UiButton"/> —
/// the same <c>UIElement_Button</c>-is-Text-compatible quirk GF-4b
/// already ported).</summary>
private const uint HeaderCaptionElementId = 0x100002F6u;
/// <summary>Retail's own row-name id (set once at row build; retail /// <summary>Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — <c>DoSkillRecords</c>' /// never re-writes it on refresh either — <c>DoSkillRecords</c>'
/// <c>UIElement_Text::SetText(id_2, &amp;var_138)</c> at /// <c>UIElement_Text::SetText(id_2, &amp;var_138)</c> at
@ -165,10 +234,15 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// 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. <see cref="UnselectedNameColor"/> is the row's OWN authored /// refresh. <see cref="UnselectedNameColor"/> is the row's OWN authored
/// (DAT-default) name color, captured at build time so R2-4a's /// (DAT-default) name color, captured at build time so R2-4a's
/// selection highlight can restore it exactly on deselect.</summary> /// selection highlight can restore it exactly on deselect.
/// <see cref="Bucket"/> is the bucket this row was LAST built into —
/// <see cref="Refresh"/> compares it against a fresh
/// <see cref="ComputeBucket"/> call every tick to detect an
/// advance/retreat that needs a re-bucket.</summary>
private readonly record struct SkillRow( private readonly record struct SkillRow(
UiElement Root, UiElement Root,
uint SkillId, uint SkillId,
SkillBucket Bucket,
UiText? NameText, UiText? NameText,
UiText? LevelText, UiText? LevelText,
UiText? UpCostText, UiText? UpCostText,
@ -237,11 +311,32 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
IRuntimeCharacterCreationView view, IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot) RuntimeCharacterCreationSnapshot snapshot)
{ {
if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId) bool heritageChanged = !_rowsBuilt || _lastHeritageId != snapshot.HeritageId;
// Group 2 closeout: an advance/retreat can move a row into a
// different bucket (UpdateSkillEntry's own re-bucket-on-level-
// change) — detect that cheaply against each row's own cached
// Bucket before paying for a full rebuild.
bool bucketsChanged = !heritageChanged && AnyRowBucketChanged(view);
if (heritageChanged || bucketsChanged)
{ {
// Only a HERITAGE change invalidates the current selection
// (retail's own roster-replace semantics) — a bucket move keeps
// the same skill selected, just relocated within the list.
uint? preservedSkillId = heritageChanged ? null : _selectedSkillId;
RebuildRows(view, snapshot.HeritageId); RebuildRows(view, snapshot.HeritageId);
_lastHeritageId = snapshot.HeritageId; _lastHeritageId = snapshot.HeritageId;
_rowsBuilt = true; _rowsBuilt = true;
if (preservedSkillId is { } skillId)
{
foreach (SkillRow candidate in _rows)
{
if (candidate.SkillId != skillId)
continue;
_selectedSkillId = skillId;
ApplySelectionHighlight();
break;
}
}
} }
foreach (SkillRow row in _rows) foreach (SkillRow row in _rows)
@ -253,6 +348,23 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
} }
/// <summary>Group 2 closeout: true when any CURRENTLY BUILT row's live
/// bucket (recomputed from its skill's present level/MinLevel) no
/// longer matches the bucket it was last built into.</summary>
private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view)
{
foreach (SkillRow row in _rows)
{
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail)
? detail.MinLevel
: 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback.
if (ComputeBucket(level, minLevel) != row.Bucket)
return true;
}
return false;
}
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{ {
foreach (SkillRow row in _rows) foreach (SkillRow row in _rows)
@ -264,8 +376,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_rows.Clear(); _rows.Clear();
_list?.Flush(); _list?.Flush();
// The skill list is rebuilding under a (possibly new) heritage — // The skill list is rebuilding — the caller (Refresh) decides
// any previously selected skill id may no longer exist as a row. // whether to restore _selectedSkillId afterward (preserved across a
// bucket-move rebuild, cleared across a heritage change).
_selectedSkillId = null; _selectedSkillId = null;
ClearInfoBox(); ClearInfoBox();
@ -277,64 +390,111 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
return; return;
} }
// Templates[1] (0x100002FF) is the REAL skill row — see this // Group 2 closeout: gather every costable skill's (id, name, bucket)
// class's own doc comment for the full byte trace. // first, group by bucket, sort each bucket alphabetically by name
UiTemplateListEntry template = _list.Templates[1]; // (InsertEntrySorted's own wcscmp compare), THEN build rows in
// DoSkillRecords' own header-then-rows-per-bucket order.
var byBucket = new Dictionary<SkillBucket, List<(uint SkillId, string Name)>>(4)
{
[SkillBucket.Specialized] = [],
[SkillBucket.Trained] = [],
[SkillBucket.UseableUntrained] = [],
[SkillBucket.UnuseableUntrained] = [],
};
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{ {
if (!IsCostable(heritage, view.Options, skillId)) if (!IsCostable(heritage, view.Options, skillId))
continue; continue;
if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
is not { } rowRoot) uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail)
{ ? detail.MinLevel
continue; : 1u;
} string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
byBucket[ComputeBucket(level, minLevel)].Add((skillId, name));
_list.AddPrebuiltRow(rowRoot);
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
// Captured AFTER SetLine (which never touches DefaultColor —
// it's read lazily inside the LinesProvider closure) so this is
// the row's own DAT-authored default color, for R2-4a's
// selection highlight to restore on deselect.
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId;
// R2-4a: retail re-selects the row after an arrow click too
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
// call following IncreaseSkillLevel/DecreaseSkillLevel).
if (upButton is not null)
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null)
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
// R2-4a: the row-click equivalent of retail's listbox-level
// selection notification (idElement==0x100003f7 &&
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
// has no generic selection mechanism of its own (see its class
// doc), so this page opts the row in directly. Templates[1]
// (0x100002FF) resolves through DatWidgetFactory's Type-3
// (generic-container) fallback arm to UiDatElement, which
// already carries a page-opt-in OnClick/ClickThrough seam for
// exactly this — "generic decoration; behavioral widgets opt
// back in" (UiDatElement's own doc).
if (rowRoot is UiDatElement datRow)
{
datRow.ClickThrough = false;
datRow.OnClick = () => SelectRow(capturedSkillId);
}
_rows.Add(new SkillRow(
rowRoot, skillId, nameText, levelText, upCostText, downCostText,
upButton, downButton, unselectedColor));
} }
foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values)
bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
if (_list.Templates.Count < 1)
return;
UiTemplateListEntry headerTemplate = _list.Templates[0];
// Templates[1] (0x100002FF) is the REAL skill row — see this
// class's own doc comment for the full byte trace.
UiTemplateListEntry rowTemplate = _list.Templates[1];
foreach ((SkillBucket bucket, string stringKey) in BucketOrder)
{
BuildHeaderRow(headerTemplate, stringKey);
foreach ((uint skillId, _) in byBucket[bucket])
BuildSkillRow(rowTemplate, skillId, bucket);
}
}
/// <summary>Builds one <c>Templates[0]</c> bucket-header row and writes
/// its caption (<see cref="HeaderCaptionElementId"/>) from the string
/// table — <c>DoSkillRecords</c>'s own unconditional 4-header build,
/// regardless of whether the bucket ends up with any rows.</summary>
private void BuildHeaderRow(UiTemplateListEntry template, string stringKey)
{
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot)
return;
_list.AddPrebuiltRow(headerRoot);
if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption
&& _bindings.ResolveText?.Invoke(stringKey) is { } text)
{
caption.Label = text;
}
}
private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket)
{
if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot)
return;
_list.AddPrebuiltRow(rowRoot);
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
// Captured AFTER SetLine (which never touches DefaultColor —
// it's read lazily inside the LinesProvider closure) so this is
// the row's own DAT-authored default color, for R2-4a's
// selection highlight to restore on deselect.
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId;
// R2-4a: retail re-selects the row after an arrow click too
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
// call following IncreaseSkillLevel/DecreaseSkillLevel).
if (upButton is not null)
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null)
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
// R2-4a: the row-click equivalent of retail's listbox-level
// selection notification (idElement==0x100003f7 &&
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
// has no generic selection mechanism of its own (see its class
// doc), so this page opts the row in directly. Templates[1]
// (0x100002FF) resolves through DatWidgetFactory's Type-3
// (generic-container) fallback arm to UiDatElement, which
// already carries a page-opt-in OnClick/ClickThrough seam for
// exactly this — "generic decoration; behavioral widgets opt
// back in" (UiDatElement's own doc).
if (rowRoot is UiDatElement datRow)
{
datRow.ClickThrough = false;
datRow.OnClick = () => SelectRow(capturedSkillId);
}
_rows.Add(new SkillRow(
rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText,
upButton, downButton, unselectedColor));
} }
private void RefreshRowValues( private void RefreshRowValues(
@ -488,13 +648,24 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
if (_disposed) if (_disposed)
return; return;
_selectedSkillId = skillId; _selectedSkillId = skillId;
ApplySelectionHighlight();
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
}
/// <summary>Applies <see cref="SelectedNameColor"/>/<see cref="SkillRow.UnselectedNameColor"/>
/// to every row's name text based on <see cref="_selectedSkillId"/> —
/// factored out of <see cref="SelectRow"/> so <see cref="Refresh"/> can
/// re-apply it after a bucket-move rebuild restores a preserved
/// selection onto the NEW row objects (a rebuild discards the old ones,
/// so the highlight must be re-painted, not merely remembered).</summary>
private void ApplySelectionHighlight()
{
foreach (SkillRow row in _rows) foreach (SkillRow row in _rows)
{ {
if (row.NameText is { } nameText) if (row.NameText is { } nameText)
nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor; nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
} }
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
} }
/// <summary> /// <summary>
@ -504,26 +675,45 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// when nothing is selected (retail's own <c>arg2==0</c>/lookup-miss /// when nothing is selected (retail's own <c>arg2==0</c>/lookup-miss
/// arms, both <c>UIElement_Text::ClearAllText</c>). Title is the skill /// arms, both <c>UIElement_Text::ClearAllText</c>). Title is the skill
/// name plus its current score (<c>" (%d)\n"</c>, e.g. "Loyalty (5)"). /// name plus its current score (<c>" (%d)\n"</c>, e.g. "Loyalty (5)").
/// Body is level-gated bonus text /// Body is: DESCRIPTION, then level-gated bonus text
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c> — /// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c> —
/// TWO spaces before the number, matching the compiled literal /// TWO spaces before the number, matching the compiled literal
/// verbatim) only. /// verbatim), then <see cref="ComposeFormula"/>'s "Formula : ..." line —
/// <c>eax_2[7]</c>/<c>eax_2[8]</c> off the row's cached
/// <c>tagSkillRecord</c>, byte-traced against <c>tagSkillRecord</c>'s
/// own field order (<c>acclient.h</c>). Routed through
/// <see cref="DatRichText.Compose"/> (escape-normalize + word-wrap, the
/// SAME composer the description pages use) since the description text
/// can run long enough to need wrapping in this box's width; composed
/// ONCE per call (not per-frame — this method itself only runs when
/// <see cref="Refresh"/>'s caller detects a revision change) and handed
/// to <see cref="UiText.LinesProvider"/> as a closed-over, already-built
/// list, matching the F11 no-per-frame-recompute discipline.
/// ///
/// <para> /// <para>
/// <b>PARTIAL PORT — see the batch report:</b> retail's body ALSO /// <b>Group 2 closeout (Campaign CC gate round 1):</b> DESCRIPTION is a
/// prepends the skill's DESCRIPTION (<c>SkillBase._description</c>, /// byte-verified port (<c>SkillBase._description</c>, read directly off
/// read via <c>eax_2[7]</c> off the row's own cached /// the DAT) and the ONLY segment routed through
/// <c>tagSkillRecord</c>) and appends /// <see cref="DatRichText.Compose"/>'s word-wrap — description text can
/// <c>MakeSkillFormula @0x00480e10</c>'s computed "Formula : ..." text /// run arbitrarily long, unlike the bonus/formula lines below. The bonus
/// (attribute names + weighted-formula arithmetic, sourced from /// line (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
/// <c>SkillBase._formula</c>). Neither is reachable from this page's /// TWO spaces before the number, matching the compiled literal
/// current data surface: <see cref="AcDream.Core.CharGen.ChargenOptions"/> /// verbatim) and <see cref="ComposeFormula"/>'s result are each added as
/// carries per-skill COSTS only (never description/formula), and /// their OWN single, UNWRAPPED <see cref="UiText.Line"/> — deliberately
/// <see cref="CharacterCreationRuntimeBindings"/> has no resolver for /// bypassing <c>DatRichText.Compose</c> for these two, since its
/// either (unlike <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>, /// word-splitting wrap (<see cref="UiText.WrapWords"/>) collapses
/// which already exists for the score). Porting them needs a new /// consecutive spaces when it rejoins tokens, which would silently
/// binding of that same shape, backed by the global SkillTable — out of /// mangle the bonus line's own authored double-space formatting (caught
/// this file's edit contract for this batch. /// by <c>SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine</c>
/// during this closeout — routing it through the wrapper the first time
/// produced "Training Bonus +5 ", single space, trailing artifact from
/// the wrapper's own newline-as-empty-paragraph handling). <see cref="ComposeFormula"/>'s
/// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH
/// CONFIDENCE (every piece is a directly-read compiled string literal or
/// a field the DatReaderWriter binding already exposes by name); the
/// CONNECTOR text between a two-attribute formula's two terms is a
/// documented approximation (register AP-231) — see that method's own
/// doc.
/// </para> /// </para>
/// </summary> /// </summary>
private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
@ -549,10 +739,109 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10", ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
_ => string.Empty, _ => string.Empty,
}; };
SetLine(text, bonus);
bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail);
var lines = new List<UiText.Line>();
if (hasDetail && !string.IsNullOrEmpty(detail.Description))
{
lines.AddRange(DatRichText.Compose(
text, [new DatRichText.Segment(detail.Description, text.DefaultColor)]));
}
if (bonus.Length > 0)
lines.Add(new UiText.Line(bonus, text.DefaultColor));
if (hasDetail)
lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor));
text.LinesProvider = () => lines;
} }
} }
/// <summary>
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c> — retail's
/// formula-text composition. HIGH CONFIDENCE (directly read from
/// compiled string literals plus the field layout
/// <see cref="AcDream.Core.CharGen.ChargenSkillFormula"/> shares with
/// the DatReaderWriter binding's own <c>SkillFormula</c> struct): the
/// <c>"Formula : "</c> prefix, the per-attribute <c>"(%u x %s)"</c>-vs-
/// bare-name choice (a term's own multiplier <c>&gt; 1</c> gets the
/// parenthesized multiply form, else just the attribute's name — the
/// exact <c>eax_6 &lt;= 1</c>/<c>ebx_3 &lt;= 1</c> gate), the
/// <c>" / %u"</c> divisor suffix (gated on <c>Divisor != 1</c>, the
/// exact <c>__saved_ebp_11 != 1</c> gate), and the <c>" +%u"</c>
/// additive-bonus suffix (gated on <c>AdditiveBonus != 0</c>, the exact
/// <c>__saved_ebp_12 != 0</c> gate).
///
/// <para>
/// <b>LOWER CONFIDENCE, disclosed rather than silently guessed
/// (register AP-231):</b> the connector text between a two-attribute
/// formula's own two terms. This port renders <c>" + "</c> — the
/// well-known "(Attr1 + Attr2) / N" shape most published AC skill
/// formulas use — but the decompiled function's own two candidate
/// connector literals (<c>data_7a01a4</c>, appended between the terms;
/// <c>data_797584</c>, appended again immediately after BOTH terms are
/// present) could not be recovered byte-exact by this session's
/// static-only tooling (no live cdb attach, no running Ghidra MCP
/// instance): both sit behind reference-counted <c>PStringBase</c>
/// appends whose actual wide-character content Binary Ninja's HLIL does
/// not surface as a literal, and the surrounding control flow (a
/// <c>goto</c>-based re-convergence between the single-attribute and
/// dual-attribute code paths) left <c>data_797584</c>'s exact role
/// ambiguous enough that this port does NOT invent a second connector
/// for it — a two-attribute skill's formula therefore renders as
/// <c>"Formula : (2 x Strength) + Endurance / 4 +2"</c>-shaped text
/// that is very likely retail-correct in STRUCTURE but not yet
/// byte-verified against a live capture. Single-attribute formulas (the
/// majority of skills) are unaffected by this gap.
/// </para>
/// </summary>
private static string ComposeFormula(ChargenSkillFormula formula)
{
bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0;
bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0;
var builder = new StringBuilder("Formula : ");
if (attribute1Active)
{
AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1);
if (attribute2Active)
builder.Append(" + ");
}
if (attribute2Active)
AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2);
if (formula.Divisor != 1)
builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}");
if (formula.AdditiveBonus != 0)
builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}");
return builder.ToString();
}
private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId)
{
string name = AttributeName((ChargenAttributeId)attributeId);
if (multiplier > 1)
builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})");
else
builder.Append(name);
}
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
/// verbatim — retail hardcodes these six literals directly (not a
/// DAT/localization lookup). Duplicated locally from
/// <c>CharacterCreationProfessionPage</c>'s own private copy rather than
/// extracted to a shared helper — six lines, two call sites, not worth
/// a new file for this closeout's scope.</summary>
private static string AttributeName(ChargenAttributeId id) => id switch
{
ChargenAttributeId.Strength => "Strength",
ChargenAttributeId.Endurance => "Endurance",
ChargenAttributeId.Quickness => "Quickness",
ChargenAttributeId.Coordination => "Coordination",
ChargenAttributeId.Focus => "Focus",
ChargenAttributeId.Self => "Self",
_ => string.Empty,
};
private void ClearInfoBox() private void ClearInfoBox()
{ {
if (_infoTitle is { } title) SetLine(title, string.Empty); if (_infoTitle is { } title) SetLine(title, string.Empty);

View file

@ -81,22 +81,39 @@ public static class ChargenTableReader
heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value);
var globalSkillCosts = new Dictionary<uint, ChargenSkillCost>(skillTable?.Skills.Count ?? 0); var globalSkillCosts = new Dictionary<uint, ChargenSkillCost>(skillTable?.Skills.Count ?? 0);
// Group 2 (Campaign CC gate round 1 closeout): SkillBase.MinLevel/
// Description/Formula — GLOBAL only, no per-heritage counterpart
// (see ChargenSkillDetail's own doc).
var globalSkillDetails = new Dictionary<uint, ChargenSkillDetail>(skillTable?.Skills.Count ?? 0);
if (skillTable is not null) if (skillTable is not null)
{ {
foreach (KeyValuePair<DatReaderWriter.Enums.SkillId, SkillBase> pair in skillTable.Skills) foreach (KeyValuePair<DatReaderWriter.Enums.SkillId, SkillBase> pair in skillTable.Skills)
{ {
uint skillId = (uint)pair.Key; uint skillId = (uint)pair.Key;
SkillBase skill = pair.Value;
globalSkillCosts[skillId] = new ChargenSkillCost( globalSkillCosts[skillId] = new ChargenSkillCost(
skillId, skillId,
pair.Value.TrainedCost, skill.TrainedCost,
pair.Value.SpecializedCost); skill.SpecializedCost);
globalSkillDetails[skillId] = new ChargenSkillDetail(
skillId,
skill.MinLevel,
skill.Description.Value,
new ChargenSkillFormula(
skill.Formula.AdditiveBonus,
skill.Formula.Attribute1Multiplier,
skill.Formula.Attribute2Multiplier,
skill.Formula.Divisor,
(uint)skill.Formula.Attribute1,
(uint)skill.Formula.Attribute2));
} }
} }
return new ChargenOptions( return new ChargenOptions(
Array.AsReadOnly(starterAreas), Array.AsReadOnly(starterAreas),
heritagesById.ToFrozenDictionary(), heritagesById.ToFrozenDictionary(),
globalSkillCosts.ToFrozenDictionary()); globalSkillCosts.ToFrozenDictionary(),
globalSkillDetails.ToFrozenDictionary());
} }
private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area)

View file

@ -26,10 +26,21 @@ namespace AcDream.Core.CharGen;
/// per-gender appearance option lists, skill costs — hangs off this one /// per-gender appearance option lists, skill costs — hangs off this one
/// root. /// root.
/// </summary> /// </summary>
/// <param name="GlobalSkillDetailsBySkillId">
/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's
/// MinLevel/Description/Formula per skill (see <see cref="ChargenSkillDetail"/>'s
/// own doc for why these are GLOBAL-only, unlike <paramref name="GlobalSkillCostsBySkillId"/>
/// which also has a per-heritage counterpart). Defaults to null (not an
/// empty dictionary) so every pre-existing caller that builds a
/// <see cref="ChargenOptions"/> without this parameter — five test fixtures
/// plus <c>ChargenOptions.Empty</c> below — compiles and behaves exactly as
/// before; <see cref="TryGetSkillDetail"/> treats null the same as "empty."
/// </param>
public sealed record ChargenOptions( public sealed record ChargenOptions(
IReadOnlyList<ChargenStarterArea> StarterAreas, IReadOnlyList<ChargenStarterArea> StarterAreas,
IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById, IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById,
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId) IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId,
IReadOnlyDictionary<uint, ChargenSkillDetail>? GlobalSkillDetailsBySkillId = null)
{ {
public static ChargenOptions Empty { get; } = new( public static ChargenOptions Empty { get; } = new(
Array.Empty<ChargenStarterArea>(), Array.Empty<ChargenStarterArea>(),
@ -49,4 +60,13 @@ public sealed record ChargenOptions(
area = default; area = default;
return false; return false;
} }
/// <summary>See <see cref="GlobalSkillDetailsBySkillId"/>'s own doc.</summary>
public bool TryGetSkillDetail(uint skillId, [MaybeNullWhen(false)] out ChargenSkillDetail detail)
{
if (GlobalSkillDetailsBySkillId is { } details && details.TryGetValue(skillId, out detail))
return true;
detail = default;
return false;
}
} }

View file

@ -26,6 +26,53 @@ public enum ChargenSkillAdvancementClass : uint
/// </summary> /// </summary>
public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost); public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
/// <summary>
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c>'s six raw inputs —
/// retail's <c>SkillFormula</c> struct (<c>acclient.h</c>) verbatim field
/// order/shape: <c>_w</c>=<see cref="AdditiveBonus"/>,
/// <c>_x</c>=<see cref="Attribute1Multiplier"/>,
/// <c>_y</c>=<see cref="Attribute2Multiplier"/>, <c>_z</c>=<see cref="Divisor"/>,
/// <c>_attr1</c>=<see cref="Attribute1"/>, <c>_attr2</c>=<see cref="Attribute2"/>.
/// <see cref="Attribute1"/>/<see cref="Attribute2"/> are the raw 1-6 retail
/// attribute id (matching <c>AcDream.Runtime.Session.ChargenAttributeId</c>'s
/// own numbering exactly — Strength=1..Self=6) rather than that enum type
/// itself, since Core does not (and must not) reference Runtime; the App
/// layer, which already references both, does the enum cast at the one
/// call site that needs an attribute NAME.
/// </summary>
public readonly record struct ChargenSkillFormula(
int AdditiveBonus,
int Attribute1Multiplier,
int Attribute2Multiplier,
int Divisor,
uint Attribute1,
uint Attribute2);
/// <summary>
/// One skill's GLOBAL (heritage-independent) presentation data — retail's
/// <c>SkillBase._min_level</c>/<c>_description</c>/<c>_formula</c> fields,
/// sourced ONLY from the portal.dat SkillTable. Distinct from
/// <see cref="ChargenSkillCost"/> (which exists BOTH per-heritage
/// (<c>SkillCG</c>) AND globally) because these three fields have NO
/// per-heritage override in retail at all — <c>SkillCG</c> (the per-
/// heritage cost record <c>HeritageGroupCG.Skills</c> projects) carries
/// only <c>Id</c>/<c>NormalCost</c>/<c>PrimaryCost</c>, verified against the
/// DatReaderWriter binding.
/// </summary>
/// <param name="MinLevel">
/// Retail's <c>_min_level</c> is typed <c>SKILL_ADVANCEMENT_CLASS</c>, not a
/// character level — <c>gmCGSkillsPage::UpdateSkillEntry @0x00480bf0</c>'s
/// own bucket test (<c>arg2-&gt;iMinlevel &lt;= 1</c>) reads it as "the
/// lowest <see cref="ChargenSkillAdvancementClass"/> at which this skill is
/// USEABLE" — <c>&lt;= 1</c> (Inactive/Untrained) means useable while
/// untrained, <c>== 2</c> (Trained) means training is required first.
/// </param>
public readonly record struct ChargenSkillDetail(
uint SkillId,
uint MinLevel,
string Description,
ChargenSkillFormula Formula);
/// <summary> /// <summary>
/// Retail's fixed-size per-character skill-advancement array /// Retail's fixed-size per-character skill-advancement array
/// (<c>CharGenState.skillLevels</c>). ACE's <c>CharacterCreateInfo.Unpack</c> /// (<c>CharGenState.skillLevels</c>). ACE's <c>CharacterCreateInfo.Unpack</c>

View file

@ -335,12 +335,20 @@ public sealed class CharacterCreationUiControllerTests
environment.TabButton(CharacterCreationUiController.SkillsTabElementId) environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!(); .OnClick!();
IReadOnlyList<UiElement> rows = environment.SkillsList().ViewportForTest!.Children; IReadOnlyList<UiElement> children = environment.SkillsList().ViewportForTest!.Children;
// Group 2 closeout: the listbox now ALSO carries the four bucket-
// header rows (DoSkillRecords' own unconditional 4-header build,
// present regardless of whether a bucket is empty) — filter to
// genuine skill rows (carry a 0x10000301 name descendant; headers
// carry only 0x100002f6) before counting.
List<UiElement> skillRows = [.. children.Where(
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is not null)];
Assert.Equal(4, children.Count - skillRows.Count); // four bucket headers, always built.
// Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/ // Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
// SkillFreeTrained(3, added for the F2 arrow-lock coverage below). // SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
Assert.Equal(3, rows.Count); Assert.Equal(3, skillRows.Count);
UiElement row = Assert.Single(rows, candidate => UiElement row = Assert.Single(skillRows, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
@ -363,10 +371,21 @@ public sealed class CharacterCreationUiControllerTests
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs // production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
// ~1045), so force one the same way the file's other post-click // ~1045), so force one the same way the file's other post-click
// refresh assertions do. // refresh assertions do.
//
// Group 2 closeout: advancing also moves the row from the
// UseableUntrained bucket to the Trained bucket, which rebuilds
// every row — row/upCost/downCost captured above are now stale, so
// re-fetch by name (the SAME lookup every other test in this file
// uses post-rebuild) instead of reusing them.
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!();
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
environment.Controller.Tick(); environment.Controller.Tick();
row = Assert.Single(environment.SkillsList().ViewportForTest!.Children, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("4", JoinedText(upCost)); Assert.Equal("4", JoinedText(upCost));
Assert.Equal("2", JoinedText(downCost)); Assert.Equal("2", JoinedText(downCost));
} }
@ -377,10 +396,11 @@ public sealed class CharacterCreationUiControllerTests
/// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter /// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter
/// white") and the info title /// white") and the info title
/// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix) /// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix)
/// populates. Untrained/Inactive carries no bonus line, so the info /// populates. Group 2 closeout: Untrained/Inactive carries no BONUS
/// TEXT pane stays blank (the still-missing description/formula halves /// line, but the info TEXT pane is no longer blank — the DESCRIPTION
/// — see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own /// and <c>MakeSkillFormula</c> lines both render regardless of level
/// doc).</summary> /// (see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
/// doc for the composition order).</summary>
[Fact] [Fact]
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle() public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
{ {
@ -404,7 +424,13 @@ public sealed class CharacterCreationUiControllerTests
string expectedTitle = string expectedTitle =
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})"; $"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle())); Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText())); // SkillTrainOnly's fixture detail: description "A test skill
// description.", formula (2 x Strength) / 4 +2, no bonus line
// (Untrained). JoinedText's own single-space join collapses the
// description's own word-wrapped line break.
Assert.Equal(
"A test skill description. Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
} }
/// <summary>R2-4a: retail re-selects the row after an arrow click too /// <summary>R2-4a: retail re-selects the row after an arrow click too
@ -413,7 +439,11 @@ public sealed class CharacterCreationUiControllerTests
/// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks /// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks
/// the level-gated bonus line as the skill advances (the TWO-space /// the level-gated bonus line as the skill advances (the TWO-space
/// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>, /// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
/// matching the compiled string verbatim).</summary> /// matching the compiled string verbatim, unwrapped end to end — see
/// <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own doc for
/// why the bonus line is never routed through the word-wrap the
/// description segment gets). Group 2 closeout: the description and
/// formula lines now bracket the bonus line on every assertion below.</summary>
[Fact] [Fact]
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine() public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
{ {
@ -426,11 +456,20 @@ public sealed class CharacterCreationUiControllerTests
up.OnClick!(); // Untrained/Inactive -> Trained. up.OnClick!(); // Untrained/Inactive -> Trained.
BumpRevisionAndTick(environment); BumpRevisionAndTick(environment);
Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText())); Assert.Equal(
"A test skill description. Training Bonus +5 Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
// Group 2 closeout: the Untrained -> Trained move above re-buckets
// the row (rebuilding every row and nulling the OLD button's
// OnClick as teardown) — re-fetch by name before the second click
// instead of reusing the pre-rebuild `up` reference.
(up, _) = environment.SkillRowArrows(SkillTrainOnly);
up.OnClick!(); // Trained -> Specialized. up.OnClick!(); // Trained -> Specialized.
BumpRevisionAndTick(environment); BumpRevisionAndTick(environment);
Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText())); Assert.Equal(
"A test skill description. Specialization Bonus +10 Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
} }
/// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own /// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own
@ -503,17 +542,26 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(0x1000001Bu, up.ActiveRetailStateId); Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
Assert.Equal(0x1000001Au, down.ActiveRetailStateId); Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
// Group 2 closeout: advancing a skill can move its row into a NEW
// bucket (UpdateSkillEntry's own re-bucket), which rebuilds every
// row — up/down/freeUp/freeDown are re-fetched by name after each
// level-changing click instead of reusing the pre-click widget
// references, which would otherwise be silently stale (never
// refreshed again once their row is discarded).
up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled. up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
BumpRevisionAndTick(environment); BumpRevisionAndTick(environment);
(up, down) = environment.SkillRowArrows(SkillTrainOnly);
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId); Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained); (UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked. freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
BumpRevisionAndTick(environment); BumpRevisionAndTick(environment);
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId); Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks; freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted. BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId); Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId); Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
} }
@ -534,6 +582,96 @@ public sealed class CharacterCreationUiControllerTests
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model); Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
} }
// ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ──
/// <summary><c>DoSkillRecords</c>'s own unconditional 4-header build —
/// every bucket header is present, in Specialized/Trained/
/// UseableUntrained/UnuseableUntrained order, even though this
/// fixture's three skills leave the Specialized bucket empty.</summary>
[Fact]
public void SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Specialized"] = "Specialized";
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
List<string> headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children
.Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null)
.Select(candidate => Assert.IsType<UiButton>(
UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)];
Assert.Equal(
["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"],
headerCaptions);
}
/// <summary><c>UpdateSkillEntry</c>'s own <c>iMinlevel &lt;= 1</c> split:
/// while Untrained, SkillTrainOnly (fixture MinLevel 1) is useable and
/// SkillSpecializable (fixture MinLevel 2) is not — they land in
/// DIFFERENT buckets even though both start Untrained (the default,
/// unset, <c>FakeView.GetSkillLevel</c> state).</summary>
[Fact]
public void SkillsPage_UntrainedSkill_BucketsByMinLevel()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
int useableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
int unuseableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Unuseable Untrained");
int trainOnlyRowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
int specializableRowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable));
Assert.InRange(trainOnlyRowIndex, useableHeaderIndex + 1, unuseableHeaderIndex - 1);
Assert.True(specializableRowIndex > unuseableHeaderIndex);
}
/// <summary>Advancing a skill re-buckets its row — the row's position
/// moves from the UseableUntrained section to the Trained section,
/// matching <c>UpdateSkillEntry</c>'s own remove-and-reinsert (ported
/// here as a detected full rebuild, not an incremental single-row
/// move — see <see cref="CharacterCreationSkillsPage"/>'s own class doc
/// for why that substitution is faithful to the OBSERVABLE result).</summary>
[Fact]
public void SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); // Untrained -> Trained.
BumpRevisionAndTick(environment);
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
int trainedHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Trained");
int useableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
int rowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
Assert.InRange(rowIndex, trainedHeaderIndex + 1, useableHeaderIndex - 1);
}
[Fact] [Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex() public void TownButton_SelectsTheLiteralStartAreaIndex()
{ {
@ -2374,6 +2512,31 @@ public sealed class CharacterCreationUiControllerTests
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6), [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
}; };
// Group 2 closeout: global SkillTable detail (MinLevel/
// Description/Formula) — SkillTrainOnly is useable while
// Untrained (MinLevel 1) and carries a real description +
// single-attribute formula for the info-box completion tests;
// SkillSpecializable requires Trained first (MinLevel 2), the
// useable-vs-unuseable-untrained bucket split's own test case.
var skillDetails = new Dictionary<uint, ChargenSkillDetail>
{
[SkillTrainOnly] = new ChargenSkillDetail(
SkillTrainOnly,
MinLevel: 1u,
Description: "A test skill description.",
Formula: new ChargenSkillFormula(
AdditiveBonus: 2,
Attribute1Multiplier: 2,
Attribute2Multiplier: 0,
Divisor: 4,
Attribute1: (uint)ChargenAttributeId.Strength,
Attribute2: 0u)),
[SkillSpecializable] = new ChargenSkillDetail(
SkillSpecializable, MinLevel: 2u, Description: string.Empty, Formula: default),
[SkillFreeTrained] = new ChargenSkillDetail(
SkillFreeTrained, MinLevel: 1u, Description: string.Empty, Formula: default),
};
var aluvian = new ChargenHeritageOptions( var aluvian = new ChargenHeritageOptions(
AluvianId, AluvianId,
"Aluvian", "Aluvian",
@ -2426,7 +2589,8 @@ public sealed class CharacterCreationUiControllerTests
[AluvianId] = aluvian, [AluvianId] = aluvian,
[OlthoiId] = olthoi, [OlthoiId] = olthoi,
}, },
new Dictionary<uint, ChargenSkillCost>()); new Dictionary<uint, ChargenSkillCost>(),
skillDetails);
} }
} }
@ -2806,16 +2970,22 @@ public sealed class CharacterCreationUiControllerTests
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
} }
return LayoutImporter.Build( // Group 2 closeout: Templates[0] (0x100002F4) — retail's own
new ElementInfo // bucket-header row, now consumed by the four-bucket rebuild. A
{ // plain container root (Type 3, same shape as Templates[1]'s own
Id = templateElementId, // row — a Type-1 UiButton root would CONSUME its own children and
Type = 1u, // hide the caption), carrying the caption child (0x100002f6, a
Width = 280f, // UiButton, read via .Label — CharacterCreationSkillsPage's own
Height = 16f, // HeaderCaptionElementId doc).
}, var header = new ElementInfo
_ => (0u, 0, 0), {
null).Root; Id = templateElementId,
Type = 3u,
Width = 280f,
Height = 16f,
};
header.Children.Add(ButtonInfo(0x100002F6u));
return LayoutImporter.Build(header, _ => (0u, 0, 0), null).Root;
} }
// ── Summary page fixture (CC5) ─────────────────────────────────────── // ── Summary page fixture (CC5) ───────────────────────────────────────

View file

@ -277,6 +277,54 @@ public sealed class ChargenTableReaderInstalledDatTests
} }
} }
/// <summary>
/// Campaign CC gate round 1 closeout (Group 2): pins
/// <see cref="ChargenOptions.GlobalSkillDetailsBySkillId"/>'s real
/// shape against the installed DAT — same 38-entry population as
/// <see cref="ChargenOptions.GlobalSkillCostsBySkillId"/> (both read the
/// SAME SkillTable loop), MinLevel distributed 23 at <c>&lt;= 1</c>
/// (useable while Untrained) / 15 at exactly <c>2</c> (Trained
/// required), per the Batch F investigation's own recorded finding, and
/// never above 2 — <c>UpdateSkillEntry</c>'s own bucket test only ever
/// distinguishes <c>&lt;= 1</c> from <c>&gt; 1</c>, so a MinLevel of 3
/// would be observationally identical to 2 but is worth flagging if a
/// future DAT drop ever introduces one.
/// </summary>
[Fact]
public void InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.Equal(options.GlobalSkillCostsBySkillId.Count, options.GlobalSkillDetailsBySkillId!.Count);
Assert.Equal(38, options.GlobalSkillDetailsBySkillId.Count);
int useableUntrained = 0;
int trainedRequired = 0;
bool anyDescriptionNonEmpty = false;
foreach (ChargenSkillDetail detail in options.GlobalSkillDetailsBySkillId.Values)
{
Assert.True(detail.MinLevel <= 2u, $"skill {detail.SkillId}: MinLevel {detail.MinLevel} exceeds Trained(2).");
if (detail.MinLevel <= 1u)
useableUntrained++;
else
trainedRequired++;
anyDescriptionNonEmpty |= !string.IsNullOrEmpty(detail.Description);
}
Assert.Equal(23, useableUntrained);
Assert.Equal(15, trainedRequired);
Assert.True(anyDescriptionNonEmpty, "Expected at least one skill to carry a non-empty description.");
}
/// <summary> /// <summary>
/// F5's strengthened installed-DAT gate. <see cref="ChargenGenderOptions.HasAnyAppearanceOptions"/> /// F5's strengthened installed-DAT gate. <see cref="ChargenGenderOptions.HasAnyAppearanceOptions"/>
/// only proves an OR across eight lists for at least one gender per /// only proves an OR across eight lists for at least one gender per