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

File diff suppressed because one or more lines are too long

View file

@ -554,6 +554,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RuntimeAdvancementKind.TrainSkill,
statId,
credits));
// Campaign CT slice CT3 (2026-08-24): the Titles page's DAT
// id -> display-string chain (CT2). Constructed once — its own
// constructor does no DAT I/O (only .Resolve reads touch the
// dats), matching the characterCreationStrings precedent below.
var characterTitleResolver = new CharacterTitleResolver(d.Dats);
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
uint MagicSkillLevel(MagicSchool school)
@ -822,7 +827,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
&& d.Inventory.Objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)),
Character: new CharacterRuntimeBindings(characterSheet),
Character: new CharacterRuntimeBindings(
characterSheet,
d.Character.Titles,
characterTitleResolver,
SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)),
Inventory: new InventoryRuntimeBindings(
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,

View file

@ -150,6 +150,15 @@ internal sealed class DeferredGameRuntimeStateCommands
generation,
new RuntimeAdvancementCommand(kind, statId, cost)));
/// <summary>Campaign CT slice CT3 (2026-08-24): retail <c>TitleSet
/// (0x002C)</c> — same generation-capturing late seam as every other
/// method here. <see cref="AcDream.App.UI.Layout.CharacterTitlesController"/>
/// is the only caller.</summary>
public RuntimeCommandResult SetTitle(uint titleId) =>
Invoke((commands, generation) => commands.Character.SetTitle(
generation,
titleId));
// Campaign LA slice LA8: the retained character-management screen uses
// the same generation-capturing late seam as every gameplay panel. The
// screen never receives GameRuntime or WorldSession and cannot retain a

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

View file

@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles
/// page (LayoutDesc <c>0x2100002E</c>, element <c>0x10000539</c> —
/// <c>gmCharacterTitleUI</c>) to CT2's <see cref="RuntimeCharacterTitleState"/>
/// owner through the standard <see cref="UiTemplateListBox"/>/
/// <see cref="UiScrollbar"/>/<see cref="UiButton"/> classes only — no bespoke
/// widgets, matching every other social/options row-list page in this
/// codebase (<see cref="SocialFriendsPageController"/>,
/// <see cref="SocialFellowshipPageController"/>,
/// <see cref="SocialAllegiancePageController"/>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Rows.</b> <c>AddTitleToList @0x0049A840</c> resolves each row's text
/// through <c>CharacterTitleTable::GetCharacterTitleFromID</c> (ported as
/// <see cref="CharacterTitleResolver"/>, CT2) and inserts it SORTED
/// (<c>FindSortedInsertPosition @0x0049A760</c> — an ordinal string sort on
/// the resolved display text). This port rebuilds the full sorted row set on
/// every change (<see cref="RebuildRows"/>) rather than performing a true
/// positional splice: <see cref="UiTemplateListBox"/> has no insert-at-index
/// primitive, and no other <see cref="UiTemplateListBox"/> consumer in this
/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen
/// skills/the Options tabs all rebuild-on-change the same way) — the
/// resulting VISIBLE order is retail-exact even though the underlying
/// mechanism is "rebuild," not "splice."
/// </para>
/// <para>
/// <b>Selection + highlight.</b> Retail's <c>InfoRegion::SetState</c>
/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT)
/// applies <c>SetState(selected ? 6 : 1)</c> directly to the row element —
/// state 6 is <see cref="UiButtonStateMachine.Highlight"/>. The title row
/// template (<c>0x10000536</c>) authors that exact Highlight state
/// (<c>0x06001AAF</c>) alongside its DirectState background
/// (<c>0x06004CCA</c>), so this controller uses the row's own
/// <see cref="IUiDatStateful.TrySetRetailState"/> — no synthesized color
/// swap, unlike pages whose row template lacks a state-based highlight.
/// </para>
/// <para>
/// <b>The "Set as Display Title" button (<c>0x10000535</c>).</b>
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED per the campaign plan's CT1
/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id
/// DIFFERS from the CURRENT display title; no selection is the Ghosted case,
/// not the enabled one. A click sends
/// <c>CM_Social::Event_SetDisplayCharacterTitle</c> (CT2's
/// <see cref="RuntimeCharacterTitleState"/> command seam) — wire only, no
/// local mutation; the ghost gate itself makes an already-current selection
/// unreachable from the UI, so the click handler's own defensive re-check is
/// belt-and-braces, not the primary guard.
/// </para>
/// <para>
/// <b>Display-title text (<c>0x1000052F</c>).</b> <c>Refresh @0x0049abc0</c>
/// shows the resolved current display title, or retail's hardcoded literal
/// <c>"Unknown"</c> when the id does not resolve — refreshed on BOTH
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (retail's own
/// <c>RecvNotice_UpdateCharacterTitleTable</c> unconditionally calls
/// <c>Refresh()</c> on every <c>0x0029</c>, CT2 review anchor 1) and
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/>.
/// </para>
/// <para>
/// <b>The two page captions (<c>0x1000052E</c>/<c>0x10000531</c>).</b> Left
/// untouched by this controller — <see cref="LayoutImporter.BuildText"/>
/// already resolves every element's authored <c>StringInfo</c> caption at
/// import time (<c>ResolveAuthoredString</c>), the SAME mechanism every
/// other DAT-authored label in this window already relies on, so no
/// controller-side string lookup is needed or added here.
/// </para>
/// </remarks>
public sealed class CharacterTitlesController : IDisposable
{
public const uint CurrentDisplayTitleTextId = 0x1000052Fu;
public const uint TitleListBoxId = 0x10000532u;
public const uint SetDisplayButtonId = 0x10000535u;
/// <summary>The row template's own text child (<c>0x10000536</c>'s
/// single Type-0xC child) — CT1 ground truth §3.</summary>
private const uint RowTextId = 0x10000537u;
/// <summary>Retail's hardcoded fallback literal (<c>Refresh
/// @0x0049abc0</c>) for a display title id that does not resolve —
/// ported verbatim, not a StringTable key (CT2 review anchor 3).</summary>
private const string UnknownTitleText = "Unknown";
private readonly record struct Row(UiElement Root, uint TitleId);
private readonly RuntimeCharacterTitleState _titles;
private readonly Func<uint, string?> _resolveTitle;
private readonly Func<uint, RuntimeCommandResult> _sendSetTitle;
private readonly UiTemplateListBox _listBox;
private readonly UiText? _displayText;
private readonly UiButton? _setDisplayButton;
private readonly List<Row> _rows = new();
private uint? _selectedTitleId;
private bool _disposed;
private CharacterTitlesController(
RuntimeCharacterTitleState titles,
Func<uint, string?> resolveTitle,
Func<uint, RuntimeCommandResult> sendSetTitle,
UiTemplateListBox listBox,
UiText? displayText,
UiButton? setDisplayButton)
{
_titles = titles;
_resolveTitle = resolveTitle;
_sendSetTitle = sendSetTitle;
_listBox = listBox;
_displayText = displayText;
_setDisplayButton = setDisplayButton;
}
/// <summary>
/// Binds the Titles page's list box, scrollbar, display-title text, and
/// Set-as-Display button under <paramref name="layoutRoot"/> (the
/// character window's imported tree — the Titles page's element ids are
/// unique client-wide, so no page-scoped search is needed, unlike the
/// multi-tab social panel's row families). Returns null (logging why)
/// when the list box itself is missing — every other element is
/// optional so a partial import still gets what it can.
/// </summary>
/// <param name="resolveTitle">CT2's <see cref="CharacterTitleResolver.Resolve(uint)"/>
/// method group in production; a delegate (not the concrete DAT-backed
/// class) so this controller stays hermetically testable without a live
/// <c>IDatReaderWriter</c>.</param>
public static CharacterTitlesController? Bind(
UiElement layoutRoot,
RuntimeCharacterTitleState titles,
Func<uint, string?> resolveTitle,
Func<uint, uint, UiElement?> templateResolver,
Func<uint, RuntimeCommandResult> sendSetTitle)
{
ArgumentNullException.ThrowIfNull(layoutRoot);
ArgumentNullException.ThrowIfNull(titles);
ArgumentNullException.ThrowIfNull(resolveTitle);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(sendSetTitle);
if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " +
"found — the Titles page will not populate.");
return null;
}
listBox.TemplateResolver = templateResolver;
uint scrollbarElementId = listBox.ScrollbarElementId;
UiElement? scrollbarElement = scrollbarElementId == 0
? null
: UiElement.FindDescendant(layoutRoot, scrollbarElementId);
if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
$"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " +
"not found — the Titles list will not scroll.");
UiText? displayText =
UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText;
UiButton? setDisplayButton =
UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton;
var controller = new CharacterTitlesController(
titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton);
controller.WireButton();
titles.TableReplaced += controller.OnTableReplaced;
titles.TitleAdded += controller.OnTitleAdded;
titles.DisplayTitleChanged += controller.OnDisplayTitleChanged;
controller.RebuildRows();
controller.RefreshDisplayText();
controller.RefreshButtonGhost();
return controller;
}
private void WireButton()
{
if (_setDisplayButton is null) return;
_setDisplayButton.OnClick = () =>
{
// Belt-and-braces re-check (CT2 review anchor 2): retail's real
// guard is the Ghosted state itself — UiButton refuses to raise
// OnClick while !Enabled — so this branch is normally
// unreachable from a real click, but a direct-call test (or a
// stray event) must still send nothing while ghosted, and never
// wait for a confirmation ACE does not send when re-setting the
// already-current title.
if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId)
return;
_sendSetTitle(id);
};
}
/// <summary>
/// <c>0x0029 CharacterTitle</c> — retail's own <c>Refresh()</c> is
/// unconditional here (CT2 review anchor 1), and <c>UnPack</c> always
/// rebuilds <c>mTitleList</c> from scratch. <see cref="RebuildRows"/>
/// itself decides whether the current selection survives (it does when
/// the selected id is still earned in the new table).
/// </summary>
private void OnTableReplaced()
{
RebuildRows();
RefreshDisplayText();
RefreshButtonGhost();
}
/// <summary>
/// <c>0x002B UpdateTitle</c>, add half — CT2's F1 fix already dedupes
/// this event to genuine new memberships only (a repeat add fires no
/// event at all), so every firing here is a real new row.
/// </summary>
private void OnTitleAdded(uint titleId)
{
RebuildRows();
RefreshButtonGhost();
}
private void OnDisplayTitleChanged(uint titleId)
{
RefreshDisplayText();
RefreshButtonGhost();
}
/// <summary>
/// Full sorted rebuild — see the class remarks for why this port
/// rebuilds rather than performing retail's literal single-row
/// positional insert. Preserves scroll position
/// (<see cref="UiTemplateListBox.FlushPreservingScroll"/>). The current
/// selection survives when the selected id is still present in the
/// rebuilt row set; otherwise it is cleared here so the Set-as-Display
/// button's ghost state can never desync from what is actually
/// highlighted (a selection pointing at a no-longer-visible row would
/// leave the button enabled with nothing shown selected).
/// </summary>
private void RebuildRows()
{
_listBox.FlushPreservingScroll();
_rows.Clear();
// A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per
// read — safe here (a UI refresh call site, not a per-frame poll).
var sorted = new List<(uint Id, string Text)>();
foreach (uint id in _titles.EarnedTitleIds)
sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText));
// FindSortedInsertPosition @0x0049A760: ordinal string sort on the
// resolved display text.
sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text));
foreach ((uint id, string text) in sorted)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) continue;
if (row is UiDatElement datRow)
{
// Generic Type-3 container fallback (DatWidgetFactory) —
// "generic decoration; behavioral widgets opt back in" (its
// own class doc). Same page-opt-in shape
// CharacterCreationSkillsPage uses for its selectable rows.
datRow.ClickThrough = false;
uint capturedId = id;
datRow.OnClick = () => SelectRow(capturedId);
}
if (UiElement.FindDescendant(row, RowTextId) is UiText rowText)
{
string capturedText = text;
rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)];
}
_rows.Add(new Row(row, id));
}
if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected))
_selectedTitleId = null;
ApplyRowHighlights();
}
private void SelectRow(uint titleId)
{
if (_disposed) return;
_selectedTitleId = titleId;
ApplyRowHighlights();
RefreshButtonGhost();
}
/// <summary>Retail <c>InfoRegion::SetState(selected ? 6 : 1)</c> — the
/// row's OWN authored Highlight/DirectState media swap, not a
/// synthesized color (see class remarks).</summary>
private void ApplyRowHighlights()
{
foreach (Row row in _rows)
{
if (row.Root is IUiDatStateful stateful)
{
stateful.TrySetRetailState(
row.TitleId == _selectedTitleId
? UiButtonStateMachine.Highlight
: UiButtonStateMachine.Normal);
}
}
}
private void RefreshDisplayText()
{
if (_displayText is null) return;
string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText;
_displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)];
}
/// <summary>
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED — campaign plan CT1 fix
/// round): Ghosted UNLESS a row is selected whose title id DIFFERS from
/// the current display title. No selection is the Ghosted case.
/// </summary>
private void RefreshButtonGhost()
{
if (_setDisplayButton is null) return;
bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId;
_setDisplayButton.TrySetRetailState(
shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_titles.TableReplaced -= OnTableReplaced;
_titles.TitleAdded -= OnTitleAdded;
_titles.DisplayTitleChanged -= OnDisplayTitleChanged;
}
}

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

View file

@ -802,7 +802,10 @@ public class CharacterStatControllerTests
Assert.Equal(
new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u },
skills.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
Assert.True(titles.ClickThrough);
// CT3 (2026-08-24): the Titles tab is no longer inert — it switches
// to a real page (previously AP-109's known gap).
Assert.False(titles.ClickThrough);
Assert.NotNull(titles.OnClick);
}
[Fact]
@ -826,6 +829,45 @@ public class CharacterStatControllerTests
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
}
/// <summary>
/// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted
/// page and only rebind its content), the Titles page (0x10000539) is a
/// genuinely separate, non-duplicated subtree (CT1 ground truth §3) that
/// must actually be shown/hidden on tab switch.
/// </summary>
[Fact]
public void TitlesTab_Click_ShowsTitlesPageAndHidesAttributesSkillsContent()
{
var layout = FixtureLoader.LoadCharacter();
var titlesTab = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabTitlesId));
var attributesTab = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
var titlesPage = layout.FindElement(CharacterStatController.TitlesPageId);
var attributesPage = layout.FindElement(CharacterStatController.AttributesPageId);
Assert.NotNull(titlesPage);
Assert.NotNull(attributesPage);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
Assert.True(attributesPage!.Visible);
Assert.False(titlesPage!.Visible);
titlesTab.OnClick!();
Assert.True(titlesPage.Visible);
Assert.False(attributesPage.Visible);
Assert.Equal(RetailUiStateIds.Open, titlesTab.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, attributesTab.ActiveRetailStateId);
// Switching back to Attributes restores the shared content page and
// re-hides Titles.
attributesTab.OnClick!();
Assert.True(attributesPage.Visible);
Assert.False(titlesPage.Visible);
Assert.Equal(RetailUiStateIds.Closed, titlesTab.ActiveRetailStateId);
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
[Fact]

View file

@ -0,0 +1,414 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Runtime;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for
/// <see cref="CharacterTitlesController"/>. Binding-seam coverage
/// (<c>feedback_test_the_binding_seam.md</c>) uses the REAL committed
/// <c>character_2100002E.json</c> fixture — verified (2026-08-24) to already
/// carry the whole Titles page subtree, including the ListBox's own
/// authored <c>TemplateList</c> entry pointing at <c>(0x2100005E,
/// 0x10000536)</c> — so every element this controller binds except the row
/// template ITSELF (a separate LayoutDesc CT1 could only reach via a live
/// DAT probe, with no committed fixture yet) comes from the real imported
/// tree, not a hand-built stand-in.
/// </summary>
public sealed class CharacterTitlesControllerTests
{
// Row template ground truth (docs/research/2026-08-24-campaign-ct-dat-
// ground-truth.md §3): LayoutDesc 0x2100005E, element 0x10000536 — a
// 270x24 Type-3 container with DirectState/Highlight media and one
// Type-0xC text child (0x10000537).
private const uint RowTemplateLayoutId = 0x2100005Eu;
private const uint RowTemplateElementId = 0x10000536u;
private const uint RowTextId = 0x10000537u;
private const uint RowNormalSprite = 0x06004CCAu;
private const uint RowHighlightSprite = 0x06001AAFu;
private static ElementInfo BuildRowTemplateInfo()
{
var info = new ElementInfo
{
Id = RowTemplateElementId,
Type = 3u,
Width = 270f,
Height = 24f,
};
info.StateMedia[""] = (RowNormalSprite, 3);
info.StateMedia["Highlight"] = (RowHighlightSprite, 1);
info.Children.Add(new ElementInfo
{
Id = RowTextId,
Type = 0xCu,
Width = 270f,
Height = 24f,
HJustify = HJustify.Left,
FontColor = Vector4.One,
});
return info;
}
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
=> LayoutImporter.Build(BuildRowTemplateInfo(), static _ => (0u, 0, 0), null).Root;
private sealed class Harness
{
public required ImportedLayout Layout;
public required UiTemplateListBox ListBox;
public required UiText DisplayText;
public required UiButton SetDisplayButton;
public required RuntimeCharacterTitleState Titles;
public required Dictionary<uint, string> Names;
public required List<uint> SentTitleIds;
public required CharacterTitlesController Controller;
public IReadOnlyList<UiElement> Rows =>
ListBox.ViewportForTest?.Children ?? [];
public string RowText(UiElement row) =>
((UiText)UiElement.FindDescendant(row, RowTextId)!).LinesProvider().Single().Text;
public uint RowMedia(UiElement row) =>
((UiDatElement)row).ActiveMedia().File;
}
// ── Binding seam ─────────────────────────────────────────────────────
[Fact]
public void Bind_FindsEveryTitlesPageElement_InTheRealImportedFixture()
{
Harness h = BindWithEarnedTitles([], displayTitleId: 0u);
Assert.NotNull(h.Controller);
Assert.Equal(CharacterTitlesController.TitleListBoxId, h.ListBox.DatElementId);
Assert.Equal(CharacterTitlesController.CurrentDisplayTitleTextId, h.DisplayText.DatElementId);
Assert.Equal(CharacterTitlesController.SetDisplayButtonId, h.SetDisplayButton.DatElementId);
// The authored scrollbar (0x10000533, the ListBox's own
// ScrollbarElementId) must actually be wired to the list's scroll
// model, not merely present.
var scrollbar = Assert.IsType<UiScrollbar>(
h.Layout.FindElement(h.ListBox.ScrollbarElementId));
Assert.Same(h.ListBox.Scroll, scrollbar.Model);
}
[Fact]
public void Bind_MissingListBox_ReturnsNullWithoutThrowing()
{
var root = new UiPanel();
var titles = new RuntimeCharacterTitleState();
CharacterTitlesController? controller = CharacterTitlesController.Bind(
root,
titles,
static _ => null,
FakeRowTemplateResolver,
static _ => new RuntimeCommandResult(RuntimeCommandStatus.Inactive, default));
Assert.Null(controller);
}
[Fact]
public void RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds()
{
// The REAL fixture's ListBox (0x10000532) authors TemplateList =
// [(0x2100005E, 0x10000536)] (dat property 0x64) — this proves the
// controller's AddItemFromTemplateList(0) call actually reads that
// authored entry rather than a hardcoded pair of its own.
var seen = new List<(uint LayoutId, uint ElementId)>();
UiElement? Recording(uint layoutId, uint elementId)
{
seen.Add((layoutId, elementId));
return FakeRowTemplateResolver(layoutId, elementId);
}
BindWithEarnedTitles(
[1u], displayTitleId: 0u,
names: new() { [1u] = "Adventurer" },
rowResolver: Recording);
(uint layoutId, uint elementId) = Assert.Single(seen);
Assert.Equal(RowTemplateLayoutId, layoutId);
Assert.Equal(RowTemplateElementId, elementId);
}
private static Harness BindWithEarnedTitles(
IReadOnlyList<uint> earnedIds,
uint displayTitleId,
Dictionary<uint, string>? names = null,
Func<uint, uint, UiElement?>? rowResolver = null)
{
ImportedLayout layout = FixtureLoader.LoadCharacter();
var titles = new RuntimeCharacterTitleState();
titles.ReplaceTable(displayTitleId, earnedIds.ToArray());
Dictionary<uint, string> resolvedNames = names ?? new Dictionary<uint, string>();
var sent = new List<uint>();
RuntimeCommandResult SendSetTitle(uint id)
{
sent.Add(id);
return new RuntimeCommandResult(RuntimeCommandStatus.Accepted, default);
}
string? ResolveTitle(uint id) =>
resolvedNames.TryGetValue(id, out string? name) ? name : null;
CharacterTitlesController? controller = CharacterTitlesController.Bind(
layout.Root,
titles,
ResolveTitle,
rowResolver ?? FakeRowTemplateResolver,
SendSetTitle);
Assert.NotNull(controller);
var listBox = Assert.IsType<UiTemplateListBox>(
layout.FindElement(CharacterTitlesController.TitleListBoxId));
var displayText = Assert.IsType<UiText>(
layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId));
var button = Assert.IsType<UiButton>(
layout.FindElement(CharacterTitlesController.SetDisplayButtonId));
return new Harness
{
Layout = layout,
ListBox = listBox,
DisplayText = displayText,
SetDisplayButton = button,
Titles = titles,
Names = resolvedNames,
SentTitleIds = sent,
Controller = controller!,
};
}
// ── Row sort + content ──────────────────────────────────────────────
[Fact]
public void Rows_AreSortedAlphabeticallyByResolvedTitleText()
{
Harness h = BindWithEarnedTitles(
[13u, 5u, 1u],
displayTitleId: 0u,
names: new()
{
[1u] = "Adventurer",
[5u] = "Life Mage",
[13u] = "War Mage",
});
Assert.Equal(3, h.Rows.Count);
Assert.Equal(
["Adventurer", "Life Mage", "War Mage"],
h.Rows.Select(h.RowText));
}
[Fact]
public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral()
{
Harness h = BindWithEarnedTitles(
[99u],
displayTitleId: 0u,
names: []);
UiElement row = Assert.Single(h.Rows);
Assert.Equal("Unknown", h.RowText(row));
}
// ── Selection + highlight ─────────────────────────────────────────────
[Fact]
public void SelectingARow_AppliesHighlightMedia_AndDeselectsTheOthers()
{
Harness h = BindWithEarnedTitles(
[1u, 5u],
displayTitleId: 0u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
UiElement first = h.Rows[0];
UiElement second = h.Rows[1];
((UiDatElement)first).OnClick!();
Assert.Equal(RowHighlightSprite, h.RowMedia(first));
Assert.Equal(RowNormalSprite, h.RowMedia(second));
((UiDatElement)second).OnClick!();
Assert.Equal(RowNormalSprite, h.RowMedia(first));
Assert.Equal(RowHighlightSprite, h.RowMedia(second));
}
// ── Ghost truth table (UpdateButtons @0x0049A500, CORRECTED direction) ─
[Fact]
public void Ghost_NoSelection_ButtonIsGhosted()
{
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
Assert.False(h.SetDisplayButton.Enabled);
}
[Fact]
public void Ghost_SelectedRowEqualsCurrentDisplayTitle_ButtonIsGhosted()
{
Harness h = BindWithEarnedTitles(
[1u, 5u], displayTitleId: 5u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
// Sorted order: Adventurer(1), Life Mage(5) — select the row whose
// id equals the current display title (5).
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
((UiDatElement)lifeMageRow).OnClick!();
Assert.False(h.SetDisplayButton.Enabled);
}
[Fact]
public void Ghost_SelectedRowDiffersFromCurrentDisplayTitle_ButtonIsNormal()
{
Harness h = BindWithEarnedTitles(
[1u, 5u], displayTitleId: 5u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer");
((UiDatElement)adventurerRow).OnClick!();
Assert.True(h.SetDisplayButton.Enabled);
}
// ── Click -> SetTitle wire send ────────────────────────────────────────
[Fact]
public void ClickingSetDisplay_SendsExactlyOneSetTitleWithTheSelectedId_AndMutatesNothingLocally()
{
Harness h = BindWithEarnedTitles(
[1u, 13u], displayTitleId: 1u,
names: new() { [1u] = "Adventurer", [13u] = "War Mage" });
UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage");
((UiDatElement)warMageRow).OnClick!();
h.SetDisplayButton.OnClick!();
Assert.Equal([13u], h.SentTitleIds);
// No optimistic local mutation — CT2's own contract (ACE sends no
// echo when re-setting the current title; the display title only
// ever changes from a DisplayTitleChanged event).
Assert.Equal(1u, h.Titles.DisplayTitleId);
Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text);
}
[Fact]
public void ClickingSetDisplay_WhileGhosted_SendsNothing()
{
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
// No row selected -> Ghosted. A direct OnClick invocation bypasses
// UiButton's own Enabled-gated event routing, so this exercises the
// controller's own belt-and-braces re-check.
h.SetDisplayButton.OnClick!();
Assert.Empty(h.SentTitleIds);
}
// ── Wire events ─────────────────────────────────────────────────────
[Fact]
public void TableReplaced_RebuildsRows_AndClearsSelection()
{
Harness h = BindWithEarnedTitles(
[1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" });
((UiDatElement)h.Rows[0]).OnClick!();
Assert.True(h.SetDisplayButton.Enabled); // selected, differs from display(0)
h.Titles.ReplaceTable(0u, [13u]);
Assert.Equal(["War Mage"], h.Rows.Select(h.RowText));
// The previously-selected row no longer exists post-rebuild -> back
// to the no-selection Ghosted state.
Assert.False(h.SetDisplayButton.Enabled);
}
[Fact]
public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted()
{
Harness h = BindWithEarnedTitles(
[1u, 5u], displayTitleId: 0u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
((UiDatElement)lifeMageRow).OnClick!();
Assert.True(h.SetDisplayButton.Enabled);
// A resend of the SAME table (retail's own Refresh() is
// unconditional — CT2 review anchor 1) must not silently desync the
// ghost state from the still-valid selection.
h.Titles.ReplaceTable(0u, [1u, 5u]);
UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow));
Assert.True(h.SetDisplayButton.Enabled);
}
[Fact]
public void TitleAdded_InsertsExactlyOneRow_PreservingExistingRowsInSortedOrder()
{
Harness h = BindWithEarnedTitles(
[1u], displayTitleId: 0u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
Assert.Single(h.Rows);
h.Titles.ApplyUpdateTitle(5u, setAsDisplay: false);
Assert.Equal(["Adventurer", "Life Mage"], h.Rows.Select(h.RowText));
}
[Fact]
public void DisplayTitleChanged_UpdatesTextAndReevaluatesGhost()
{
Harness h = BindWithEarnedTitles(
[1u, 5u], displayTitleId: 0u,
names: new() { [1u] = "Adventurer", [5u] = "Life Mage" });
UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage");
((UiDatElement)lifeMageRow).OnClick!();
Assert.True(h.SetDisplayButton.Enabled); // selected(5) != display(0)
// Simulates the server echo (0x002B UpdateTitle, setAsDisplay=true)
// that a real Set-as-Display send would eventually produce.
h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true);
Assert.Equal("Life Mage", h.DisplayText.LinesProvider().Single().Text);
// Selection now equals the (new) current display title -> Ghosted.
Assert.False(h.SetDisplayButton.Enabled);
}
[Fact]
public void DisplayTitleText_UnresolvedId_ShowsRetailUnknownLiteral()
{
Harness h = BindWithEarnedTitles([1u], displayTitleId: 77u, names: new() { [1u] = "Adventurer" });
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
}
[Fact]
public void DisplayTitleText_NoDisplayTitleSet_ShowsRetailUnknownLiteral()
{
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
}
// ── Lifecycle ───────────────────────────────────────────────────────
[Fact]
public void Dispose_UnsubscribesFromTitleEvents()
{
Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" });
h.Controller.Dispose();
// Must not throw, and must not rebuild the (now-orphaned) rows.
h.Titles.ReplaceTable(0u, [1u, 5u]);
Assert.Single(h.Rows);
}
}