diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3f22d4e9..517dd5cc 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -401,7 +401,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) |
| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) |
| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) |
-| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` |
+| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. **Batch F investigation (Campaign CC gate round 1, 2026-08-16 — R2-4b): the remaining flat-list-vs-four-bucket half's blocker is now PRECISELY IDENTIFIED, still NOT implemented.** Retail's `UpdateSkillEntry @0x00480bf0` splits Untrained-class rows into UseableUntrained/UnuseableUntrained via `arg2->iMinlevel <= 1` — `iMinlevel` copies `SkillBase._min_level` (portal.dat SkillTable, live-DAT-confirmed present and populated — `SkillTable_MinLevelDistribution_NeverExceedsTrained` measures 23 skills at MinLevel=1, 15 at MinLevel=2 in the installed dat). `AcDream.Core.CharGen.ChargenOptions`/`ChargenHeritageOptions`/`ChargenSkillCost` carry per-skill COSTS only; MinLevel is not threaded through CC1's `ChargenTableReader` at all, and `CharacterCreationRuntimeBindings` has no resolver for it (unlike `GetSkillScore`, which already exists for the analogous per-skill score lookup, `AcDream.App.Net.ChargenSkillScoreResolver`). Closing this row for real needs: (1) `ChargenSkillCost` (or a sibling record) gains a `MinLevel`/useable-while-untrained field, (2) `ChargenTableReader.Load` populates it from `SkillBase.MinLevel`, (3) the page reads it directly (`ChargenOptions` is already reachable from `CharacterCreationSkillsPage`, no new binding needed once (1)/(2) land) to build the four header rows (`Templates[0]`, `0x100002F4` — its own caption child `0x100002f6` live-DAT-measured as a `UiButton`, read via `.Label`, not a `UiText` — the same `UIElement_Button`-is-`DynamicCast(0xc)`-compatible-with-Text quirk GF-4b already ported) and re-bucket on every level change (`InsertEntrySorted`'s own alphabetical-by-name category-relative insert). Batch F separately fixed two ADJACENT bugs found while re-deriving `SetSkillText`'s full per-branch behavior for this SAME row (review F1/F2 — plain bugs, not divergences, so no register rows of their own): the Untrained-down/Specialized-up literal-`"0"`-vs-blank cost text, and the `pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair (gated on `remainingSkillCredits` and a re-derived `bUntrainable`/`bUnspecializable` — the row's OWN effective trained/specialized cost being nonzero — using cost data this page already resolves, no new channel needed for those two). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`, `SelectRow`, `RefreshInfoBox`); `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` (`ChargenSkillCost` — the field that would need to grow); `src/AcDream.Content/CharGen/ChargenTableReader.cs` (the read site that would need to populate it) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. Separately, a player cannot yet tell "Useable Untrained" from "Unuseable Untrained" (both render identically, ungrouped) until the `MinLevel` channel above is wired — a second, narrower discoverability gap layered on the first. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0`; `gmCGSkillsPage::SetSkillText @ 0x00480600`; `gmCGSkillsPage::ShowSkillsText @ 0x00481250`; `gmCGSkillsPage::MakeSkillFormula @ 0x00480e10` |
| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site |
| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) |
diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
index bf0e521c..f5abb835 100644
--- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
+++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
@@ -267,6 +267,36 @@ by construction (`SetBackdrop` throws if called without a reserved slot,
and the entity-list-assembly helper `BuildDrawEntities` degrades to
exactly the main entity whenever no backdrop is configured/set).
+**Batch F (Skills page completion — R2-4 + review F1/F2) is CODE-COMPLETE
+2026-08-16, pending the user's visual gate.** Four of R2-4's five
+sub-items are fixed; R2-4b (the four-bucket sorted model) is NOT — see the
+AP-213 register row for the exact missing data channel this batch's
+investigation pinned down (`SkillBase.MinLevel`, confirmed present in the
+installed dat but not threaded through `ChargenOptions`/
+`CharacterCreationRuntimeBindings`). **Fixed:** R2-4a (row selection — a
+row click, or an arrow click matching retail's own post-Increase/
+DecreaseSkillLevel re-select, highlights the row and writes the info
+panes' TITLE — name + score — and a level-gated bonus line; the
+description/formula halves stay unported for the SAME missing-data reason
+as R2-4b, documented on `CharacterCreationSkillsPage.RefreshInfoBox`'s own
+doc rather than a new register row since no file outside the page's own
+scope was needed to identify it); R2-4c (the listbox's own authored
+scrollbar link, live-DAT-CONFIRMED at `0x100003F8` — exactly this batch's
+own "+1 from the listbox" hypothesis — wired to the listbox's `Scroll`
+model, the ordinary page-level linkage every other `UiTemplateListBox`
+owner uses); review F1 (the Untrained-down/Specialized-up literal `"0"`
+cost text the prior port rendered blank, and the exact per-branch 999-blank
+gate — up-cost only, never down-cost); review F2 (the
+`pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair, gated on
+credits and a re-derived `bUntrainable`/`bUnspecializable` — the row's own
+effective cost being nonzero — using cost data the page already resolves,
+no new channel needed). Fixture + one live-DAT test this round (no
+graphical client launch); App suite live-DAT env went from 5321/3 to
+5328/3 (+7, zero regressions — one pre-existing baseline flake, the
+streaming "injected dungeon enqueue failure" test, is a known standalone-
+pass-only flake unrelated to this batch and did not reproduce on the full
+post-fix run), Runtime 1735/0 unchanged.
+
User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session,
windowed). Screenshots: retail Heritage, acdream Heritage, retail
Profession. The user's side-by-side retail reports are AXIOMS
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
index 2bb022a8..e7e0c231 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@@ -54,6 +55,59 @@ namespace AcDream.App.UI.Layout;
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
/// stays registered).
///
+///
+///
+/// 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).
+///
+/// - R2-4a (row selection): a row click (or an arrow click, matching
+/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(...,
+/// 1) re-select) now selects that skill — the row's NAME text swaps to
+/// (best-derived "brighter white" per the
+/// 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.
+/// - R2-4c (scrollbar): the listbox's own authored scrollbar link
+/// (, dat
+/// property 0x72) is now wired to
+/// — the SAME
+/// page-level UiScrollbar.Model linkage every other
+/// UiTemplateListBox owner uses (no widget change).
+/// - Review F1 (cost text): SetSkillText's Untrained down-cost
+/// (@0x00480877) and Specialized up-cost (@0x0048067f) are
+/// literal "0", unconditional — the prior port rendered blank
+/// (null) instead. The <0x3e7 (999) blank gate exists
+/// ONLY on the up-cost at Untrained (@0x00480819) and Trained
+/// (@0x0048071f); every down-cost write is unconditional
+/// (@0x00480877/@0x00480780/@0x004806c1), including
+/// Trained's raw iTrainCost even when it would exceed 999.
+/// - Review F2 (arrow states): SetSkillText ends every branch
+/// driving pSkillUpButton/pSkillDownButton through its own
+/// custom Ghosted/Enabled state pair (/
+/// — raw ids via
+/// , the SAME "authored
+/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
+/// remainingSkillCredits vs. the advance cost and is ALWAYS ghosted
+/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained
+/// (nothing below it) and otherwise gated on bUntrainable/
+/// bUnspecializable — re-derived from DoSkillRecords's own
+/// tagSkillRecord build (@0x00480e40-region) as "the row's OWN
+/// effective trained/specialized cost is nonzero" (a free/heritage-granted
+/// skill or specialization locks its own down arrow), using the SAME
+/// heritage-then-global cost this page already resolves via
+/// — no new data needed.
+///
+///
///
internal sealed class CharacterCreationSkillsPage : IDisposable
{
@@ -84,18 +138,44 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
/// case 0x10000305 fires DecreaseSkillLevel.
private const uint RowDownButtonId = 0x10000305u;
+ /// Retail's own custom Ghosted state id for
+ /// pSkillUpButton/pSkillDownButton (SetSkillText's
+ /// own SetState(0x1000001a) calls) — distinct from the standard
+ /// UiButtonStateMachine.Ghosted (13) numbering; the same
+ /// "authored custom pair, raw retail id" shape as GF-1's
+ /// Unselected/Selected (0x10000016/0x10000017).
+ private const uint ArrowGhostedStateId = 0x1000001Au;
+
+ /// Retail's own custom Enabled state id for the same two
+ /// buttons (SetState(0x1000001b)).
+ private const uint ArrowEnabledStateId = 0x1000001Bu;
+
+ /// R2-4a row-selection highlight: pure white. Re-derived from
+ /// the GF-11b precedent (list-caption color swap Normal
+ /// (218,167,85) -> Highlight/white (255,255,255) on
+ /// selection) plus the user's own report ("retail selection turns the
+ /// row brighter white") absent a skills-row-specific cdb capture — the
+ /// direction (unselected -> brighter/whiter) is directly evidenced;
+ /// the exact target RGB is the best available derivation, not a live
+ /// measurement.
+ private static readonly Vector4 SelectedNameColor = Vector4.One;
+
/// One built skill row: the resolved Templates[1]
/// subtree plus the child widgets needs
/// every tick, resolved once at build time rather than re-walked per
- /// refresh.
+ /// 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.
private readonly record struct SkillRow(
UiElement Root,
uint SkillId,
+ UiText? NameText,
UiText? LevelText,
UiText? UpCostText,
UiText? DownCostText,
UiButton? UpButton,
- UiButton? DownButton);
+ UiButton? DownButton,
+ Vector4 UnselectedNameColor);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list;
@@ -104,6 +184,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
private readonly UiText? _infoText;
private readonly List _rows = [];
private uint _lastHeritageId;
+ private uint? _selectedSkillId;
private bool _rowsBuilt;
private bool _disposed;
@@ -116,6 +197,20 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = templateResolver;
+
+ // R2-4c (Batch F): wire the listbox's own authored scrollbar (dat
+ // property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME
+ // page-level Model linkage every other UiTemplateListBox owner uses
+ // (ConfigOptionsPageController, SocialFriendsPageController, et
+ // al.) — no widget change, just resolving the id the importer
+ // already read and pointing its Model at this listbox's own Scroll.
+ if (_list is not null
+ && _list.ScrollbarElementId != 0
+ && UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar)
+ {
+ scrollbar.Model = _list.Scroll;
+ }
+
// Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
// dat CHILD of button 0x100003f9, not as a standalone descendant of
@@ -152,6 +247,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
foreach (SkillRow row in _rows)
RefreshRowValues(row, view, snapshot);
+ RefreshInfoBox(view, snapshot);
+
if (_credits is { } credits)
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
}
@@ -162,10 +259,16 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
+ if (row.Root is UiDatElement datRoot) datRoot.OnClick = null;
}
_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.
+ _selectedSkillId = null;
+ ClearInfoBox();
+
if (_list is null
|| _list.Templates.Count < 2
|| _list.TemplateResolver is null
@@ -189,8 +292,14 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_list.AddPrebuiltRow(rowRoot);
- if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText)
+ UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
+ if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
+ // 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;
@@ -198,13 +307,33 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
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);
+ upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null)
- downButton.OnClick = () => Retreat(capturedSkillId);
+ downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
+
+ // R2-4a: the row-click equivalent of retail's listbox-level
+ // selection notification (idElement==0x100003f7 &&
+ // idMessage==4 in ListenToElementMessage) — UiTemplateListBox
+ // has no generic selection mechanism of its own (see its class
+ // doc), so this page opts the row in directly. Templates[1]
+ // (0x100002FF) resolves through DatWidgetFactory's Type-3
+ // (generic-container) fallback arm to UiDatElement, which
+ // already carries a page-opt-in OnClick/ClickThrough seam for
+ // exactly this — "generic decoration; behavioral widgets opt
+ // back in" (UiDatElement's own doc).
+ if (rowRoot is UiDatElement datRow)
+ {
+ datRow.ClickThrough = false;
+ datRow.OnClick = () => SelectRow(capturedSkillId);
+ }
_rows.Add(new SkillRow(
- rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton));
+ rowRoot, skillId, nameText, levelText, upCostText, downCostText,
+ upButton, downButton, unselectedColor));
}
}
@@ -220,28 +349,71 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
if (row.LevelText is { } levelText)
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
- // SetSkillText @0x00480600's own per-state up/down cost pair: at
- // Untrained, up=trainCost (down blank, nothing below Untrained); at
- // Trained, up=(specCost-trainCost), down=trainCost; at Specialized,
- // up=blank (nothing above Specialized), down=(specCost-trainCost).
- // Retail also blanks a cost >= 999 (data_794320, an empty
- // PStringBase) instead of showing the raw number.
- (int? upCost, int? downCost) = level switch
+ // Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact
+ // per-state cost text + arrow-enable pair — see this class's own
+ // header doc for the full byte trace of every address cited below.
+ string upCostText;
+ string downCostText;
+ bool upEnabled;
+ bool downEnabled;
+ switch (level)
{
- ChargenSkillAdvancementClass.Specialized =>
- ((int?)null, (int?)(specializedCost - trainedCost)),
- ChargenSkillAdvancementClass.Trained =>
- ((int?)(specializedCost - trainedCost), (int?)trainedCost),
- _ => ((int?)trainedCost, (int?)null),
- };
- if (row.UpCostText is { } upCostText)
- SetLine(upCostText, FormatCost(upCost));
- if (row.DownCostText is { } downCostText)
- SetLine(downCostText, FormatCost(downCost));
+ case ChargenSkillAdvancementClass.Specialized:
+ // @0x0048067f: up = literal "0", unconditional (nothing
+ // above Specialized). @0x004806c1: down = specCost-
+ // trainCost, UNCONDITIONAL (no 999-blank gate).
+ // @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c +
+ // @0x004807f1/@0x004807f4: down arrow enabled iff
+ // bUnspecializable — re-derived as specializedCost != 0
+ // (a free/heritage-granted specialization, cost 0, locks
+ // its own down arrow — DoSkillRecords zeroes
+ // bUnspecializable exactly there, @0x00480e40 region).
+ upCostText = "0";
+ downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture);
+ upEnabled = false;
+ downEnabled = specializedCost != 0;
+ break;
+ case ChargenSkillAdvancementClass.Trained:
+ // @0x0048071f: up = specCost-trainCost, blank if >=999.
+ // @0x00480780: down = trainCost, UNCONDITIONAL (no gate,
+ // even past 999). @0x004807ce: up arrow enabled iff
+ // remainingSkillCredits >= specCost-trainCost.
+ // @0x004807ec + @0x004807f1/@0x004807f4: down arrow
+ // enabled iff bUntrainable — re-derived as trainedCost != 0
+ // (same free-skill-locks-the-down-arrow rule, mirrored on
+ // the trained cost).
+ upCostText = FormatGatedCost(specializedCost - trainedCost);
+ downCostText = trainedCost.ToString(CultureInfo.InvariantCulture);
+ upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost;
+ downEnabled = trainedCost != 0;
+ break;
+ default:
+ // Untrained/Inactive. @0x00480819: up = trainCost, blank if
+ // >=999. @0x00480877: down = literal "0", unconditional.
+ // @0x004808b3: down arrow ALWAYS ghosted (nothing below
+ // Untrained). @0x004808d1: up arrow enabled iff
+ // remainingSkillCredits >= trainCost.
+ upCostText = FormatGatedCost(trainedCost);
+ downCostText = "0";
+ upEnabled = snapshot.RemainingSkillCredits >= trainedCost;
+ downEnabled = false;
+ break;
+ }
+
+ if (row.UpCostText is { } upCostTextWidget)
+ SetLine(upCostTextWidget, upCostText);
+ if (row.DownCostText is { } downCostTextWidget)
+ SetLine(downCostTextWidget, downCostText);
+ row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
+ row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
}
- private static string FormatCost(int? cost) =>
- cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty;
+ /// The up-cost-only 999 blank gate (< 0x3e7,
+ /// data_794320 — an empty PStringBase). Never applied to a
+ /// down-cost or a literal "0" write — see the per-branch citations in
+ /// .
+ private static string FormatGatedCost(int cost) =>
+ cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty;
private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
@@ -301,6 +473,92 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_bindings.UntrainSkill(skillId);
}
+ ///
+ /// R2-4a: row click / arrow click selection — the port's equivalent of
+ /// retail's listbox-level SetSelectedItem notification (see
+ /// 's own wiring doc). Applies the highlight
+ /// to every row (so the PREVIOUSLY selected row also gets restored to
+ /// its own ) and refreshes
+ /// the info panes for the newly selected skill. '
+ /// View is resolved fresh here, never cached, per
+ /// feedback_resolve_deferred_funcs_per_call.md.
+ ///
+ private void SelectRow(uint skillId)
+ {
+ if (_disposed)
+ return;
+ _selectedSkillId = skillId;
+ foreach (SkillRow row in _rows)
+ {
+ if (row.NameText is { } nameText)
+ nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
+ }
+ if (_bindings.View() is { } view)
+ RefreshInfoBox(view, view.Snapshot);
+ }
+
+ ///
+ /// gmCGSkillsPage::ShowSkillsText @0x00481250 — writes
+ /// m_pInfoBoxTitle (0x100003fb) and m_pInfoBoxText
+ /// (0x100003fc) for the currently selected skill, or clears both
+ /// 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
+ /// ("Training Bonus +5"/"Specialization Bonus +10" —
+ /// TWO spaces before the number, matching the compiled literal
+ /// verbatim) only.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
+ {
+ if (_selectedSkillId is not { } skillId)
+ {
+ ClearInfoBox();
+ return;
+ }
+
+ ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
+ uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u;
+ string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
+
+ if (_infoTitle is { } title)
+ SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})");
+
+ if (_infoText is { } text)
+ {
+ string bonus = level switch
+ {
+ ChargenSkillAdvancementClass.Trained => "Training Bonus +5",
+ ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
+ _ => string.Empty,
+ };
+ SetLine(text, bonus);
+ }
+ }
+
+ private void ClearInfoBox()
+ {
+ if (_infoTitle is { } title) SetLine(title, string.Empty);
+ if (_infoText is { } text) SetLine(text, string.Empty);
+ }
+
public void Dispose()
{
if (_disposed)
@@ -310,6 +568,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
+ if (row.Root is UiDatElement datRow) datRow.OnClick = null;
}
_rows.Clear();
_list?.Flush();
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
index b1ea5e84..435efab7 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
@@ -1137,6 +1137,66 @@ public sealed class CharacterCreationLiveDatTests
Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId);
}
+ ///
+ /// R2-4c (Campaign CC gate round 1 Batch F). Live-DAT probe: pins the
+ /// Skills listbox's own authored scrollbar link (dat property
+ /// 0x72, ) —
+ /// CONFIRMED as 0x100003F8, exactly the "+1 from the listbox"
+ /// hypothesis this batch's own investigation raised — and confirms a
+ /// real resolves at that id under the Skills
+ /// page root, the exact fact
+ /// 's constructor now wires
+ /// (scrollbar.Model = list.Scroll).
+ ///
+ ///
+ /// Also pins Templates[0]'s (0x100002F4) own header-
+ /// caption child id (0x100002f6, DoSkillRecords @0x00481840's
+ /// own GetChildRecursive call) as prep evidence for whoever ports
+ /// the four-bucket sorted model (R2-4b, still open — see the AP-213
+ /// row): live-DAT-measured as a , NOT a
+ /// — the SAME UIElement_Button-is-
+ /// DynamicCast(0xc)-compatible-with-UIElement_Text quirk
+ /// this campaign already ported for the six attribute-slider labels
+ /// (GF-4b) — retail's own DynamicCast(0xc) cast at
+ /// 0x00481855 would return null on a REAL Button object
+ /// otherwise, and the very next line unconditionally calls
+ /// UIElement_Text::SetStringInfo on it. A future port reads the
+ /// header caption through UiButton.Label, the same seam GF-4b
+ /// already established.
+ ///
+ ///
+ [InstalledDatFact]
+ public void SkillsPage_Listbox_HasAScrollbarLink_AndHeaderTemplateHasACaptionChild()
+ {
+ using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
+ uint layoutId = RetailDataIdResolver.Resolve(
+ dats, CharacterCreationUiController.RootEnum, 5u);
+ ImportedLayout screen = BuildSelected(
+ dats, layoutId, CharacterCreationUiController.RootElementId);
+
+ UiElement skillsRoot = Assert.IsAssignableFrom(
+ screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
+ UiTemplateListBox list = Assert.IsType(
+ UiElement.FindDescendant(skillsRoot, 0x100003F7u));
+
+ Console.WriteLine(
+ $"[CC-Batch-F-DAT] Skills listbox ScrollbarElementId=0x{list.ScrollbarElementId:X8}");
+ Assert.Equal(0x100003F8u, list.ScrollbarElementId);
+ Assert.IsType(
+ UiElement.FindDescendant(skillsRoot, list.ScrollbarElementId));
+
+ UiTemplateListEntry headerTemplate = list.Templates[0];
+ Assert.Equal(0x100002F4u, headerTemplate.TemplateElementId);
+ UiElement? headerRow = LayoutImporter.Import(
+ dats,
+ headerTemplate.TemplateLayoutId,
+ headerTemplate.TemplateElementId,
+ _ => (0u, 0, 0),
+ null)?.Root;
+ UiElement realHeaderRow = Assert.IsAssignableFrom(headerRow);
+ Assert.IsType(UiElement.FindDescendant(realHeaderRow, 0x100002F6u));
+ }
+
///
/// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// during the investigation: the Message dialog catalog's popup
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
index 8790f9bb..089525d8 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
@@ -24,6 +24,13 @@ public sealed class CharacterCreationUiControllerTests
private const uint SkillTrainOnly = 1u;
private const uint SkillSpecializable = 2u;
+ /// Review fix round F2 (Batch F): a "free" skill (trained
+ /// cost 0 in Aluvian's own per-heritage cost list) — pins
+ /// SetSkillText's bUntrainable re-derivation
+ /// (trainedCost != 0), which locks the down arrow at Trained for
+ /// exactly this shape.
+ private const uint SkillFreeTrained = 3u;
+
[Fact]
public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage()
{
@@ -329,8 +336,9 @@ public sealed class CharacterCreationUiControllerTests
.OnClick!();
IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children;
- // Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2).
- Assert.Equal(2, rows.Count);
+ // Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
+ // SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
+ Assert.Equal(3, rows.Count);
UiElement row = Assert.Single(rows, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
@@ -341,11 +349,13 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level));
// Default (never-touched) level: up cost = trained cost (2), down
- // cost blank (nothing below Untrained/Inactive).
+ // cost = literal "0" (nothing below Untrained/Inactive, but
+ // SetSkillText @0x00480600's own Untrained branch writes a literal
+ // 0, unconditional — review fix round F1, Batch F).
UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("2", JoinedText(upCost));
- Assert.Equal(string.Empty, JoinedText(downCost));
+ Assert.Equal("0", JoinedText(downCost));
// Advancing to Trained flips the cost pair: up = specCost-trainCost
// (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a
@@ -361,6 +371,169 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal("2", JoinedText(downCost));
}
+ // ── Campaign CC gate round 1 Batch F: R2-4 + review F1/F2 ───────────
+
+ /// R2-4a: a plain row click selects the skill — the row's own
+ /// 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).
+ [Fact]
+ public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.Runtime.SelectHeritageDirect(AluvianId);
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ (UiDatElement row, UiText nameText) = environment.SkillRow(SkillTrainOnly);
+ Vector4 unselectedColor = nameText.DefaultColor;
+
+ // Nothing selected yet.
+ Assert.Equal(string.Empty, JoinedText(environment.SkillInfoTitle()));
+
+ row.OnClick!();
+
+ Assert.Equal(Vector4.One, nameText.DefaultColor);
+ Assert.NotEqual(unselectedColor, nameText.DefaultColor);
+
+ // FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10.
+ string expectedTitle =
+ $"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
+ Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
+ Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText()));
+ }
+
+ /// R2-4a: retail re-selects the row after an arrow click too
+ /// (ListenToElementMessage @0x004814c0's own
+ /// SetSelectedItem(...,1) call following
+ /// 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).
+ [Fact]
+ public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.Runtime.SelectHeritageDirect(AluvianId);
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ (UiButton up, _) = environment.SkillRowArrows(SkillTrainOnly);
+
+ up.OnClick!(); // Untrained/Inactive -> Trained.
+ BumpRevisionAndTick(environment);
+ Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText()));
+
+ up.OnClick!(); // Trained -> Specialized.
+ BumpRevisionAndTick(environment);
+ Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText()));
+ }
+
+ /// R2-4a: selecting a SECOND row restores the FIRST row's own
+ /// authored (unselected) color instead of leaving it stuck
+ /// highlighted.
+ [Fact]
+ public void SkillsPage_RowClick_DeselectsPreviousRow_RestoresItsOwnColor()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.Runtime.SelectHeritageDirect(AluvianId);
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ (UiDatElement firstRow, UiText firstName) = environment.SkillRow(SkillTrainOnly);
+ Vector4 firstUnselected = firstName.DefaultColor;
+ (UiDatElement secondRow, UiText secondName) = environment.SkillRow(SkillSpecializable);
+
+ firstRow.OnClick!();
+ Assert.Equal(Vector4.One, firstName.DefaultColor);
+
+ secondRow.OnClick!();
+ Assert.Equal(Vector4.One, secondName.DefaultColor);
+ Assert.Equal(firstUnselected, firstName.DefaultColor);
+ }
+
+ /// Review fix round F1: SetSkillText's Specialized
+ /// branch (@0x00480679) writes a literal "0" up-cost,
+ /// unconditional (nothing above Specialized needs the 999-blank gate),
+ /// and an UNCONDITIONAL down-cost — no gate even though this fixture's
+ /// value (4) happens to be well under 999.
+ [Fact]
+ public void SkillsPage_SpecializedCostText_UpCostIsLiteralZero_DownCostUnconditional()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.Runtime.SelectHeritageDirect(AluvianId);
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ environment.Runtime.View.SetSkillLevel(SkillSpecializable, ChargenSkillAdvancementClass.Specialized);
+ BumpRevisionAndTick(environment);
+
+ (UiElement row, _) = environment.SkillRow(SkillSpecializable);
+ UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u));
+ UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u));
+
+ Assert.Equal("0", JoinedText(upCost));
+ Assert.Equal("4", JoinedText(downCost)); // specCost(6) - trainCost(2).
+ }
+
+ /// Review fix round F2: the Up/Down arrow Ghosted
+ /// (0x1000001a)/Enabled (0x1000001b) state pair —
+ /// Untrained's Down is ALWAYS ghosted (nothing below it); Up is gated
+ /// on remainingSkillCredits; a "free" skill (trained cost 0)
+ /// locks its OWN down arrow at Trained
+ /// (bUntrainable re-derived as trainedCost != 0) and
+ /// unlocks it again once Specialized (specialized cost is non-zero),
+ /// where its own Up arrow is then ALWAYS ghosted (nothing above
+ /// Specialized).
+ [Fact]
+ public void SkillsPage_ArrowStates_GatedOnCreditsAndFreeSkillLocksDownArrow()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.Runtime.SelectHeritageDirect(AluvianId);
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ // Untrained/Inactive: Down ALWAYS ghosted; Up enabled (credits 50
+ // >= trainedCost 2).
+ (UiButton up, UiButton down) = environment.SkillRowArrows(SkillTrainOnly);
+ Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
+ Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
+
+ up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
+ BumpRevisionAndTick(environment);
+ Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
+
+ (UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
+ freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
+ BumpRevisionAndTick(environment);
+ Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
+
+ freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
+ BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
+ Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
+ Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
+ }
+
+ /// R2-4c: the listbox's own authored scrollbar link
+ /// () is wired to the
+ /// SAME listbox's model — the
+ /// ordinary page-level UiScrollbar.Model linkage, no widget
+ /// change.
+ [Fact]
+ public void SkillsPage_ListboxScrollbar_IsLinkedToTheListsOwnScroll()
+ {
+ using var environment = new EnvironmentHarness();
+ environment.Controller.Open();
+ environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
+
+ UiScrollbar scrollbar = environment.SkillsScrollbar();
+ Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
+ }
+
[Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
@@ -1804,6 +1977,33 @@ public sealed class CharacterCreationUiControllerTests
return (up, down);
}
+ /// R2-4a (Batch F): locates a built skill row's ROOT
+ /// element (Templates[1]'s own UiDatElement) by its
+ /// name text, for row-CLICK (not arrow-click) selection tests.
+ /// Also returns the row's own name so a test
+ /// can assert its selection
+ /// highlight.
+ public (UiDatElement Row, UiText NameText) SkillRow(uint skillId)
+ {
+ string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId);
+ UiElement row = Assert.Single(
+ SkillsList().ViewportForTest!.Children,
+ candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
+ && JoinedText(name) == skillName);
+ UiDatElement datRow = Assert.IsType(row);
+ UiText nameText = Assert.IsType(UiElement.FindDescendant(row, 0x10000301u));
+ return (datRow, nameText);
+ }
+
+ public UiText SkillInfoTitle() =>
+ Assert.IsType(Screen.FindElement(0x100003FBu));
+
+ public UiText SkillInfoText() =>
+ Assert.IsType(Screen.FindElement(0x100003FCu));
+
+ public UiScrollbar SkillsScrollbar() =>
+ Assert.IsType(Screen.FindElement(0x100003F8u));
+
public UiTemplateListBox SummaryListBox() =>
Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
@@ -2171,6 +2371,7 @@ public sealed class CharacterCreationUiControllerTests
{
[SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6),
[SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6),
+ [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
};
var aluvian = new ChargenHeritageOptions(
@@ -2406,6 +2607,12 @@ public sealed class CharacterCreationUiControllerTests
Y = 40f,
Width = 300f,
Height = 320f,
+ // R2-4c (Batch F): the listbox's own authored scrollbar link
+ // (dat property 0x72) — an arbitrary but plausible sibling id
+ // (retail's own "+1 from the listbox" convention, matching the
+ // hypothesis this batch's own investigation raised) since no
+ // live-DAT probe has pinned the real installed value yet.
+ ScrollbarElementId = 0x100003F8u,
};
// GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row
// (0x100002F4, unused by this port's flat-list simplification);
@@ -2415,6 +2622,7 @@ public sealed class CharacterCreationUiControllerTests
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu));
page.Children.Add(list);
+ page.Children.Add(ScrollbarInfo(0x100003F8u));
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu));
page.Children.Add(TextInfo(0x100003FCu));
@@ -2552,6 +2760,24 @@ public sealed class CharacterCreationUiControllerTests
/// a bare button shape since this port's flat-list simplification never
/// resolves it.
///
+ /// Review fix round F2 (Batch F): retail's own custom
+ /// Ghosted(0x1000001a)/Enabled(0x1000001b) state pair for
+ /// pSkillUpButton/pSkillDownButton — mirrors
+ /// CharacterCreationSkillsPage's own (private)
+ /// ArrowGhostedStateId/ArrowEnabledStateId consts so
+ /// TrySetRetailState has real, matching state descriptors to
+ /// resolve against (the raw-numeric-id lookup path,
+ /// UiButton.TryFindState) — the SAME "author arbitrary numeric
+ /// states directly" pattern already
+ /// uses for its own custom pair.
+ private static ElementInfo ArrowButtonInfo(uint id)
+ {
+ ElementInfo info = ButtonInfo(id);
+ info.States[0x1000001Au] = new UiStateInfo { Id = 0x1000001Au, Name = "ArrowGhosted" };
+ info.States[0x1000001Bu] = new UiStateInfo { Id = 0x1000001Bu, Name = "ArrowEnabled" };
+ return info;
+ }
+
private static UiElement BuildSkillRowTemplate(uint templateElementId)
{
if (templateElementId == 0x100002FFu)
@@ -2563,12 +2789,19 @@ public sealed class CharacterCreationUiControllerTests
Width = 280f,
Height = 16f,
};
+ // R2-4a (Batch F): an explicit, distinguishable unselected
+ // color (retail's own gold list-caption tone, the same
+ // 218,167,85 GF-11b measured) so selection tests can tell
+ // CharacterCreationSkillsPage.SelectedNameColor (pure white)
+ // apart from a row's own authored default.
+ ElementInfo nameInfo = TextInfo(0x10000301u);
+ nameInfo.FontColor = new System.Numerics.Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f);
row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop
- row.Children.Add(TextInfo(0x10000301u)); // name
+ row.Children.Add(nameInfo); // name
row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText
row.Children.Add(TextInfo(0x10000303u)); // pUpCostText
- row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton
- row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton
+ row.Children.Add(ArrowButtonInfo(0x10000304u)); // pSkillUpButton
+ row.Children.Add(ArrowButtonInfo(0x10000305u)); // pSkillDownButton
row.Children.Add(TextInfo(0x10000306u)); // pDownCostText
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
}