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; }