diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
index e7e0c231..8531ddac 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Numerics;
+using System.Text;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@@ -7,11 +8,12 @@ using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
///
-/// The Skills page (gmCGSkillsPage, root 0x100003d3) —
-/// simplified to one flat listbox rather than retail's four-bucket sorted
-/// insertion model (InsertEntrySorted/UpdateSkillEntry,
-/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register
-/// AP-213). Decomp
+/// The Skills page (gmCGSkillsPage, root 0x100003d3) — now
+/// ported to retail's four-bucket sorted insertion model
+/// (InsertEntrySorted/UpdateSkillEntry, Specialized/Trained/
+/// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the
+/// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph
+/// below). Decomp
/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox
/// 0x100003f7, credits meter 0x100002f3 — imports as button
/// 0x100003f9's own consumed Label, see the ctor comment — info
@@ -51,19 +53,14 @@ namespace AcDream.App.UI.Layout;
/// DecreaseSkillLevel, same dispatcher's case 0x10000305).
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one
/// shared row — the row now wires exactly that, retiring AP-213's own
-/// click-to-advance/double-click-retreat single-button substitution (the
-/// row's still-simplified flat-list-vs-four-bucket half is untouched and
-/// stays registered).
+/// click-to-advance/double-click-retreat single-button substitution.
///
///
///
/// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
/// F1/F2): four of R2-4's five sub-items are fixed here; the
-/// four-bucket sorted model (R2-4b) 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 SkillBase.MinLevel, which
-/// /
-/// do not carry today).
+/// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph
+/// below for where it lands.
///
/// - R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(...,
@@ -72,11 +69,8 @@ namespace AcDream.App.UI.Layout;
/// user's own report + the GF-11b precedent) and the info panes
/// (0x100003fb/0x100003fc) get ShowSkillsText
/// @0x00481250's title (name + score, " (%d)\n") and bonus line
-/// ("Training Bonus +5"/"Specialization Bonus +10") — a
-/// PARTIAL port: the description (SkillBase._description) and
-/// MakeSkillFormula @0x00480e10's computed formula text are not
-/// reachable from this page's current data surface; see
-/// 's own doc.
+/// ("Training Bonus +5"/"Specialization Bonus +10") — see the
+/// closeout paragraph below for the description/formula completion.
/// - R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (, dat
/// property 0x72) is now wired to
@@ -108,9 +102,84 @@ namespace AcDream.App.UI.Layout;
/// — no new data needed.
///
///
+///
+///
+/// Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213
+/// CLOSED, R2-4b implemented:
+/// threads SkillBase.MinLevel/Description/Formula from
+/// the global SkillTable through
+/// (Content's ChargenTableReader.Project populates it — these three
+/// fields have NO per-heritage override in retail, unlike costs). Row
+/// building now groups every costable skill into
+/// (Specialized/Trained/UseableUntrained/UnuseableUntrained,
+/// UpdateSkillEntry's own iMinlevel <= 1 useable-vs-
+/// unuseable-untrained test) and sorts each bucket's rows alphabetically by
+/// name (InsertEntrySorted's wcscmp compare, ported as
+/// string.CompareOrdinal), inserting one Templates[0] header
+/// row per bucket (caption child 0x100002f6, a
+/// per the same UIElement_Button-is-DynamicCast(0xc)-compatible-
+/// with-Text quirk GF-4b already used) ahead of that bucket's own
+/// Templates[1] skill rows — matching DoSkillRecords's own
+/// unconditional 4-header-then-populate build order exactly. Headers are
+/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever
+/// disappears just because it has zero rows this round). Advancing/
+/// retreating a skill moves its row between buckets:
+/// detects a bucket change per-row (cheap:
+/// against each row's OWN cached ) rather than
+/// reproducing retail's incremental InsertEntrySorted single-row
+/// move — a full achieves the SAME observable
+/// bucket/sort placement every tick a change is detected, with the current
+/// selection explicitly preserved across that rebuild (unlike a heritage
+/// change, which clears it, matching retail's own roster invalidation).
+/// 's own doc covers the description/formula
+/// completion.
+///
///
internal sealed class CharacterCreationSkillsPage : IDisposable
{
+ /// Retail's four skill buckets, in DoSkillRecords's own
+ /// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained
+ /// — top to bottom in the listbox).
+ private enum SkillBucket
+ {
+ Specialized,
+ Trained,
+ UseableUntrained,
+ UnuseableUntrained,
+ }
+
+ /// Bucket header row string-table keys, in
+ /// order — DoSkillRecords'
+ /// compute_str_hash calls (ID_CharGen_Specialized etc.).
+ private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder =
+ [
+ (SkillBucket.Specialized, "ID_CharGen_Specialized"),
+ (SkillBucket.Trained, "ID_CharGen_Trained"),
+ (SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"),
+ (SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"),
+ ];
+
+ /// UpdateSkillEntry @0x00480bf0's own bucket test:
+ /// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every
+ /// other value — Inactive is
+ /// unreachable for any row this page ever lists, since every listed
+ /// skill is costable and RuntimeCharacterCreationState.ResetSkillLevelsLocked
+ /// always seeds a costable skill's slot at Untrained-or-better, kept
+ /// here only for the same defensive completeness as retail's own
+ /// switch) split on iMinlevel <= 1.
+ private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch
+ {
+ ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized,
+ ChargenSkillAdvancementClass.Trained => SkillBucket.Trained,
+ _ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained,
+ };
+
+ /// Retail's own bucket-header caption child
+ /// (0x100002f6, live-DAT-measured as a —
+ /// the same UIElement_Button-is-Text-compatible quirk GF-4b
+ /// already ported).
+ private const uint HeaderCaptionElementId = 0x100002F6u;
+
/// Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — DoSkillRecords'
/// UIElement_Text::SetText(id_2, &var_138) at
@@ -165,10 +234,15 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// every tick, resolved once at build time rather than re-walked per
/// refresh. is the row's OWN authored
/// (DAT-default) name color, captured at build time so R2-4a's
- /// selection highlight can restore it exactly on deselect.
+ /// selection highlight can restore it exactly on deselect.
+ /// is the bucket this row was LAST built into —
+ /// compares it against a fresh
+ /// call every tick to detect an
+ /// advance/retreat that needs a re-bucket.
private readonly record struct SkillRow(
UiElement Root,
uint SkillId,
+ SkillBucket Bucket,
UiText? NameText,
UiText? LevelText,
UiText? UpCostText,
@@ -237,11 +311,32 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
IRuntimeCharacterCreationView view,
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);
_lastHeritageId = snapshot.HeritageId;
_rowsBuilt = true;
+ if (preservedSkillId is { } skillId)
+ {
+ foreach (SkillRow candidate in _rows)
+ {
+ if (candidate.SkillId != skillId)
+ continue;
+ _selectedSkillId = skillId;
+ ApplySelectionHighlight();
+ break;
+ }
+ }
}
foreach (SkillRow row in _rows)
@@ -253,6 +348,23 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
}
+ /// Group 2 closeout: true when any CURRENTLY BUILT row's live
+ /// bucket (recomputed from its skill's present level/MinLevel) no
+ /// longer matches the bucket it was last built into.
+ private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view)
+ {
+ foreach (SkillRow row in _rows)
+ {
+ ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
+ uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail)
+ ? detail.MinLevel
+ : 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback.
+ if (ComputeBucket(level, minLevel) != row.Bucket)
+ return true;
+ }
+ return false;
+ }
+
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{
foreach (SkillRow row in _rows)
@@ -264,8 +376,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_rows.Clear();
_list?.Flush();
- // The skill list is rebuilding under a (possibly new) heritage —
- // any previously selected skill id may no longer exist as a row.
+ // The skill list is rebuilding — the caller (Refresh) decides
+ // whether to restore _selectedSkillId afterward (preserved across a
+ // bucket-move rebuild, cleared across a heritage change).
_selectedSkillId = null;
ClearInfoBox();
@@ -277,64 +390,111 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
return;
}
- // Templates[1] (0x100002FF) is the REAL skill row — see this
- // class's own doc comment for the full byte trace.
- UiTemplateListEntry template = _list.Templates[1];
+ // Group 2 closeout: gather every costable skill's (id, name, bucket)
+ // first, group by bucket, sort each bucket alphabetically by name
+ // (InsertEntrySorted's own wcscmp compare), THEN build rows in
+ // DoSkillRecords' own header-then-rows-per-bucket order.
+ var byBucket = new Dictionary>(4)
+ {
+ [SkillBucket.Specialized] = [],
+ [SkillBucket.Trained] = [],
+ [SkillBucket.UseableUntrained] = [],
+ [SkillBucket.UnuseableUntrained] = [],
+ };
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (!IsCostable(heritage, view.Options, skillId))
continue;
- if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId)
- is not { } rowRoot)
- {
- continue;
- }
-
- _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));
+ ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
+ uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail)
+ ? detail.MinLevel
+ : 1u;
+ string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
+ byBucket[ComputeBucket(level, minLevel)].Add((skillId, name));
}
+ foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values)
+ bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
+
+ if (_list.Templates.Count < 1)
+ return;
+ UiTemplateListEntry headerTemplate = _list.Templates[0];
+ // Templates[1] (0x100002FF) is the REAL skill row — see this
+ // class's own doc comment for the full byte trace.
+ UiTemplateListEntry rowTemplate = _list.Templates[1];
+
+ foreach ((SkillBucket bucket, string stringKey) in BucketOrder)
+ {
+ BuildHeaderRow(headerTemplate, stringKey);
+ foreach ((uint skillId, _) in byBucket[bucket])
+ BuildSkillRow(rowTemplate, skillId, bucket);
+ }
+ }
+
+ /// Builds one Templates[0] bucket-header row and writes
+ /// its caption () from the string
+ /// table — DoSkillRecords's own unconditional 4-header build,
+ /// regardless of whether the bucket ends up with any rows.
+ private void BuildHeaderRow(UiTemplateListEntry template, string stringKey)
+ {
+ if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot)
+ return;
+ _list.AddPrebuiltRow(headerRoot);
+ if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption
+ && _bindings.ResolveText?.Invoke(stringKey) is { } text)
+ {
+ caption.Label = text;
+ }
+ }
+
+ private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket)
+ {
+ if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot)
+ return;
+
+ _list.AddPrebuiltRow(rowRoot);
+
+ UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
+ if (nameText is not null)
+ SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
+ // Captured AFTER SetLine (which never touches DefaultColor —
+ // it's read lazily inside the LinesProvider closure) so this is
+ // the row's own DAT-authored default color, for R2-4a's
+ // selection highlight to restore on deselect.
+ Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
+ UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
+ UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
+ UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
+ UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
+ UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
+
+ uint capturedSkillId = skillId;
+ // R2-4a: retail re-selects the row after an arrow click too
+ // (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
+ // call following IncreaseSkillLevel/DecreaseSkillLevel).
+ if (upButton is not null)
+ upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
+ if (downButton is not null)
+ downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
+
+ // R2-4a: the row-click equivalent of retail's listbox-level
+ // selection notification (idElement==0x100003f7 &&
+ // idMessage==4 in ListenToElementMessage) — UiTemplateListBox
+ // has no generic selection mechanism of its own (see its class
+ // doc), so this page opts the row in directly. Templates[1]
+ // (0x100002FF) resolves through DatWidgetFactory's Type-3
+ // (generic-container) fallback arm to UiDatElement, which
+ // already carries a page-opt-in OnClick/ClickThrough seam for
+ // exactly this — "generic decoration; behavioral widgets opt
+ // back in" (UiDatElement's own doc).
+ if (rowRoot is UiDatElement datRow)
+ {
+ datRow.ClickThrough = false;
+ datRow.OnClick = () => SelectRow(capturedSkillId);
+ }
+
+ _rows.Add(new SkillRow(
+ rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText,
+ upButton, downButton, unselectedColor));
}
private void RefreshRowValues(
@@ -488,13 +648,24 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
if (_disposed)
return;
_selectedSkillId = skillId;
+ ApplySelectionHighlight();
+ if (_bindings.View() is { } view)
+ RefreshInfoBox(view, view.Snapshot);
+ }
+
+ /// Applies /
+ /// to every row's name text based on —
+ /// factored out of so can
+ /// re-apply it after a bucket-move rebuild restores a preserved
+ /// selection onto the NEW row objects (a rebuild discards the old ones,
+ /// so the highlight must be re-painted, not merely remembered).
+ private void ApplySelectionHighlight()
+ {
foreach (SkillRow row in _rows)
{
if (row.NameText is { } nameText)
- nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
+ nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
}
- if (_bindings.View() is { } view)
- RefreshInfoBox(view, view.Snapshot);
}
///
@@ -504,26 +675,45 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// when nothing is selected (retail's own arg2==0/lookup-miss
/// arms, both UIElement_Text::ClearAllText). Title is the skill
/// name plus its current score (" (%d)\n", e.g. "Loyalty (5)").
- /// Body is level-gated bonus text
+ /// Body is: DESCRIPTION, then level-gated bonus text
/// ("Training Bonus +5"/"Specialization Bonus +10" —
/// TWO spaces before the number, matching the compiled literal
- /// verbatim) only.
+ /// verbatim), then 's "Formula : ..." line —
+ /// eax_2[7]/eax_2[8] off the row's cached
+ /// tagSkillRecord, byte-traced against tagSkillRecord's
+ /// own field order (acclient.h). Routed through
+ /// (escape-normalize + word-wrap, the
+ /// SAME composer the description pages use) since the description text
+ /// can run long enough to need wrapping in this box's width; composed
+ /// ONCE per call (not per-frame — this method itself only runs when
+ /// 's caller detects a revision change) and handed
+ /// to as a closed-over, already-built
+ /// list, matching the F11 no-per-frame-recompute discipline.
///
///
- /// PARTIAL PORT — see the batch report: retail's body ALSO
- /// prepends the skill's DESCRIPTION (SkillBase._description,
- /// read via eax_2[7] off the row's own cached
- /// tagSkillRecord) and appends
- /// MakeSkillFormula @0x00480e10's computed "Formula : ..." text
- /// (attribute names + weighted-formula arithmetic, sourced from
- /// SkillBase._formula). Neither is reachable from this page's
- /// current data surface:
- /// carries per-skill COSTS only (never description/formula), and
- /// has no resolver for
- /// either (unlike ,
- /// 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.
+ /// Group 2 closeout (Campaign CC gate round 1): DESCRIPTION is a
+ /// byte-verified port (SkillBase._description, read directly off
+ /// the DAT) and the ONLY segment routed through
+ /// 's word-wrap — description text can
+ /// run arbitrarily long, unlike the bonus/formula lines below. The bonus
+ /// line ("Training Bonus +5"/"Specialization Bonus +10",
+ /// TWO spaces before the number, matching the compiled literal
+ /// verbatim) and 's result are each added as
+ /// their OWN single, UNWRAPPED — deliberately
+ /// bypassing DatRichText.Compose for these two, since its
+ /// word-splitting wrap () collapses
+ /// consecutive spaces when it rejoins tokens, which would silently
+ /// mangle the bonus line's own authored double-space formatting (caught
+ /// by SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine
+ /// during this closeout — routing it through the wrapper the first time
+ /// produced "Training Bonus +5 ", single space, trailing artifact from
+ /// the wrapper's own newline-as-empty-paragraph handling). 's
+ /// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH
+ /// CONFIDENCE (every piece is a directly-read compiled string literal or
+ /// a field the DatReaderWriter binding already exposes by name); the
+ /// CONNECTOR text between a two-attribute formula's two terms is a
+ /// documented approximation (register AP-231) — see that method's own
+ /// doc.
///
///
private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
@@ -549,10 +739,109 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
_ => string.Empty,
};
- SetLine(text, bonus);
+
+ bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail);
+ var lines = new List();
+ if (hasDetail && !string.IsNullOrEmpty(detail.Description))
+ {
+ lines.AddRange(DatRichText.Compose(
+ text, [new DatRichText.Segment(detail.Description, text.DefaultColor)]));
+ }
+ if (bonus.Length > 0)
+ lines.Add(new UiText.Line(bonus, text.DefaultColor));
+ if (hasDetail)
+ lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor));
+
+ text.LinesProvider = () => lines;
}
}
+ ///
+ /// gmCGSkillsPage::MakeSkillFormula @0x00480e10 — retail's
+ /// formula-text composition. HIGH CONFIDENCE (directly read from
+ /// compiled string literals plus the field layout
+ /// shares with
+ /// the DatReaderWriter binding's own SkillFormula struct): the
+ /// "Formula : " prefix, the per-attribute "(%u x %s)"-vs-
+ /// bare-name choice (a term's own multiplier > 1 gets the
+ /// parenthesized multiply form, else just the attribute's name — the
+ /// exact eax_6 <= 1/ebx_3 <= 1 gate), the
+ /// " / %u" divisor suffix (gated on Divisor != 1, the
+ /// exact __saved_ebp_11 != 1 gate), and the " +%u"
+ /// additive-bonus suffix (gated on AdditiveBonus != 0, the exact
+ /// __saved_ebp_12 != 0 gate).
+ ///
+ ///
+ /// LOWER CONFIDENCE, disclosed rather than silently guessed
+ /// (register AP-231): the connector text between a two-attribute
+ /// formula's own two terms. This port renders " + " — the
+ /// well-known "(Attr1 + Attr2) / N" shape most published AC skill
+ /// formulas use — but the decompiled function's own two candidate
+ /// connector literals (data_7a01a4, appended between the terms;
+ /// data_797584, appended again immediately after BOTH terms are
+ /// present) could not be recovered byte-exact by this session's
+ /// static-only tooling (no live cdb attach, no running Ghidra MCP
+ /// instance): both sit behind reference-counted PStringBase
+ /// appends whose actual wide-character content Binary Ninja's HLIL does
+ /// not surface as a literal, and the surrounding control flow (a
+ /// goto-based re-convergence between the single-attribute and
+ /// dual-attribute code paths) left data_797584's exact role
+ /// ambiguous enough that this port does NOT invent a second connector
+ /// for it — a two-attribute skill's formula therefore renders as
+ /// "Formula : (2 x Strength) + Endurance / 4 +2"-shaped text
+ /// that is very likely retail-correct in STRUCTURE but not yet
+ /// byte-verified against a live capture. Single-attribute formulas (the
+ /// majority of skills) are unaffected by this gap.
+ ///
+ ///
+ private static string ComposeFormula(ChargenSkillFormula formula)
+ {
+ bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0;
+ bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0;
+
+ var builder = new StringBuilder("Formula : ");
+ if (attribute1Active)
+ {
+ AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1);
+ if (attribute2Active)
+ builder.Append(" + ");
+ }
+ if (attribute2Active)
+ AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2);
+
+ if (formula.Divisor != 1)
+ builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}");
+ if (formula.AdditiveBonus != 0)
+ builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}");
+ return builder.ToString();
+ }
+
+ private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId)
+ {
+ string name = AttributeName((ChargenAttributeId)attributeId);
+ if (multiplier > 1)
+ builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})");
+ else
+ builder.Append(name);
+ }
+
+ /// Ports CharGenState::GetAttributeName @ 0x005C3A20
+ /// verbatim — retail hardcodes these six literals directly (not a
+ /// DAT/localization lookup). Duplicated locally from
+ /// CharacterCreationProfessionPage's own private copy rather than
+ /// extracted to a shared helper — six lines, two call sites, not worth
+ /// a new file for this closeout's scope.
+ private static string AttributeName(ChargenAttributeId id) => id switch
+ {
+ ChargenAttributeId.Strength => "Strength",
+ ChargenAttributeId.Endurance => "Endurance",
+ ChargenAttributeId.Quickness => "Quickness",
+ ChargenAttributeId.Coordination => "Coordination",
+ ChargenAttributeId.Focus => "Focus",
+ ChargenAttributeId.Self => "Self",
+ _ => string.Empty,
+ };
+
private void ClearInfoBox()
{
if (_infoTitle is { } title) SetLine(title, string.Empty);
diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs
index 127a215f..f902ab79 100644
--- a/src/AcDream.Content/CharGen/ChargenTableReader.cs
+++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs
@@ -81,22 +81,39 @@ public static class ChargenTableReader
heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value);
var globalSkillCosts = new Dictionary(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(skillTable?.Skills.Count ?? 0);
if (skillTable is not null)
{
foreach (KeyValuePair pair in skillTable.Skills)
{
uint skillId = (uint)pair.Key;
+ SkillBase skill = pair.Value;
globalSkillCosts[skillId] = new ChargenSkillCost(
skillId,
- pair.Value.TrainedCost,
- pair.Value.SpecializedCost);
+ skill.TrainedCost,
+ 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(
Array.AsReadOnly(starterAreas),
heritagesById.ToFrozenDictionary(),
- globalSkillCosts.ToFrozenDictionary());
+ globalSkillCosts.ToFrozenDictionary(),
+ globalSkillDetails.ToFrozenDictionary());
}
private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area)
diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs
index 218479f1..cd631e48 100644
--- a/src/AcDream.Core/CharGen/ChargenOptions.cs
+++ b/src/AcDream.Core/CharGen/ChargenOptions.cs
@@ -26,10 +26,21 @@ namespace AcDream.Core.CharGen;
/// per-gender appearance option lists, skill costs — hangs off this one
/// root.
///
+///
+/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's
+/// MinLevel/Description/Formula per skill (see 's
+/// own doc for why these are GLOBAL-only, unlike
+/// which also has a per-heritage counterpart). Defaults to null (not an
+/// empty dictionary) so every pre-existing caller that builds a
+/// without this parameter — five test fixtures
+/// plus ChargenOptions.Empty below — compiles and behaves exactly as
+/// before; treats null the same as "empty."
+///
public sealed record ChargenOptions(
IReadOnlyList StarterAreas,
IReadOnlyDictionary HeritagesById,
- IReadOnlyDictionary GlobalSkillCostsBySkillId)
+ IReadOnlyDictionary GlobalSkillCostsBySkillId,
+ IReadOnlyDictionary? GlobalSkillDetailsBySkillId = null)
{
public static ChargenOptions Empty { get; } = new(
Array.Empty(),
@@ -49,4 +60,13 @@ public sealed record ChargenOptions(
area = default;
return false;
}
+
+ /// See 's own doc.
+ 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;
+ }
}
diff --git a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
index cba65741..cb6a4b3a 100644
--- a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
+++ b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs
@@ -26,6 +26,53 @@ public enum ChargenSkillAdvancementClass : uint
///
public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
+///
+/// gmCGSkillsPage::MakeSkillFormula @0x00480e10's six raw inputs —
+/// retail's SkillFormula struct (acclient.h) verbatim field
+/// order/shape: _w=,
+/// _x=,
+/// _y=, _z=,
+/// _attr1=, _attr2=.
+/// / are the raw 1-6 retail
+/// attribute id (matching AcDream.Runtime.Session.ChargenAttributeId'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.
+///
+public readonly record struct ChargenSkillFormula(
+ int AdditiveBonus,
+ int Attribute1Multiplier,
+ int Attribute2Multiplier,
+ int Divisor,
+ uint Attribute1,
+ uint Attribute2);
+
+///
+/// One skill's GLOBAL (heritage-independent) presentation data — retail's
+/// SkillBase._min_level/_description/_formula fields,
+/// sourced ONLY from the portal.dat SkillTable. Distinct from
+/// (which exists BOTH per-heritage
+/// (SkillCG) AND globally) because these three fields have NO
+/// per-heritage override in retail at all — SkillCG (the per-
+/// heritage cost record HeritageGroupCG.Skills projects) carries
+/// only Id/NormalCost/PrimaryCost, verified against the
+/// DatReaderWriter binding.
+///
+///
+/// Retail's _min_level is typed SKILL_ADVANCEMENT_CLASS, not a
+/// character level — gmCGSkillsPage::UpdateSkillEntry @0x00480bf0's
+/// own bucket test (arg2->iMinlevel <= 1) reads it as "the
+/// lowest at which this skill is
+/// USEABLE" — <= 1 (Inactive/Untrained) means useable while
+/// untrained, == 2 (Trained) means training is required first.
+///
+public readonly record struct ChargenSkillDetail(
+ uint SkillId,
+ uint MinLevel,
+ string Description,
+ ChargenSkillFormula Formula);
+
///
/// Retail's fixed-size per-character skill-advancement array
/// (CharGenState.skillLevels). ACE's CharacterCreateInfo.Unpack
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
index 089525d8..f3607692 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
@@ -335,12 +335,20 @@ public sealed class CharacterCreationUiControllerTests
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
- IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children;
+ IReadOnlyList 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 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)/
// 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
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
@@ -363,10 +371,21 @@ public sealed class CharacterCreationUiControllerTests
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
// ~1045), so force one the same way the file's other post-click
// 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!();
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
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(UiElement.FindDescendant(row, 0x10000303u));
+ downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("4", JoinedText(upCost));
Assert.Equal("2", JoinedText(downCost));
}
@@ -377,10 +396,11 @@ public sealed class CharacterCreationUiControllerTests
/// NAME text swaps to Vector4.One (the best-derived "brighter
/// white") and the info title
/// (ShowSkillsText @0x00481250's " (%d)" score suffix)
- /// populates. Untrained/Inactive carries no bonus line, so the info
- /// TEXT pane stays blank (the still-missing description/formula halves
- /// — see 's own
- /// doc).
+ /// populates. Group 2 closeout: Untrained/Inactive carries no BONUS
+ /// line, but the info TEXT pane is no longer blank — the DESCRIPTION
+ /// and MakeSkillFormula lines both render regardless of level
+ /// (see 's own
+ /// doc for the composition order).
[Fact]
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
{
@@ -404,7 +424,13 @@ public sealed class CharacterCreationUiControllerTests
string expectedTitle =
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
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()));
}
/// 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
/// the level-gated bonus line as the skill advances (the TWO-space
/// literal "Training Bonus +5"/"Specialization Bonus +10",
- /// matching the compiled string verbatim).
+ /// matching the compiled string verbatim, unwrapped end to end — see
+ /// '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.
[Fact]
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
{
@@ -426,11 +456,20 @@ public sealed class CharacterCreationUiControllerTests
up.OnClick!(); // Untrained/Inactive -> Trained.
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.
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()));
}
/// 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(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.
BumpRevisionAndTick(environment);
+ (up, down) = environment.SkillRowArrows(SkillTrainOnly);
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
BumpRevisionAndTick(environment);
+ (freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
+ (freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
}
@@ -534,6 +582,96 @@ public sealed class CharacterCreationUiControllerTests
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
}
+ // ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ──
+
+ /// DoSkillRecords'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.
+ [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 headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children
+ .Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null)
+ .Select(candidate => Assert.IsType(
+ UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)];
+
+ Assert.Equal(
+ ["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"],
+ headerCaptions);
+ }
+
+ /// UpdateSkillEntry's own iMinlevel <= 1 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, FakeView.GetSkillLevel state).
+ [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 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);
+ }
+
+ /// Advancing a skill re-buckets its row — the row's position
+ /// moves from the UseableUntrained section to the Trained section,
+ /// matching UpdateSkillEntry's own remove-and-reinsert (ported
+ /// here as a detected full rebuild, not an incremental single-row
+ /// move — see 's own class doc
+ /// for why that substitution is faithful to the OBSERVABLE result).
+ [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 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]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
@@ -2374,6 +2512,31 @@ public sealed class CharacterCreationUiControllerTests
[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
+ {
+ [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(
AluvianId,
"Aluvian",
@@ -2426,7 +2589,8 @@ public sealed class CharacterCreationUiControllerTests
[AluvianId] = aluvian,
[OlthoiId] = olthoi,
},
- new Dictionary());
+ new Dictionary(),
+ skillDetails);
}
}
@@ -2806,16 +2970,22 @@ public sealed class CharacterCreationUiControllerTests
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
}
- return LayoutImporter.Build(
- new ElementInfo
- {
- Id = templateElementId,
- Type = 1u,
- Width = 280f,
- Height = 16f,
- },
- _ => (0u, 0, 0),
- null).Root;
+ // Group 2 closeout: Templates[0] (0x100002F4) — retail's own
+ // bucket-header row, now consumed by the four-bucket rebuild. A
+ // plain container root (Type 3, same shape as Templates[1]'s own
+ // row — a Type-1 UiButton root would CONSUME its own children and
+ // hide the caption), carrying the caption child (0x100002f6, a
+ // UiButton, read via .Label — CharacterCreationSkillsPage's own
+ // HeaderCaptionElementId doc).
+ var header = new ElementInfo
+ {
+ 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) ───────────────────────────────────────
diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
index 0753ef07..60a015f5 100644
--- a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
+++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
@@ -277,6 +277,54 @@ public sealed class ChargenTableReaderInstalledDatTests
}
}
+ ///
+ /// Campaign CC gate round 1 closeout (Group 2): pins
+ /// 's real
+ /// shape against the installed DAT — same 38-entry population as
+ /// (both read the
+ /// SAME SkillTable loop), MinLevel distributed 23 at <= 1
+ /// (useable while Untrained) / 15 at exactly 2 (Trained
+ /// required), per the Batch F investigation's own recorded finding, and
+ /// never above 2 — UpdateSkillEntry's own bucket test only ever
+ /// distinguishes <= 1 from > 1, so a MinLevel of 3
+ /// would be observationally identical to 2 but is worth flagging if a
+ /// future DAT drop ever introduces one.
+ ///
+ [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.");
+ }
+
///
/// F5's strengthened installed-DAT gate.
/// only proves an OR across eight lists for at least one gender per