From 2349f8b4dfa68312a107c86f0be1188216f5b707 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 12:34:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20Batch=20C=20=E2=80=94=20Summary=20how-to=20+=20scrollbar=20?= =?UTF-8?q?linkage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3/3: Summary how-to text + Commit 2's owed scrollbar linkage + bookkeeping sweep. - Ports gmCGSummaryPage::SetHowToText @0x0047ae20 into the Summary page's how-to box (0x10000404, HowToTextId was declared and unused since CC5). Retail concatenates ID_CharGen_SummaryHowTo + a heritage/ gender-specific name-suggestion list (heritages 1-4 — Aluvian/ Gharundim/Sho/Viamontian — only; heritages 5-13's cases in the same switch decompile to a vtable-slot artifact, the same decompiler- mangled-symbol class the Heritage page's own BonusSkillsKeyByHeritage table already documents, so no name-suggestion string exists for them and none is invented) + ID_CharGen_SummaryHowToEnd, directly concatenated (no separator literal) into ONE plain SetText call — no per-run font/color argument, unlike Heritage's ...WithFont calls, so this routes through DatRichText as a single DefaultColor segment. - Wires the description boxes' linked scrollbar to actual text scrolling — Commit 2 made the scrollbar child (0x100002e7) BUILD as a real UiScrollbar; this binds scrollbar.Model = text.Scroll, the exact pattern ChatWindowController already uses for the chat transcript. Live-DAT-measured: only Heritage's description (0x100003c4) and Summary's how-to box (0x10000404) actually author this child — Profession/Town's shorter description boxes do not (a genuine retail authoring fact, not something to "fix" further). Register: AP-215/AP-216/AP-217 rewritten (Batch C's Commit 1 already retired AP-218/AD-103) — no further changes needed this commit; ISSUES #366 (chat's new-unseen-text indicator, 0x1000048C under the chat transcript 0x10000011) NARROWED — its own pre-filed "fix shape" recommendation (a UiText child carve-out mirroring UiMeter's) is EXACTLY what Commit 2 shipped, confirmed by that commit's own client-wide sweep; #366 stays open for the still-missing behavioral half (no controller drives the indicator's visibility/click). Findings doc updated: GF-2/GF-3/GF-4/GF-6/GF-11a/GF-12/GF-14's text half all marked FIXED with their own root-cause notes; the two remaining "suspected shared roots" (frames/labels, rich text) marked CONFIRMED + CLOSED. Full App suite (Debug and Release, live-DAT): 5307 passed / 0 failed / 3 skipped (up from 5304 after Commit 2). Runtime suite: 1735/0, unaffected. Campaign CC gate round 1 Batch C is CODE-COMPLETE across all three commits — GF-2, GF-3, GF-4, GF-6, GF-11a, GF-12, and GF-14's text half are fixed; AP-216/AP-217 partially closed (register-honest about what shipped vs what needs a palette-to-RGB pipeline this batch didn't add). Pending the user's visual gate, with chat + the main game UI flagged for extra attention (Commit 2's client-wide blast radius). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 40 +++-- ...-08-16-campaign-cc-gate-round1-findings.md | 151 +++++++++++++++--- .../Layout/CharacterCreationHeritagePage.cs | 13 ++ .../UI/Layout/CharacterCreationSummaryPage.cs | 95 +++++++++++ .../Layout/CharacterCreationLiveDatTests.cs | 76 +++++++++ .../CharacterCreationUiControllerTests.cs | 61 +++++++ 6 files changed, 391 insertions(+), 45 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 307ebde0..c405ab0e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1791,29 +1791,25 @@ controllers read instead of the main window's private field. ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired -**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main -chat window authors a 16×16 "new unseen text" indicator button -(`0x1000048C`, base `0x10000527`/`0x21000040`) as a CHILD of the transcript -text element `0x10000011` (position `(0,57)` relative to the transcript, -i.e. bottom-left of the transcript pane), confirmed in the `0x2100006F` -LayoutDesc dump. `UiText.ConsumesDatChildren` is `true` (Type-12 behavioral -widgets reproduce their dat sub-elements procedurally per -`DatWidgetFactory`'s own doc comment), so `LayoutImporter.BuildWidget` -never builds `0x1000048C` as a separate widget — it is silently swallowed, -same as under the wrong `0x21000006` layout before it (not a CH6a -regression). No controller anywhere binds or drives its visible state. -**Not in CH6a's scope** (transcript/input/scrollbar/1-4-buttons only) and -not obviously CH6b/CH6c's either — files here as a standalone gap. Fix -shape: either give `UiText` an opt-in mechanism to keep specific named -non-Type-3 children (mirroring `UiMeter`'s existing text-overlay carve-out -in `DatWidgetFactory.BuildWidget`), or handle `0x1000048C` as a special -case the same way. Needs research first: what triggers retail's "new text" -indicator (unread-since-scroll-position?) and what it visually does on -click — not decoded by CH6a. +**Status:** OPEN, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C +Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is +now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField` +media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay +carve-out, EXACTLY the shape this issue proposed) as part of a chargen +description-box fix; the client-wide blast-radius sweep that fix's own +tests run +(`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`) +independently re-confirmed `0x1000048C` under `0x10000011` in layout +`0x2100006F` as one of the affected elements — it now builds as a real +widget instead of being silently swallowed. **Still open:** no controller +binds or drives its visible state (STILL the original ask — what triggers +retail's "new text" indicator, and what it does on click, remains +un-researched); this issue stays open for that behavioral half. -**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs`; -`src/AcDream.App/UI/Layout/LayoutImporter.cs` -(`BuildWidget`/`ConsumesDatChildren` handling); `src/AcDream.App/UI/UiText.cs`. +**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior, +still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs` +(`BuildWidget`'s new `UiText or UiField` carve-out — CLOSED the build half); +`src/AcDream.App/UI/UiText.cs`. ## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox 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 73f0bd66..ddd591b8 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 @@ -15,6 +15,32 @@ Fixture + live-DAT tests only this round (no graphical client launch); App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register: AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open). +**Batch C (text/frame/label fidelity, the largest visual batch) is +CODE-COMPLETE 2026-08-16, pending the user's visual gate.** GF-2, GF-3, +GF-4, GF-6, GF-11a, GF-12, and GF-14's text half are fixed — see each +entry's own FIXED note below. Three commits: (1) chargen-scoped rich +text + labels + backdrops (new shared `DatRichText` composer; `UiButton` +gains a coexisting `ValueLabel` slot; Heritage/Profession backdrop +`SetState` cascades; AP-216/AP-217 partially closed — the "beyond count"/ +"Eyes-blank" halves ship, the palette-to-RGB "actual color"/"gradient +tint" halves stay open, judged disproportionate to add alongside this +batch's ~10 other fixes), (2) a CLIENT-WIDE `LayoutImporter` fix +un-consuming media-bearing dat children on `UiText`/`UiField` (37 distinct +(layout, element) pairs across 15 layouts, independently re-derived — +includes MAIN GAME UI and CHAT INPUT, closing the build half of pre-filed +issue #366), (3) the Summary how-to text +(`gmCGSummaryPage::SetHowToText`) plus the scrollbar-to-text-scroll +linkage Commit 2 left unbound. Fixture + live-DAT tests only (no +graphical client launch); App suite 5307/3 (was 5282/3, +25 tests, one +pre-existing full-suite-only allocation flake unrelated to this batch — +passes in isolation and in the full Release run), Runtime 1735/0 +unchanged. Register: AP-215/AP-216/AP-217 rewritten, AP-218 retired, +AD-103 retired. **FLAG FOR THE LEAD: chat and the main game UI both +render new dat children (gold frames, an unseen-text indicator) for the +first time this batch — the user's own visual check of both is owed +before considering Commit 2 closed; automated coverage cannot catch a +purely visual placement regression.** + 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 @@ -86,7 +112,17 @@ ISSUES.md; this doc is the six-page batch. exactly one overlay visible per part, tracking the current part's selected color index; retires AP-215's swatch-selection substitution (item 1 — the icon-vs-ordinal item 2 stays open). -- **GF-11a Town description text does not change** when switching towns. +- **GF-11a Town description text does not change when switching towns — + FIXED (Batch C, Commit 1).** The composed string + (`gmCGTownPage::SetTownString @0x0047c1f0`'s `howTo + "\n\n" + townText + + "\n"` — this ONE composition site is where retail's OWN code, not the + authored DAT string content, inserts the separator, confirmed via the + compiled format literal's raw bytes `u"\n\n%s\n"`) was already + byte-correct; the real gap was rendering it as a single un-wrapped line, + so the town-specific SUFFIX rendered past the clipped viewport — + switching towns changed the underlying string but not what was visibly + on screen. Fixed by routing through `DatRichText.Compose` (same fix + family as GF-2). - **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80` @@ -151,20 +187,55 @@ ISSUES.md; this doc is the six-page batch. ## Presentation families (retail parity) -- **GF-2 Description textboxes broken everywhere.** acdream renders the - raw string with LITERAL `\n` escapes, one truncated line, no wrap, no - scroll, no frame. Retail: framed scrollable textbox, multi-paragraph, - colored section headers (green "Trained Starting Skills:" etc.), - scrollbar + arrows (screenshot 1 right panel). -- **GF-3 Profession template description textbox missing** (retail bottom- - left panel, "LIFE CASTERS are experts…" — screenshot 3). -- **GF-4 Profession labels missing:** "Attribute Credits" caption + value, - per-attribute name labels (Strength…Self), Health/Stamina/Mana labels + - values. Sliders and template selection themselves WORK. -- **GF-6 Appearance spin captions are numbers,** not part names - ("Hair Style", "Eyes", …). Known rows AP-215 (item 2 — item 1, the - swatch-selection substitution, RETIRED at Batch B/GF-9)/AP-218 — the - gate promotes them to must-port. +- **GF-2 Description textboxes broken everywhere — FIXED (Campaign CC gate + round 1, Batch C, Commit 1 + Commit 2).** Root cause was TWO stacked + gaps, both closed: (1) the Heritage/Town/Profession description pages + bypassed escape-normalize + word-wrap entirely, assigning a raw + unwrapped single-`Line` `LinesProvider` — fixed by routing every + description box through the new shared `DatRichText.Compose` helper + (ports `UIElement_Text::SetStringInfoWithFont`/`AppendStringInfoWithFont`'s + composition model: escape-normalize, per-segment word-wrap, per-segment + palette color — Heritage's own header/body segments now render in + retail's own green/white, matching `AppendStringInfoWithFont`'s font-index + argument). (2) The authored gold frame (8 pieces) and linked scrollbar + were silently dropped by `UiText.ConsumesDatChildren` — fixed by + Commit 2's client-wide `LayoutImporter` carve-out (see GF-12). Both + halves are pinned by live-DAT tests (`CharacterCreationLiveDatTests`, + `LayoutImporterMediaBearingChildSweepTests`) and unit tests + (`DatRichTextTests`). +- **GF-3 Profession template description textbox missing — FIXED (Batch C, + Commit 1).** `gmCGProfessionPage::InitializePage @0x00483068`'s + `m_pTextBox` (`0x100003e0`) was never bound. Fixed: + `CharacterCreationProfessionPage` now binds it and composes the + per-template string (`ID_CharGen_CustomText`/`BowText`/`SwashText`/ + `LifeText`/`WarText`/`WayText`/`SoldierText`, `UpdateProfession + @0x004821b0`'s per-case literal, plain `SetStringInfo` — one color, no + palette) through the same `DatRichText` helper. +- **GF-4 Profession labels missing — FIXED (Batch C, Commit 1).** Two + distinct mechanisms, both closed: (a) the four display buttons + (avail/health/stamina/mana credits) author their caption directly as + their own P0x17 AND carry a separate media-less Type-12 value child that + `UiButton.ConsumesDatChildren` used to drop entirely — pages substituted + the button's own `.Label`, destroying the caption. Fixed by giving + `UiButton` a coexisting `ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` + slot (`DatWidgetFactory.BuildButton`, gated on the button's own P0x17 + caption existing), so the caption and the live value now render + independently — the same fix also closes the Skills page's credits + badge. (b) The six slider name labels (`0x100002ed`, live-DAT-measured + as `UIElement_Button` — retail's `UIElement_Button` is DynamicCast(0xc)- + compatible with `UIElement_Text`) are now written once at construction + with `CharGenState::GetAttributeName @0x005C3A20`'s six hardcoded + literals, matching retail's own single `InitializePage`-time write + (never re-written on refresh, same as retail). +- **GF-6 Appearance spin captions are numbers — FIXED (Batch C, Commit 1), + retiring AP-218.** `gmCGAppearancePage::Update @0x0047e8f0` writes a + heritage-flavored STATIC caption to the Hair/Eyes/Skin spins only + (normal / `GearText_*` / `OlthoiText_*` variants) — never an index, and + never touches the other six spins' own DAT-authored caption at all. + Removed the prior 1-based-ordinal/gear-name substitution outright. + AP-215's own icon-thumbnail item (the four icon-only spins still show no + per-choice icon art — a DIFFERENT, still-open gap) is rewritten, not + retired — see that row. - **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); retail's chargen 3D view shows a scenic backdrop. (The Heritage-page preview area shows terrain in BOTH clients — establish from the decomp @@ -218,19 +289,53 @@ ISSUES.md; this doc is the six-page batch. caption carries its own rect, the label draws within THAT box using its own authored justify instead of the face-relative offset; every other button (`LabelBox` null) keeps the EXACT prior draw math. -- **GF-12 Missing authored gold frames** around boxes on every page - (Skills/Appearance/Town/Summary called out explicitly). -- **GF-14 Summary paperdoll backdrop black** (same family as GF-7); - Summary textbox wrapper + scrollbar missing (GF-2 family). +- **GF-12 Missing authored gold frames around boxes — FIXED (Batch C, + Commit 2).** Root cause: `UiText.ConsumesDatChildren` (true unless a + state authors PassToChildren) and `UiField.ConsumesDatChildren` + (unconditionally true) dropped EVERY dat child at import time, + including the eight gold-frame pieces (`0x100002DE..E3`, + `0x100000E8/EA`) every description/report box authors. Fixed by a new + `LayoutImporter.BuildWidget` carve-out (mirroring the existing `UiMeter` + text-overlay carve-out): build any child with its OWN non-empty + `StateMedia`, leaving purely structural/property-only children dropped + as before. Independently re-derived blast radius: **37 distinct + (layout, element) pairs across 15 layouts** (see the commit message for + the full enumeration), including MAIN GAME UI (`0x21000005/0x1000059A`) + and CHAT INPUT (`0x2100006F/0x10000011` — closing the BUILD half of + pre-filed issue #366's own "fix shape" recommendation, which proposed + this EXACT carve-out). Full App suite (5304 tests): zero regressions. + **The user's own visual check of chat + the main game UI is still owed** + — automated coverage cannot catch a purely visual placement regression. +- **GF-14 Summary paperdoll backdrop black** (same family as GF-7, + UNCHANGED, out of this batch's scope — the 3-D preview backdrop, not a + text-widget gap). **Summary textbox wrapper + scrollbar — FIXED (Batch + C, Commit 2 for the frame/build half, Commit 3 for the scrollbar LINK + and the how-to text's own content — see the Suspected-shared-roots + entry and Commit 3's own composition of `gmCGSummaryPage::SetHowToText` + into `0x10000404`).** - **GF-16 Hover tooltips missing on all pages** (retail pops tooltips). ## Suspected shared roots (to be CONFIRMED by the investigation, not assumed) -1. Missing frames/labels/statics across every page (GF-3, GF-4 labels, +1. ~~Missing frames/labels/statics across every page (GF-3, GF-4 labels, GF-12) — one importer/mount-level gap OR retail writes them at runtime; - decide per element from the authored DAT + decomp. -2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap - feeding GF-2/GF-3/GF-11a/GF-14. + decide per element from the authored DAT + decomp.~~ CONFIRMED, CLOSED + at Batch C: TWO distinct mechanisms, both a single shared fix each. (a) + GF-3/GF-4's labels were runtime-composition gaps (unbound description + textbox; a value-write clobbering a caption) — fixed per-page, Commit 1. + (b) GF-12's frames were the importer-level gap the investigation + suspected: `LayoutImporter.BuildWidget`'s `ConsumesDatChildren` handling + dropped every dat child of a `UiText`/`UiField`, client-wide — fixed by + the new media-bearing-child carve-out, Commit 2. +2. ~~Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap + feeding GF-2/GF-3/GF-11a/GF-14.~~ CONFIRMED, CLOSED at Batch C: the new + shared `DatRichText.Compose` helper (escape-normalize + per-segment + word-wrap + per-segment palette color, Commit 1) plus the Commit-2 frame/ + scrollbar un-consume plus Commit-3's scrollbar-to-text-scroll linkage + (`UiScrollbar.Model = text.Scroll`, the same pattern + `ChatWindowController` already used) together close the WHOLE family — + GF-2/GF-3/GF-11a/GF-14's text half are all FIXED; GF-14's backdrop half + (GF-7 family) is unrelated and stays open. 3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, GF-10 zoom art) — the AP-222 measured mechanism (state media authored vs applied) across widget kinds.~~ CLOSED, split into TWO distinct diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 7a79b465..0a2a03cb 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -135,6 +135,19 @@ internal sealed class CharacterCreationHeritagePage : IDisposable _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; _backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu); + + // Commit 2/3 follow-up (Campaign CC gate round 1 Batch C): the + // description box's own linked scrollbar — live-DAT-measured + // present here (unlike Profession/Town's shorter description + // boxes, which author no scrollbar child at all) at the SAME + // relative id CharacterCreationSummaryPage's how-to box carries. + // ChatWindowController's own scrollbar.Model = transcript.Scroll + // pattern, scoped to this box's own descendant. + if (_description is not null + && UiElement.FindDescendant(_description, 0x100002E7u) is UiScrollbar descriptionScroll) + { + descriptionScroll.Model = _description.Scroll; + } } internal void Refresh( diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index 94545683..c90a3943 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -76,11 +76,48 @@ internal sealed class CharacterCreationSummaryPage : IDisposable /// private const int MaxNameLength = 32; + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): the how-to box's own + /// linked scrollbar, relative id — live-DAT-measured as the SAME + /// template child id the Heritage description box also carries + /// (0x100002e7), distinct from (the + /// listbox's own scrollbar). Profession/Town's description boxes do + /// NOT author this child at all (shorter authored text, live-DAT- + /// confirmed) — only Heritage and Summary's how-to box do. + /// + private const uint HowToScrollRelativeId = 0x100002E7u; + + /// + /// Heritage id -> (male name-list key, female name-list key) per + /// gmCGSummaryPage::SetHowToText @0x0047ae20's switch + /// (@0x0047aeb2-0x0047afda). ONLY heritages 1-4 (Aluvian/Gharundim/ + /// Sho/Viamontian) resolve to real string literals + /// ("ID_CharGen_<Abbrev>{Male,Female}Names", confirmed + /// present in the compiled string-constant table); every other + /// heritage's case in that same switch (5/0xa Shadowbound+Penumbraen + /// share one body, 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 0xb + /// Undead, 0xc/0xd Olthoi/OlthoiAcid) decompiles to a vtable-slot + /// artifact instead of a string constant — the same decompiler- + /// mangled-symbol class the Heritage page's own + /// BonusSkillsKeyByHeritage table already documents — meaning + /// no real name-suggestion string exists for those heritages; this + /// port does not invent one. + /// + private static readonly IReadOnlyDictionary NameSuggestionKeysByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = ("ID_CharGen_AluMaleNames", "ID_CharGen_AluFemaleNames"), + [(uint)ChargenHeritageGroup.Gharundim] = ("ID_CharGen_GharuMaleNames", "ID_CharGen_GharuFemaleNames"), + [(uint)ChargenHeritageGroup.Sho] = ("ID_CharGen_ShoMaleNames", "ID_CharGen_ShoFemaleNames"), + [(uint)ChargenHeritageGroup.Viamontian] = ("ID_CharGen_ViaMaleNames", "ID_CharGen_ViaFemaleNames"), + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly RetailDialogFactory _dialogs; private readonly string _nameTooLongMessage; private readonly UiTemplateListBox? _list; private readonly UiField? _nameField; + private readonly UiText? _howToText; private string _lastCommittedName = string.Empty; private uint _nameTooLongDialogContext; private bool _disposed; @@ -145,6 +182,19 @@ internal sealed class CharacterCreationSummaryPage : IDisposable } Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport; + + // Commit 2/3 follow-up: the how-to box's linked scrollbar (Commit + // 2 made it BUILD as a real UiScrollbar; this wires it to actual + // scrolling) — ChatWindowController's own scrollbar.Model = + // transcript.Scroll pattern, scoped to THIS box's own descendant + // (the relative id recurs on Heritage's description box too, so a + // flat screen-wide lookup would be ambiguous). + _howToText = UiElement.FindDescendant(pageRoot, HowToTextId) as UiText; + if (_howToText is not null + && UiElement.FindDescendant(_howToText, HowToScrollRelativeId) is UiScrollbar howToScroll) + { + howToScroll.Model = _howToText.Scroll; + } } internal void Refresh( @@ -176,6 +226,51 @@ internal sealed class CharacterCreationSummaryPage : IDisposable RebuildListbox(view, snapshot); RebuildPreview(view, snapshot); + RefreshHowToText(snapshot); + } + + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): ports + /// gmCGSummaryPage::SetHowToText @0x0047ae20. Retail + /// concatenates ID_CharGen_SummaryHowTo + + /// (heritage/gender-specific name-suggestion list, heritages 1-4 + /// only) + ID_CharGen_SummaryHowToEnd directly + /// (append_n_chars, no separator literal) into ONE plain + /// UIElement_Text::SetText — no per-run font/color argument, + /// unlike Heritage's ...WithFont calls, so this is a single + /// segment. + /// + private void RefreshHowToText(RuntimeCharacterCreationSnapshot snapshot) + { + if (_howToText is null) + return; + + Func? resolveText = _bindings.ResolveText; + if (resolveText is null) + return; + + var builder = new System.Text.StringBuilder(); + if (resolveText("ID_CharGen_SummaryHowTo") is { } howTo) + builder.Append(howTo); + if (NameSuggestionKeysByHeritage.TryGetValue(snapshot.HeritageId, out (string Male, string Female) keys)) + { + // gmCGSummaryPage::SetHowToText @0x0047af3f et al.: the raw + // "!= 2" comparison, no gender-unset special case — an unset + // gender (0) takes the male-key branch, matching retail's own + // literal comparison. + string key = snapshot.GenderKey == 2u ? keys.Female : keys.Male; + if (resolveText(key) is { } nameTokens) + builder.Append(nameTokens); + } + if (resolveText("ID_CharGen_SummaryHowToEnd") is { } howToEnd) + builder.Append(howToEnd); + + if (builder.Length == 0) + return; + + string composed = builder.ToString(); + var segments = new[] { new DatRichText.Segment(composed, _howToText.DefaultColor) }; + _howToText.LinesProvider = () => DatRichText.Compose(_howToText, segments); } // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index e8fc405e..892439fe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -542,6 +542,35 @@ public sealed class CharacterCreationLiveDatTests "ID_CharGen_ShoushiText", "ID_CharGen_YaraqText", "ID_CharGen_SanamarText", + // GF-3: Profession template description keys. + "ID_CharGen_CustomText", + "ID_CharGen_BowText", + "ID_CharGen_SwashText", + "ID_CharGen_LifeText", + "ID_CharGen_WarText", + "ID_CharGen_WayText", + "ID_CharGen_SoldierText", + // GF-6/AP-218: Appearance spin caption keys. + "ID_CharGen_HairStyle", + "ID_CharGen_Eyes", + "ID_CharGen_Skin", + "ID_CharGen_GearText_HairButton", + "ID_CharGen_GearText_EyesButton", + "ID_CharGen_GearText_SkinButton", + "ID_CharGen_OlthoiText_HairButton", + "ID_CharGen_OlthoiText_EyesButton", + "ID_CharGen_OlthoiText_SkinButton", + // Commit 3: Summary how-to text keys. + "ID_CharGen_SummaryHowTo", + "ID_CharGen_SummaryHowToEnd", + "ID_CharGen_AluMaleNames", + "ID_CharGen_AluFemaleNames", + "ID_CharGen_GharuMaleNames", + "ID_CharGen_GharuFemaleNames", + "ID_CharGen_ShoMaleNames", + "ID_CharGen_ShoFemaleNames", + "ID_CharGen_ViaMaleNames", + "ID_CharGen_ViaFemaleNames", ]; foreach (string key in keys) { @@ -1239,6 +1268,53 @@ public sealed class CharacterCreationLiveDatTests Assert.IsType(UiElement.FindDescendant(summaryHowTo, 0x100002E7u)); } + /// + /// Commit 3: the Heritage description box (which ALSO carries the + /// linked scrollbar — live-DAT-measured, unlike Profession/Town's + /// shorter description boxes) resolves it as a real widget too, and + /// the mount wires to the box's own + /// . + /// + [InstalledDatFact] + public void HeritageDescription_ScrollbarBuildsAndLinksToTextScroll() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiText description = Assert.IsType( + UiElement.FindDescendant(heritageRoot, 0x100003C4u)); + UiScrollbar scroll = Assert.IsType( + UiElement.FindDescendant(description, 0x100002E7u)); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, _ => default, _ => default, (_, _) => default, (_, _) => default, + _ => default, _ => default, _ => default, _ => default, _ => default, () => { }); + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); + Assert.NotNull(controller); + controller!.AttachAndTick(); + + Assert.Same(description.Scroll, scroll.Model); + + controller.Dispose(); + dialogs.Dispose(); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 91b98caf..8790f9bb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1663,6 +1663,66 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId); } + /// Commit 3: the Summary how-to text concatenates HowTo + + /// the heritage/gender name-suggestion list (heritages 1-4 only) + + /// HowToEnd, in that order, no separator inserted by code. + [Fact] + public void SummaryHowToText_ComposesHowToPlusNameSuggestionsPlusHowToEnd_ForNamedHeritages() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO."; + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND."; + environment.Runtime.ResolvedStrings["ID_CharGen_AluMaleNames"] = "Alucard, Aldric"; + environment.Runtime.ResolvedStrings["ID_CharGen_AluFemaleNames"] = "Alura, Aldyth"; + environment.Controller.Open(); + + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.Runtime.SelectGenderDirect(1u); // male + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiText howTo = Assert.IsType( + environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId)); + string composed = JoinedText(howTo); + Assert.Contains("HOWTO.", composed); + Assert.Contains("Alucard, Aldric", composed); + Assert.DoesNotContain("Alura, Aldyth", composed); + Assert.Contains("HOWTOEND.", composed); + // No code-inserted separator: HowTo's own tail must be immediately + // followed by the name-list segment's own head, character for + // character (only whichever whitespace the AUTHORED strings + // themselves carry — none, in this test's fixture strings). + Assert.Contains("HOWTO.Alucard, Aldric", composed.Replace("\n", string.Empty)); + + environment.Runtime.SelectGenderDirect(2u); // female + BumpRevisionAndTick(environment); + string femaleComposed = JoinedText(howTo); + Assert.Contains("Alura, Aldyth", femaleComposed); + Assert.DoesNotContain("Alucard, Aldric", femaleComposed); + } + + /// Heritages without a real retail name-suggestion string + /// (5-13, the decompiler-artifact cases) compose HowTo directly + /// against HowToEnd — no invented text, no crash. + [Fact] + public void SummaryHowToText_SkipsNameSuggestions_ForHeritagesWithNoRealString() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO."; + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND."; + environment.Controller.Open(); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Undead); + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiText howTo = Assert.IsType( + environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId)); + string composed = JoinedText(howTo); + Assert.Contains("HOWTO.", composed); + Assert.Contains("HOWTOEND.", composed); + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -1897,6 +1957,7 @@ public sealed class CharacterCreationUiControllerTests public Dictionary ResolvedStrings { get; } = []; public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); + public void SelectGenderDirect(uint genderKey) => SelectGender(genderKey); public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => View.GetSkillLevel(skillId);