fix(chargen): Campaign CC gate round 1 Batch C — Summary how-to + scrollbar linkage

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 12:34:38 +02:00
parent 5190e16915
commit 2349f8b4df
6 changed files with 391 additions and 45 deletions

View file

@ -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<UiScrollbar>(UiElement.FindDescendant(summaryHowTo, 0x100002E7u));
}
/// <summary>
/// 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 <see cref="UiScrollbar.Model"/> to the box's own
/// <see cref="UiText.Scroll"/>.
/// </summary>
[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<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
UiText description = Assert.IsType<UiText>(
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
UiScrollbar scroll = Assert.IsType<UiScrollbar>(
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<UiButton>(layout.FindElement(elementId));

View file

@ -1663,6 +1663,66 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId);
}
/// <summary>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.</summary>
[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<UiText>(
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);
}
/// <summary>Heritages without a real retail name-suggestion string
/// (5-13, the decompiler-artifact cases) compose HowTo directly
/// against HowToEnd — no invented text, no crash.</summary>
[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<UiText>(
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<string, string> ResolvedStrings { get; } = [];
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
public void SelectGenderDirect(uint genderKey) => SelectGender(genderKey);
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
View.GetSkillLevel(skillId);