using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
///
/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles
/// page (LayoutDesc 0x2100002E, element 0x10000539 —
/// gmCharacterTitleUI) to CT2's
/// owner through the standard /
/// / classes only — no bespoke
/// widgets, matching every other social/options row-list page in this
/// codebase (,
/// ,
/// ).
///
///
///
/// Rows. AddTitleToList @0x0049A840 resolves each row's text
/// through CharacterTitleTable::GetCharacterTitleFromID (ported as
/// , CT2) and inserts it SORTED
/// (FindSortedInsertPosition @0x0049A760 — an ordinal string sort on
/// the resolved display text). This port rebuilds the full sorted row set on
/// every change () rather than performing a true
/// positional splice: has no insert-at-index
/// primitive, and no other 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." AddTitleToList itself
/// early-outs (@0x0049a873/@0x0049a914) before ever reaching
/// the insert — retail NEVER creates a row for id 0 or for an id
/// GetCharacterTitleFromID fails to resolve, so
/// skips those ids entirely rather than falling back to a placeholder row
/// text (CT3 fix round — the "Unknown" literal belongs ONLY to the
/// display-title text below, never to a row).
///
///
/// Selection + highlight. Retail's InfoRegion::SetState
/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT)
/// applies SetState(selected ? 6 : 1) directly to the row element —
/// state 6 is . The title row
/// template (0x10000536) authors that exact Highlight state
/// (0x06001AAF) alongside its DirectState background
/// (0x06004CCA), so this controller uses the row's own
/// — no synthesized color
/// swap, unlike pages whose row template lacks a state-based highlight.
///
///
/// Selection lifetime (CT3 fix round — BLOCKER). Refresh
/// @0x0049abc0 itself calls SetSelectedItem(nullptr, 1)
/// (@0x0049ac5a) UNCONDITIONALLY, before it repopulates the list —
/// every code path that reaches Refresh() drops the current
/// selection outright, regardless of whether the previously-selected title
/// id is still earned. Refresh() runs on BOTH
/// (0x0029)
/// and (the
/// display-title half of 0x002B), so
/// and both clear
/// before rebuilding/re-highlighting.
/// RecvNotice_AddCharacterTitle @0x0049a990 (the add half of
/// 0x002B, ) is a DIFFERENT retail method
/// that splices one row into mTitleList without ever touching
/// m_pSelectedItem — selection SURVIVES a title add. Concretely:
/// after the user sets a display title and ACE echoes 0x002B, the
/// previously-highlighted row goes dark and the Set-as-Display button
/// re-ghosts, exactly like retail — but earning a brand-new title while a
/// row is selected leaves that selection alone.
///
///
/// The "Set as Display Title" button (0x10000535).
/// UpdateButtons @0x0049A500 (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
/// CM_Social::Event_SetDisplayCharacterTitle (CT2's
/// 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.
///
///
/// Display-title text (0x1000052F). Refresh @0x0049abc0
/// shows the resolved current display title, or retail's hardcoded literal
/// "Unknown" when the id does not resolve — refreshed on BOTH
/// (retail's own
/// RecvNotice_UpdateCharacterTitleTable unconditionally calls
/// Refresh() on every 0x0029, CT2 review anchor 1) and
/// .
///
///
/// The two page captions (0x1000052E/0x10000531). Left
/// untouched by this controller —
/// already resolves every element's authored StringInfo caption at
/// import time (ResolveAuthoredString), 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.
///
///
public sealed class CharacterTitlesController : IDisposable
{
public const uint CurrentDisplayTitleTextId = 0x1000052Fu;
public const uint TitleListBoxId = 0x10000532u;
public const uint SetDisplayButtonId = 0x10000535u;
/// The row template's own text child (0x10000536's
/// single Type-0xC child) — CT1 ground truth §3.
private const uint RowTextId = 0x10000537u;
/// Retail's hardcoded fallback literal (Refresh
/// @0x0049abc0) for a display title id that does not resolve —
/// ported verbatim, not a StringTable key (CT2 review anchor 3).
private const string UnknownTitleText = "Unknown";
private readonly record struct Row(UiElement Root, uint TitleId);
private readonly RuntimeCharacterTitleState _titles;
private readonly Func _resolveTitle;
private readonly Func _sendSetTitle;
private readonly UiTemplateListBox _listBox;
private readonly UiText? _displayText;
private readonly UiButton? _setDisplayButton;
private readonly List _rows = new();
private uint? _selectedTitleId;
private bool _disposed;
private CharacterTitlesController(
RuntimeCharacterTitleState titles,
Func resolveTitle,
Func sendSetTitle,
UiTemplateListBox listBox,
UiText? displayText,
UiButton? setDisplayButton)
{
_titles = titles;
_resolveTitle = resolveTitle;
_sendSetTitle = sendSetTitle;
_listBox = listBox;
_displayText = displayText;
_setDisplayButton = setDisplayButton;
}
///
/// Binds the Titles page's list box, scrollbar, display-title text, and
/// Set-as-Display button under (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.
///
/// CT2's
/// method group in production; a delegate (not the concrete DAT-backed
/// class) so this controller stays hermetically testable without a live
/// IDatReaderWriter.
public static CharacterTitlesController? Bind(
UiElement layoutRoot,
RuntimeCharacterTitleState titles,
Func resolveTitle,
Func templateResolver,
Func 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;
// The row template (0x10000536) authors a 270x24 box (CT1 ground
// truth §3); UiTemplateListBox's own DefaultLineHeight is 16, which
// would desync wheel/line scroll from the actual row pitch
// (CharacterManagementUiController.cs:463 sets its own row height
// the same way for the same reason).
listBox.LineHeight = 24;
// CT6 (2026-08-25): the Titles list (authored H=455 inside the
// 575px page — CT1 ground truth §3) shrinks/grows with the window
// the same way CharacterStatController's attribute/skill list does.
// CT6 fix round (S3 correction): the former Anchors fallback here
// ("if LayoutPolicy is null") was DEAD CODE — both 0x10000532 (this
// ListBox) and its page container 0x10000539 author
// HasOriginalParentSize=true in the real DAT (pinned by
// CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize),
// so LayoutImporter/DatWidgetFactory ALWAYS assigns a real
// LayoutPolicy to this element and the fallback branch never ran on
// either the installed DAT or the committed fixture. The actual
// reflow mechanism is that authored LayoutPolicy stretching with the
// mounted content's height — deleted rather than left as
// unreachable/misleading compatibility code. UiTemplateListBox's own
// internal viewport (created lazily inside AddItemFromTemplateList)
// already carries the #372-class eager-baseline-capture fix, so once
// the ListBox itself reflows, its scrollbar (bound below) picks up
// the new content/view relationship for free.
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();
// Bind-time refresh (CT3 fix round NOTE 6): retail itself only
// shows "Unknown" until the first notice arrives (nothing runs
// Refresh() before Refresh() is first called), but ACE always sends
// 0x0029 at SendSelf before this panel can even open, so the
// pre-notice "Unknown" frame is unreachable in live play. Refreshing
// at bind time instead keeps a window RE-mount (tab re-open, panel
// rebuild) consistent with whatever the table already holds, rather
// than flashing "Unknown" for one frame before the next notice.
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);
};
}
///
/// 0x0029 CharacterTitle — retail's own Refresh() is
/// unconditional here (CT2 review anchor 1), and UnPack always
/// rebuilds mTitleList from scratch. BLOCKER fix (CT3 fix round):
/// Refresh also calls SetSelectedItem(nullptr, 1)
/// (@0x0049ac5a) unconditionally, BEFORE it repopulates — so the
/// selection is cleared here regardless of whether the previously
/// selected id is still earned, not merely dropped when it disappears.
///
private void OnTableReplaced()
{
ClearSelection();
RebuildRows();
RefreshDisplayText();
RefreshButtonGhost();
}
///
/// 0x002B UpdateTitle, 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.
/// RecvNotice_AddCharacterTitle @0x0049a990 splices the one new
/// row into mTitleList without ever touching
/// m_pSelectedItem (unlike Refresh's unconditional
/// clear) — selection deliberately survives a title add.
///
private void OnTitleAdded(uint titleId)
{
RebuildRows();
RefreshButtonGhost();
}
///
/// 0x002B UpdateTitle, display half — this is the other trigger
/// for retail's Refresh() (CT2 review anchor 1), so it carries
/// the same unconditional SetSelectedItem(nullptr, 1)
/// (@0x0049ac5a) as . This handler
/// does not call (the row SET is unchanged —
/// only the display title moved), so it re-applies highlights directly
/// to actually dark out the previously-selected row.
///
private void OnDisplayTitleChanged(uint titleId)
{
ClearSelection();
ApplyRowHighlights();
RefreshDisplayText();
RefreshButtonGhost();
}
/// Refresh @0x0049abc0's SetSelectedItem(nullptr,
/// 1) (@0x0049ac5a) — clears the tracked selection only; the
/// caller is responsible for re-applying row highlights and the button
/// ghost state afterward.
private void ClearSelection() => _selectedTitleId = null;
///
/// 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
/// (). Callers that
/// mirror retail's unconditional Refresh() selection clear
/// () call
/// themselves before this runs; the check below is a defensive
/// fallback for any other caller ( included)
/// so a selection can never point at a row that no longer exists.
///
private void RebuildRows()
{
_listBox.FlushPreservingScroll();
_rows.Clear();
// AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914):
// retail never creates a row for id 0 or for an id
// GetCharacterTitleFromID fails to resolve — "Unknown" is the
// display-title text's OWN fallback (RefreshDisplayText), never a
// row's (CT3 fix round — was previously ported backwards).
// A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per
// read — safe here (a UI refresh call site, not a per-frame poll).
var candidates = new List<(uint Id, string Text)>();
foreach (uint id in _titles.EarnedTitleIds)
{
if (id == 0) continue;
string? text = _resolveTitle(id);
if (text is null) continue;
candidates.Add((id, text));
}
// FindSortedInsertPosition @0x0049A760: ordinal string sort on the
// resolved display text. OrderBy is a STABLE sort (unlike
// List.Sort) so equal-text rows keep retail's insert-after-
// equals order; ties are broken by title id for full determinism.
List<(uint Id, string Text)> sorted = candidates
.OrderBy(static c => c.Text, StringComparer.Ordinal)
.ThenBy(static c => c.Id)
.ToList();
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)
{
// Build the line array once per text change and capture it
// — LinesProvider runs every draw, so a `=> [new Line(...)]`
// literal would allocate a fresh array every frame
// (pattern: CharacterCreationSkillsPage.cs:829). DefaultColor
// is the row template's own authored font color, not a
// hardcoded white.
UiText.Line[] lines = [new UiText.Line(text, rowText.DefaultColor)];
rowText.LinesProvider = () => lines;
}
_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();
}
/// Retail InfoRegion::SetState(selected ? 6 : 1) — the
/// row's OWN authored Highlight/DirectState media swap, not a
/// synthesized color (see class remarks).
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;
// Cached array, authored color — same reasoning as the row text
// above (CT3 fix round).
UiText.Line[] lines = [new UiText.Line(text, _displayText.DefaultColor)];
_displayText.LinesProvider = () => lines;
}
///
/// UpdateButtons @0x0049A500 (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.
///
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;
}
}