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