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

@ -156,7 +156,22 @@ public sealed record ToolbarRuntimeBindings(
// vendor-owned split-exempt-seed predicate (research doc §C.1).
Func<uint, bool> IsVendorSplitExempt);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
/// <param name="Provider">The sheet + raise-request flow (pre-existing).</param>
/// <param name="Titles">Campaign CT slice CT2's <c>RuntimeCharacterTitleState</c>
/// owner — CT3's <see cref="Layout.CharacterTitlesController"/> binds
/// directly against it.</param>
/// <param name="TitleResolver">CT2's DAT id-&gt;string chain
/// (<c>CharacterTitleTable::GetCharacterTitleFromID</c>). Constructed once at
/// composition time — its own constructor does no DAT I/O.</param>
/// <param name="SendSetTitle">Retail <c>TitleSet (0x002C)</c> — wire only, no
/// local mutation (CT2's <c>IRuntimeCharacterCommands.SetTitle</c> via the
/// late-bound game-runtime command seam, same shape as every fellowship/
/// allegiance command above).</param>
public sealed record CharacterRuntimeBindings(
CharacterSheetProvider Provider,
RuntimeCharacterTitleState Titles,
CharacterTitleResolver TitleResolver,
Func<uint, RuntimeCommandResult> SendSetTitle);
/// <summary>
/// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel
@ -532,6 +547,7 @@ public sealed class RetailUiRuntime : IDisposable
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private IDisposable? _characterSheetSubscription;
private Layout.CharacterTitlesController? _characterTitlesController;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@ -4040,6 +4056,37 @@ public sealed class RetailUiRuntime : IDisposable
currentSheet = provider.BuildSheet();
refreshRows();
});
// CT3 (2026-08-24): the Titles page's row template lives in a
// SEPARATE LayoutDesc (0x2100005E, element 0x10000536 — CT1 ground
// truth §3), reachable only through the targeted single-root
// ImportInfos overload — same caching resolver shape the social
// panel's Friends/Fellowship/Allegiance row families already use.
var titleRowTemplates = new Layout.RowTemplateResolver(
(layoutId, elementId) => LayoutImporter.ImportInfos(
_bindings.Assets.Dats, layoutId, elementId),
info =>
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
return LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
});
UiElement? TitleTemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
return titleRowTemplates.Resolve(templateLayoutId, templateElementId);
}
_characterTitlesController = Layout.CharacterTitlesController.Bind(
layout.Root,
_bindings.Character.Titles,
_bindings.Character.TitleResolver.Resolve,
TitleTemplateResolver,
_bindings.Character.SendSetTitle);
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
layout.Root,
@ -4799,6 +4846,7 @@ public sealed class RetailUiRuntime : IDisposable
() =>
{
_characterSheetSubscription?.Dispose();
_characterTitlesController?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowLockPresentation.Dispose();
WindowOpacity.Dispose();