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:
parent
b5d36f5211
commit
03e073b748
8 changed files with 913 additions and 14 deletions
341
src/AcDream.App/UI/Layout/CharacterTitlesController.cs
Normal file
341
src/AcDream.App/UI/Layout/CharacterTitlesController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue