feat(ui): Campaign CT slice CT3 — Titles page live via standard GUI classes

Titles tab (AP-109's known-inert gap) now switches to a real page and
CharacterTitlesController binds it entirely through UiTemplateListBox/
UiScrollbar/UiButton — zero bespoke widgets, matching every other
social/options row-list page in this codebase.

Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList
@0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by
resolved display text — this port rebuilds the full sorted set on every
change rather than a positional splice, since UiTemplateListBox has no
insert-at-index primitive and no other consumer needs one either);
InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the
same mechanism CT1's SEALED VERDICT confirmed for the stat rows);
UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is
selected whose id differs from the current display title — no selection
IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including
the hardcoded "Unknown" fallback, refreshed on both TableReplaced and
DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle
@0x006a5720 (wire-only TitleSet 0x002C send, no local mutation).

CharacterStatController.Bind now three-way switches Attributes/Skills/
Titles — Titles is a genuinely separate, non-duplicated page container
(CT1 ground truth §3), unlike Attributes/Skills which share one mounted
page and only rebind content.

The two page captions (0x1000052E/0x10000531) are left untouched:
LayoutImporter.BuildText already resolves every element's authored
StringInfo caption at import time, so no controller-side string lookup
was added.

New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands
(InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape.
CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle;
CharacterTitleResolver (CT2) is constructed once at composition time and
its .Resolve method group is passed to the controller as a delegate
(not the concrete DAT-backed type) so the controller stays hermetically
testable without a live IDatReaderWriter.

Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs):
binding-seam coverage against the REAL committed character_2100002E.json
fixture (verified this session to already carry the Titles page subtree,
including the ListBox's own authored TemplateList=[(0x2100005E,
0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds
proves the controller reads that authored pair, not a hardcoded one); a
hand-authored ElementInfo standing in only for the row template itself
(a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT
probe only); sorted-row order, Unknown fallback, row selection/highlight,
the ghost truth table (no selection / selected==display / selected!=
display), click-sends-exactly-one-SetTitle-and-mutates-nothing,
click-while-ghosted-sends-nothing, TableReplaced rebuild (including
selection survival when the id is still earned), TitleAdded single-row
growth, DisplayTitleChanged text+ghost refresh, and Dispose
unsubscription. CharacterStatControllerTests updated for the Titles tab
no longer being ClickThrough, plus a new tab-switch visibility test.

Register: amends AP-109 (docs/architecture/retail-divergence-register.md)
to record the Titles-page half as LIVE; the header identity block and
luminance fields remain open for CT4.

Suites: full solution 15,405 tests / 0 skips (App 6,130) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 22:59:40 +02:00
parent b5d36f5211
commit 03e073b748
8 changed files with 913 additions and 14 deletions

View file

@ -35,10 +35,15 @@ namespace AcDream.App.UI.Layout;
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
///
/// <para>Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
/// Source: <c>UIElement::SetState @ 0x00464E70</c> and UIStateId.Open (0x0C) /
/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and
/// propagates the state to its three chrome children through PassToChildren.</para>
/// <para>Tab states: whichever of Attributes/Skills/Titles is active shows "Open"
/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored
/// default. Source: <c>UIElement::SetState @ 0x00464E70</c>. The imported Type-12
/// tab owns its authored font color and propagates the state to its three chrome
/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the
/// Titles tab click to a real page switch (previously a known-inert AP-109 gap);
/// <see cref="CharacterTitlesController"/> owns the Titles page's OWN content
/// (row list, display-title text, Set-as-Display button) once its page container
/// is shown.</para>
///
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
@ -146,6 +151,7 @@ public static class CharacterStatController
{
Attributes,
Skills,
Titles,
}
public enum RaiseTargetKind
@ -278,6 +284,11 @@ public static class CharacterStatController
UiElement? skillsTab = layout.FindElement(TabSkillsId);
UiElement? titlesTab = layout.FindElement(TabTitlesId);
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
// CT3: unlike Attributes/Skills (which share ONE mounted page and just
// rebind its content — see the Attributes/Skills tab-switch note above),
// the Titles page (0x10000539) is its OWN separate, non-duplicated subtree
// (CT1 ground truth §3) that must be actually shown/hidden.
UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId);
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
@ -472,9 +483,12 @@ public static class CharacterStatController
RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes));
RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills));
// Titles remain the known AP-109 gap. Keep its retail-authored closed visual,
// but do not make an inert page look interactive until that controller lands.
RetailTabBinding.SetClick(titlesTab, null);
// CT3 (2026-08-24): the Titles tab now switches to its own real page
// (previously the known AP-109 gap — retail-authored closed visual, but
// no click routing at all). Content population is
// CharacterTitlesController's job, bound separately by the caller
// against the SAME imported layout root.
RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles));
UpdateTabStates();
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
@ -501,8 +515,30 @@ public static class CharacterStatController
attrSel[0] = -1;
skillSel[0] = -1;
SetFooterSelected(false);
RebuildActiveList();
RefreshActiveRaiseButtons();
// CT3: Titles is a genuinely separate page container (unlike
// Attributes/Skills, which share one mounted page and only rebind
// its content — see RebuildActiveList) — actually flip visibility
// between it and the shared Attributes/Skills content page.
bool showTitles = tab == CharacterStatTab.Titles;
if (titlesPage is not null) titlesPage.Visible = showTitles;
if (contentPage is not null) contentPage.Visible = !showTitles;
if (showTitles)
{
// Titles authors its own (unused) copies of the raise buttons
// (CT1 ground truth); nothing on this page ever selects a stat
// row, so keep them hidden rather than rebuilding a list this
// tab does not show.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
}
else
{
RebuildActiveList();
RefreshActiveRaiseButtons();
}
UpdateTabStates();
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
}
@ -511,7 +547,7 @@ public static class CharacterStatController
{
RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes);
RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills);
RetailTabBinding.SetOpen(titlesTab, false);
RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles);
}
void RebuildActiveList()