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; } }