From 0fed5fdd914c4ab79235685bb0e1f8ef439a2da1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 15:33:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20closeout=20=E2=80=94=20Group=202:=20Skills=20page=20four-bu?= =?UTF-8?q?cket=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../UI/Layout/CharacterCreationSkillsPage.cs | 479 ++++++++++++++---- .../CharGen/ChargenTableReader.cs | 23 +- src/AcDream.Core/CharGen/ChargenOptions.cs | 22 +- .../CharGen/ChargenSkillAdvancement.cs | 47 ++ .../CharacterCreationUiControllerTests.cs | 214 +++++++- .../ChargenTableReaderInstalledDatTests.cs | 48 ++ 6 files changed, 712 insertions(+), 121 deletions(-) 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