fix(CT): CT3 fix round — port Refresh's unconditional selection clear, drop unresolvable-id rows
Opus dual-lens review of CT3 (03e073b7) found 1 BLOCKER + 2 SHOULD-FIX + notes.
BLOCKER: CharacterTitlesController never ported Refresh @0x0049abc0's own
SetSelectedItem(nullptr, 1) (@0x0049ac5a) — retail clears the current
title selection UNCONDITIONALLY on every Refresh() call, regardless of
whether the previously-selected id is still earned. OnTableReplaced
(0x0029) and OnDisplayTitleChanged (the display half of 0x002B) are
retail's two Refresh() call sites, so both now clear _selectedTitleId
before rebuilding/re-highlighting. OnTitleAdded (0x002B's add half) is a
DIFFERENT retail method — RecvNotice_AddCharacterTitle @0x0049a990 splices
one row without ever touching m_pSelectedItem — so it deliberately still
preserves selection. Net effect: after the user sets a display title and
ACE echoes 0x002B, the previously-highlighted row now goes dark and the
Set-as-Display button re-ghosts, matching retail; earning a new title
while a row is selected still leaves that selection alone.
SHOULD-FIX: ported AddTitleToList @0x0049A840's early-outs
(@0x0049a873/@0x0049a914) — an id of 0, or an id CharacterTitleResolver
fails to resolve, now produces NO row at all. The "Unknown" fallback
literal belongs only to the display-title text (Refresh @0x0049abc0's
other half), never a row — this was previously ported backwards.
SHOULD-FIX: rows and the display text now use their UiText's own authored
DefaultColor instead of a hardcoded Vector4.One, and each LinesProvider
now returns a cached UiText.Line[] built once per text change instead of
allocating a fresh array literal every draw call (pattern:
CharacterCreationSkillsPage.cs:829).
Notes (all ruled in): corrected two CharacterStatController comments that
falsely claimed the Titles page authors its own copies of the raise
buttons (verified against the fixture — it does not; the hide loop that
comment guarded is a defensive no-op given Visible's draw/click cascade,
kept only for the contentPage-not-found fallback); switched the row sort
from List.Sort to a stable OrderBy/ThenBy (ties broken by title id) so
equal-text rows keep retail's insert-after-equals order; wrapped the
title-resolver delegate in RetailUiRuntime.MountCharacter with the same
DatLock the row-template resolver already takes (DatCollection is
documented not thread-safe); set the list box's authored 24px row height
so wheel/line scroll lands row-aligned; kept the bind-time display-text
refresh with a comment explaining why the pre-notice "Unknown" frame is
unreachable in live play (ACE always sends 0x0029 before this panel can
open).
Tests: inverted TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted
into TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned (cites
@0x0049ac5a), added its DisplayTitleChanged twin, and added
TitleAdded_PreservesSelection (the case most at risk from the blocker fix).
Inverted Rows_UnresolvedTitle_ShowsRetailUnknownLiteral into
Rows_UnresolvedTitle_ProducesNoRow (cites @0x0049a873/@0x0049a914) and added
Rows_TitleIdZero_ProducesNoRow for the other early-out. Extended
ClickingSetDisplay_..._AndMutatesNothingLocally to assert the row set and
selection are untouched by the click. Added
Fixture_PageCaptions_ResolveToNonEmptyText, which rebuilds the committed
character_2100002E.json fixture with a stub string resolver to pin this
class's own claim that the two page captions (0x1000052E/0x10000531) carry
a resolvable authored StringInfo.
Verified pre-existing/unrelated: the full hermetic suite run surfaced 2
failures in AcDream.App.Tests (LiveEntityNetworkBranchRoutingTests IL-shape
assertion, GameWindowRenderLeafCompositionTests IL-shape assertion) that
also fail with these five files stashed back to their pre-fix-round state —
confirmed unrelated to this change.
Build green. CharacterTitlesControllerTests: 24/24 (was 21, +3 net after
one invert-and-split and two new facts). Full hermetic solution suite
(Lane!=InstalledDat/PreparedPackage/Live/Manual/Timing/Windows/Linux/
SystemFont, Purpose!=Diagnostic, Status!=KnownFailure): only the two
pre-existing IL-shape failures above; every other project green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ea946257d
commit
4cc9448b0a
5 changed files with 287 additions and 33 deletions
|
|
@ -374,8 +374,12 @@ public static class CharacterStatController
|
|||
// Mutable selected-index box: -1 = nothing selected.
|
||||
|
||||
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
|
||||
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
|
||||
// in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
|
||||
// (0x10000246, 0x100005EB) each appear TWICE under BOTH the Attributes page
|
||||
// (0x1000022B) and the Skills page (0x1000022C) — once per footer-state group
|
||||
// (0x10000247/0x10000241) — four copies total. Verified against the committed
|
||||
// fixture (CT3 fix round): Titles (0x10000539) authors NO copies of its own —
|
||||
// correcting this comment's earlier, false "once per tab page
|
||||
// (Attributes/Skills/Titles)" claim. ImportedLayout._byId keeps only the LAST
|
||||
// mounted copy. We collect all copies so we can hide them all initially and
|
||||
// show/hide the correct set when a row is selected.
|
||||
//
|
||||
|
|
@ -526,10 +530,19 @@ public static class CharacterStatController
|
|||
|
||||
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.
|
||||
// CT3 fix round: the prior comment here ("Titles authors its
|
||||
// own copies of the raise buttons") was FALSE — verified
|
||||
// against the fixture, Titles (0x10000539) has none; see the
|
||||
// corrected collection comment above. contentPage.Visible =
|
||||
// false (just above) already suppresses the Attributes
|
||||
// page's real raise-button copies for both draw and click
|
||||
// routing (UiElement early-returns on an invisible node
|
||||
// before descending to children), so this loop is a no-op
|
||||
// in the common case. It is kept only as a defensive
|
||||
// fallback for the case where contentPage was not found at
|
||||
// bind time (contentPage is null, line ~525) but allRaise1/
|
||||
// allRaise10 were still populated via the tree-walk/FindElement
|
||||
// fallback above.
|
||||
foreach (var b in allRaise1) b.Visible = false;
|
||||
foreach (var b in allRaise10) b.Visible = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Linq;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -29,7 +29,13 @@ namespace AcDream.App.UI.Layout;
|
|||
/// 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."
|
||||
/// mechanism is "rebuild," not "splice." <c>AddTitleToList</c> itself
|
||||
/// early-outs (<c>@0x0049a873</c>/<c>@0x0049a914</c>) before ever reaching
|
||||
/// the insert — retail NEVER creates a row for id 0 or for an id
|
||||
/// <c>GetCharacterTitleFromID</c> fails to resolve, so <see cref="RebuildRows"/>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Selection + highlight.</b> Retail's <c>InfoRegion::SetState</c>
|
||||
|
|
@ -43,6 +49,27 @@ namespace AcDream.App.UI.Layout;
|
|||
/// swap, unlike pages whose row template lacks a state-based highlight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Selection lifetime (CT3 fix round — BLOCKER).</b> <c>Refresh
|
||||
/// @0x0049abc0</c> itself calls <c>SetSelectedItem(nullptr, 1)</c>
|
||||
/// (<c>@0x0049ac5a</c>) UNCONDITIONALLY, before it repopulates the list —
|
||||
/// every code path that reaches <c>Refresh()</c> drops the current
|
||||
/// selection outright, regardless of whether the previously-selected title
|
||||
/// id is still earned. <c>Refresh()</c> runs on BOTH
|
||||
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (<c>0x0029</c>)
|
||||
/// and <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/> (the
|
||||
/// display-title half of <c>0x002B</c>), so <see cref="OnTableReplaced"/>
|
||||
/// and <see cref="OnDisplayTitleChanged"/> both clear
|
||||
/// <see cref="_selectedTitleId"/> before rebuilding/re-highlighting.
|
||||
/// <c>RecvNotice_AddCharacterTitle @0x0049a990</c> (the add half of
|
||||
/// <c>0x002B</c>, <see cref="OnTitleAdded"/>) is a DIFFERENT retail method
|
||||
/// that splices one row into <c>mTitleList</c> without ever touching
|
||||
/// <c>m_pSelectedItem</c> — selection SURVIVES a title add. Concretely:
|
||||
/// after the user sets a display title and ACE echoes <c>0x002B</c>, 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.
|
||||
/// </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
|
||||
|
|
@ -149,6 +176,12 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
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;
|
||||
|
||||
uint scrollbarElementId = listBox.ScrollbarElementId;
|
||||
UiElement? scrollbarElement = scrollbarElementId == 0
|
||||
|
|
@ -175,6 +208,14 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
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;
|
||||
|
|
@ -201,12 +242,15 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
/// <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).
|
||||
/// rebuilds <c>mTitleList</c> from scratch. BLOCKER fix (CT3 fix round):
|
||||
/// <c>Refresh</c> also calls <c>SetSelectedItem(nullptr, 1)</c>
|
||||
/// (<c>@0x0049ac5a</c>) 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.
|
||||
/// </summary>
|
||||
private void OnTableReplaced()
|
||||
{
|
||||
ClearSelection();
|
||||
RebuildRows();
|
||||
RefreshDisplayText();
|
||||
RefreshButtonGhost();
|
||||
|
|
@ -216,6 +260,10 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
/// <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.
|
||||
/// <c>RecvNotice_AddCharacterTitle @0x0049a990</c> splices the one new
|
||||
/// row into <c>mTitleList</c> without ever touching
|
||||
/// <c>m_pSelectedItem</c> (unlike <c>Refresh</c>'s unconditional
|
||||
/// clear) — selection deliberately survives a title add.
|
||||
/// </summary>
|
||||
private void OnTitleAdded(uint titleId)
|
||||
{
|
||||
|
|
@ -223,36 +271,68 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x002B UpdateTitle</c>, display half — this is the other trigger
|
||||
/// for retail's <c>Refresh()</c> (CT2 review anchor 1), so it carries
|
||||
/// the same unconditional <c>SetSelectedItem(nullptr, 1)</c>
|
||||
/// (<c>@0x0049ac5a</c>) as <see cref="OnTableReplaced"/>. This handler
|
||||
/// does not call <see cref="RebuildRows"/> (the row SET is unchanged —
|
||||
/// only the display title moved), so it re-applies highlights directly
|
||||
/// to actually dark out the previously-selected row.
|
||||
/// </summary>
|
||||
private void OnDisplayTitleChanged(uint titleId)
|
||||
{
|
||||
ClearSelection();
|
||||
ApplyRowHighlights();
|
||||
RefreshDisplayText();
|
||||
RefreshButtonGhost();
|
||||
}
|
||||
|
||||
/// <summary><c>Refresh @0x0049abc0</c>'s <c>SetSelectedItem(nullptr,
|
||||
/// 1)</c> (<c>@0x0049ac5a</c>) — clears the tracked selection only; the
|
||||
/// caller is responsible for re-applying row highlights and the button
|
||||
/// ghost state afterward.</summary>
|
||||
private void ClearSelection() => _selectedTitleId = null;
|
||||
|
||||
/// <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).
|
||||
/// (<see cref="UiTemplateListBox.FlushPreservingScroll"/>). Callers that
|
||||
/// mirror retail's unconditional <c>Refresh()</c> selection clear
|
||||
/// (<see cref="OnTableReplaced"/>) call <see cref="ClearSelection"/>
|
||||
/// themselves before this runs; the check below is a defensive
|
||||
/// fallback for any other caller (<see cref="OnTitleAdded"/> included)
|
||||
/// so a selection can never point at a row that no longer exists.
|
||||
/// </summary>
|
||||
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 sorted = new List<(uint Id, string Text)>();
|
||||
var candidates = new List<(uint Id, string Text)>();
|
||||
foreach (uint id in _titles.EarnedTitleIds)
|
||||
sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText));
|
||||
{
|
||||
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.
|
||||
sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text));
|
||||
// resolved display text. OrderBy is a STABLE sort (unlike
|
||||
// List<T>.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)
|
||||
{
|
||||
|
|
@ -272,8 +352,14 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
|
||||
if (UiElement.FindDescendant(row, RowTextId) is UiText rowText)
|
||||
{
|
||||
string capturedText = text;
|
||||
rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)];
|
||||
// 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));
|
||||
|
|
@ -314,7 +400,10 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
{
|
||||
if (_displayText is null) return;
|
||||
string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText;
|
||||
_displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)];
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -4080,10 +4080,20 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
lock (_bindings.Assets.DatLock)
|
||||
return titleRowTemplates.Resolve(templateLayoutId, templateElementId);
|
||||
}
|
||||
// CT3 fix round: CharacterTitleResolver.Resolve reads the SAME
|
||||
// IDatReaderWriter (EnumMapper + StringTable lookups) as the row
|
||||
// template resolver just above — DatCollection is documented not
|
||||
// thread-safe, so this delegate needs the identical DatLock scope,
|
||||
// not just the template resolver.
|
||||
string? TitleResolver(uint titleId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return _bindings.Character.TitleResolver.Resolve(titleId);
|
||||
}
|
||||
_characterTitlesController = Layout.CharacterTitlesController.Bind(
|
||||
layout.Root,
|
||||
_bindings.Character.Titles,
|
||||
_bindings.Character.TitleResolver.Resolve,
|
||||
TitleResolver,
|
||||
TitleTemplateResolver,
|
||||
_bindings.Character.SendSetTitle);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue