acdream/docs/plans/2026-08-24-character-panel-parity-campaign.md
Erik 4cbbdaf4bf docs(CT): CT5 review-closed (fix round 0a37a28e)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:48:08 +02:00

26 KiB
Raw Blame History

Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment)

Status: PLANNED 2026-08-24 (owner gate report after the CA5/chat rounds). Execution model: Fable plans and coordinates; Sonnet implements each slice; Opus runs the dual-lens review (retail-faithful + architectural) per slice, then a fix round. No pushes to gitea until the owner says so. Register: this campaign retires AP-109 (inert Titles page) when CT3+CT4 land; every deviation a slice introduces adds its row in the same commit.

Owner report (2026-08-24, screenshots on file)

  1. Attribute/skill row icons misaligned vs retail.
  2. Retail keeps a margin between the value column and the border — the gutter reserved for the list scrollbar that appears when the window is resized shorter. We author no such margin and never show the scrollbar on Attributes.
  3. The character window is resizable in Y down to an authored minimum in retail; ours is not.
  4. Header identity block: retail shows the name, then " " (e.g. "Female Aluvian War Mage"), then "Non-Player Killer" / "Player Killer" / "Player Killer Lite" in pure white — on Attributes AND Skills. We show gender+heritage only, no PK line contract, color off.
  5. Level number color slightly off vs retail.
  6. Titles tab is inert (AP-109): retail lists all earned titles (sorted), shows the current display title, and lets the player set one ("Set as Display Title"); scrollbar with many titles; the header identity line updates live when the display title changes.
  7. All windows share retail's authored minimum-size behavior — resize clamps to the authored constraints everywhere.

Retail recon (verified 2026-08-24, this session)

Titles page — gmCharacterTitleUI

  • PostInit @0x0049A610 binds: display-title text 0x1000052F, "Set as Display Title" button 0x10000535, title ListBox 0x10000532. Registers notice handlers for the title-table / add-title / set-display-title notices.
  • Rows carry the title id in attribute 0x1000008E; AddTitleToList @0x0049A840 resolves the display string via CharacterTitleTable::GetCharacterTitleFromID (DAT title-string table — CT2 locates the DID) and inserts SORTED (FindSortedInsertPosition @0x0049A760). It writes the resolved title text into row child 0x10000537 and stamps the row's id via SetAttribute_Enum(row, 0x1000008E, titleId), inserting the row via AddItemFromTemplateList(listBox, 0, insertPos) — CT3 mirrors this exact write shape when populating 0x10000532.
  • CORRECTED (CT1 fix round 2026-08-24): UpdateButtons @0x0049A500 — the display button is GHOSTED (state 0xd) UNLESS a row is SELECTED whose title id differs from the current display title; no selection → Ghosted. (Not "ghosts when selected == current" — that phrasing had the no-selection case backwards.) Verbatim mechanism: a no-match selection falls through to index 0xFFFFFFFFGetItem returns null → SetState(0xd). Selection change (msg 4/0x43) re-runs it.
  • Clicking 0x10000535 sends CM_Social::Event_SetDisplayCharacterTitle(titleId) (ListenToElementMessage @0x0049A6D0).
  • gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50 → the stat panel refreshes its header when the display title changes.

Wire (ACE cross-checked)

  • Inbound CharacterTitle event 0x0029 (already in our GameEventType): u32 =1, u32 displayTitleId, u32 count, count × u32 titleId (GameEventCharacterTitle.cs).
  • Inbound UpdateTitle event 0x002B: u32 titleId, u32 setAsDisplay (GameEventUpdateTitle.cs).
  • Outbound TitleSet GameAction (GameActionSetTitle.cs): u32 titleId. Retail sender: CM_Social::Event_SetDisplayCharacterTitle.

Header identity — gmStatManagementUI::PostInit @0x004EFD90

Binds name 0x10000231, heritage line 0x10000232, PK line 0x10000233, level 0x1000023B, total XP 0x10000235, XP-to-level 0x10000238 + meter 0x10000236, luminance pair 0x100005C5/C6, list box 0x1000023D. The refresh (vtable slot, near UpdateExperience @0x004F0A70) composes the heritage line WITH the display title; the PK strings are exactly "Player Killer" / "Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite — cross-anchor CharExamineUI::SetAppraiseInfo @0x004B45F0). CT5 reads the composing function verbatim before writing a line of C#.

CT4 contract (read verbatim 2026-08-24, Fable)

gmStatManagementUI::UpdateCharacterInfo @0x004F0770:

  • Name line (0x10000231): ACCWeenieObject::GetObjectName(player, NAME_SINGULAR) through AllegianceData::GetFullName @0x005B6950 (read verbatim): when AllegianceSystem::GetTitle(rank, heritage, gender) yields an allegiance rank title, the line is "<sep @data_794098>"; otherwise the plain name. The owner's retail screenshot (plain "Dww") is the rankless case.
  • Heritage line (0x10000232): AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71, heritage=Int 0xBC, 0) ("Female Aluvian"); then, when CharacterTitleTable::GetCharacterTitleFromID(m_titleID) resolves, AppendText(separator @data_794358) + AppendText(titleString) → "Female Aluvian War Mage". PE-read RECOVERED (2026-08-24): the separator @data_794358 is a single space " "; the allegiance-rank separator @data_794098 is likewise " ".
  • Level (0x1000023B): InqInt(0x19) present → Formatted(@data_7a0184) = "%d"; absent → literal @data_7b0f34 = "???" (both PE-read recovered 2026-08-24).
  • PK line (0x10000233): UpdatePKStatus @0x004F00A0 — three-way IsPK / IsPKLite / neither → StringInfo from table enum 0x10000001 (= StringTable 0x23000001, same compute_str_hash mechanism the chat labels use) with keys ID_StatManagement_Header_PKStatus_PK / _PKL / _NPK.
  • Related key family for the footer/meter (already-shipped surfaces — audit only if CT1 finds drift): ID_StatManagement_Footer_*, ID_StatManagement_Header_XPToLevelMeterInfinity.

Already in-tree

  • Tab/page ids wired (TabTitlesId 0x10000538, TitlesPageId 0x10000539); pages currently show retail-authored closed visuals.
  • Header labels partially bound (StatHeaderLine + PkStatus seams exist in CharacterStatController.Bind — content contract wrong).
  • GameEventType.CharacterTitle/UpdateTitle enum entries exist and now (CT2, landed) have a parser, a RuntimeCharacterTitleState owner, and an outbound TitleSet builder — see CT2's paragraph below.
  • The character window registers with DatConstraintSource — authored min/max plumbing exists in RetailWindowFrame; Y-resize for this window and the list-scrollbar contract do not.

Slices

CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed ca4100e7, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round e264d839. Research: docs/research/2026-08-24-campaign-ct-dat-ground-truth.md; 9 InstalledDat pins in CharacterPanelLiveDatTests. Three corrections now BINDING on later slices: (a) the character root (0x10000227, Type-8 TabControl) authors NO min/max constraints and MountCharacter wires no DatConstraintSource — CT6 must first find retail's actual minimum mechanism in the decomp (likely class behavior, not a DAT property); (b) row templates are reachable ONLY via the targeted ImportInfos(dats, layoutId, elementId) overload (0x10000248 stat row in 0x21000045; 0x10000536 title row in 0x2100005E) — the plain import's #375 prototype-skip hides them; (c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4), name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the 282px row edge; row Highlight media is 0x06000F93 (code uses 0x06001397 — flagged, CT5 verifies). Title chain verified end-to-end: titleId → EnumMapper 0x22000041 (canonical key, e.g. ID_CharacterTitle_War_Mage) → compute_str_hash → StringTable 0x2300000E → text (id 13 = "War Mage"). Original scope: Live-DAT probe of layout 0x2100002E: attribute/skill row templates (icon x/y vs our hand-built rows), the value-column right margin, header element fonts/colors (level 0x1000023B color — item 5), Titles-page elements (0x1000052F/32/35 geometry, row template, scrollbar), window min/max constraints. Output: research doc + InstalledDat pins (the tooltip/scrollbar-pin pattern). No production changes.

CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed bcfddc97, Opus review (0 blockers, 4 should-fix), fix round 544f8cb2. Parsed 0x0029 CharacterTitle (retail's CharacterTitleTable::UnPack @0x005c6e90 — the leading ACE 1u/retail-Pack-constant field is discarded, matching retail's own read) and 0x002B UpdateTitle (CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0: title id + setAsDisplay). New sibling owner RuntimeCharacterTitleState (RuntimeCharacterState.Titles) holds the earned-title set + display title id, clears at generation reset (CaptureOwnership/IsConverged extended with TitleCount/DisplayTitleIsDefault), and fires TableReplaced/TitleAdded/DisplayTitleChanged. Outbound TitleSet (0x002C) ships behind IRuntimeCharacterCommands.SetTitle on both hosts (DirectGameRuntimeCommandAdapter direct-send, CurrentGameRuntimeCommandAdapter via the LiveCommandBus/ LiveSessionCommandRouter queue) — verified against retail's own CM_Social::Event_SetDisplayCharacterTitle @0x006a5720, which sends the wire message and touches no local field; the display title updates only from the server's own echo. No register row: this slice introduces no retail deviation. App-layer CharacterTitleResolver (src/AcDream.App/UI/Layout/CharacterTitleResolver.cs) ports GetCharacterTitleFromID's EnumMapper(0x22000041) → hash → StringTable(0x2300000E) chain for CT3/CT4 to consume; Runtime stays id-only. Conformance tests against ACE's writer shapes (tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs), Runtime owner tests (RuntimeCharacterTitleStateTests.cs + RuntimeCharacterStateTests.cs integration), a wire-send command test (DirectGameRuntimeCommandAdapterTests.cs), and an InstalledDat pin (CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14) all pass.

CT2 fix round (Opus dual-lens review, 2026-08-24). Four SHOULD-FIX corrections landed. F1 (the important one): the NOTICE broadcast is unconditional (retail's server-side SendNotice_AddCharacterTitle fires regardless of prior membership), but the client-side table ADD is DEDUPED — gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks mTitleList and returns without effect when the id is already present, only inserting + adding the row on a miss. RuntimeCharacterTitleState.ApplyUpdateTitle (which models the CLIENT receive side, not the server send side) now fires TitleAdded only on a genuine new membership; the inverted pin is ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision. F3: the send-side titleId == 0 rejection is REMOVED from both command adapters — retail's own send path (Event_SetDisplayCharacterTitle @0x006a5720) packs whatever id it is handed, and ACE accepts id 0 (CharacterTitle.Invalid is a defined enum value); retail's actual protection is the UI ghost-when-current gate (CT3's job), not a send-side rejection. No register row: removing the guard makes acdream MORE retail-exact, not less. The fix round also closed four SHOULD-FIX-adjacent items: A2 (ResetSession now publishes TableReplaced unconditionally and DisplayTitleChanged when the display id was non-zero before the clear, matching the LocalPlayerState.Clear() precedent), A3 (RuntimeCharacterState .CaptureOwnership reads the new non-allocating Titles.Count instead of EarnedTitleIds.Count), A4 (the whole mutation in ReplaceTable/ ApplyUpdateTitle now happens under one _gate hold, with change flags computed inside the lock and events raised after release), and A5 (every revision bump is now gated on an actual state change — a no-op wire resend produces zero revision edges; TableReplaced itself still fires unconditionally per retail's own Refresh() dispatch). A1 (CharacterTitleResolverLiveDatTests now honors ACDREAM_DAT_DIR first), A6 (documented the EmitResult primaryObjectId-as-title-id precedent inline), A7 (corrected the "third consumer" comment — CT1 §5 already records gmAttributeUI::PostInit's icon-DID lookup as that third consumer; CT5 is where the shared GetDIDByEnum helper gets factored), and A8 (CharacterTitleResolver now memoizes the final resolved string per title id, the DAT-static equivalent of retail's lazy-hash cache on the string buffer) round out the fix round.

CT3 anchors from the CT2 review (carried forward for CT3 to consume, not yet acted on):

  1. CT3 must refresh the display-title TEXT from TableReplaced as well as DisplayTitleChanged — retail's RecvNotice_UpdateCharacterTitleTable unconditionally Refresh()es on every 0x0029 arrival, not only when the display id differs.
  2. ACE sends NO echo when re-setting the already-current title — the Set-as-Display button must not wait for a confirmation that never arrives; retail prevents the send in the first place via the UI ghost-when-current gate.
  3. Retail's fallback display text when a title id doesn't resolve is the hardcoded literal "Unknown" (Refresh @0x0049abc0), not a StringTable key — CharacterTitleResolver.Resolve returning null is the correct signal for CT3 to substitute that literal.
  4. The deduped client-side add contract (F1 above) — CT3's title-list row rendering must not assume every TitleAdded firing corresponds to a wire arrival; the reverse still holds (every genuine new row has a TitleAdded firing).

CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed 03e073b7, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round 4cc9448b; full hermetic suite re-verified green after a load-flake false alarm. Bind the authored page through the standard GUI classes (UiTemplateListBox/UiScrollbar/UiButton — zero bespoke widgets): sorted rows via the ported title-table lookup, selection, ghost-when-current logic (state 0xd contract), display-title text, Set-as-Display round trip, scrollbar. Retires half of AP-109.

CT3 fix round (Opus dual-lens review, 2026-08-24). BLOCKER: ported Refresh @0x0049abc0's unconditional SetSelectedItem(nullptr, 1) (@0x0049ac5a) — selection now clears on BOTH TableReplaced and DisplayTitleChanged, regardless of whether the previously-selected id is still earned in the new table, but deliberately survives TitleAdded (RecvNotice_AddCharacterTitle @0x0049a990 splices one row without ever touching m_pSelectedItem — a genuinely different retail method from Refresh). SHOULD-FIX: AddTitleToList @0x0049A840's early-outs (@0x0049a873/@0x0049a914) ported — an id of 0, or an id CharacterTitleResolver.Resolve fails to resolve, now produces NO row at all (the "Unknown" fallback literal belongs only to the display-title text, never a row — this was previously ported backwards); rows use the row template's own authored DefaultColor instead of a hardcoded white, and each row/display-text UiText.Line[] is built once per text change and cached instead of reallocated every draw call. Notes also applied: corrected two 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, kept only for the contentPage-not-found fallback path), switched the row sort from List.Sort to a stable OrderBy/ThenBy (ties broken by title id), wrapped the title-resolver delegate in the same DatLock the row-template resolver already takes (RetailUiRuntime.MountCharacter), and set the list box's authored 24px row height so wheel/line scroll lands row-aligned.

CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed ed652ed8, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round e7e32409. Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK status, identical on Attributes AND Skills pages. Level color from the authored element. Retires the rest of AP-109's UI half.

CT4 landing notes (2026-08-24). Verified the existing Label(...) seam already covers both Attributes/Skills page copies — CharacterStatController binds the SAME physically-visible container (contentPage = the Attributes page chain) for both tabs; the Skills-page duplicate header subtree is never shown (pinned by Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated, a test that PREDATES CT4 — corrected at the CT4 fix round below, since the original wording here implied CT4 wrote it fresh; the pre-existing test did not cover PkStatusId until the fix round extended it). All four header identity elements (Name/Heritage/PkStatus/Level) switched from hand-picked Body/Gold runtime colors to the widget's own authored DefaultColor (LabelAuthoredColor), matching CT1's live-DAT pin exactly — the former "runtime color, dat carries none" comment was false. PK status now resolves through StringTable 0x23000001 by key with a bitwise IsPK/IsPKLite test (the prior exact-equality switch silently dropped combined-flag values); live-DAT-verified authored strings: ID_StatManagement_Header_PKStatus_PK → "Player Killer", _PKL → "Player Killer Lite", _NPK → "Non-Player Killer" (pinned in CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings). Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???" when absent (CharacterSheet.Level is now int?). The heritage line's appended title now comes from CT2/CT3's RuntimeCharacterTitleState.DisplayTitleId resolved through CharacterTitleResolver, refreshing live on both TableReplaced and DisplayTitleChanged (CharacterSheetProvider's ChangeBinding now subscribes to both). Name-line ruling: ships the PLAIN-NAME case only — retail's allegiance rank-title prefix (AllegianceData::GetFullName @0x005b6950AllegianceSystem::GetTitle @0x005b8dd0) needs a ~200-string, 22-function heritage×gender table (verbatim hardcoded literals in the decomp, not DAT-resolved — e.g. GetAluvianMaleTitle @0x005b7bc0's "Yeoman"/"Baronet"/"Baron"/"Reeve"/ "Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of reasonable size for this slice; RuntimeAllegianceState already carries the local player's own rank, so only the string table is missing. Luminance (item 5): the DATA (CharacterSheet.AvailableLuminance/MaximumLuminance, PropertyInt64 6/7) already flows generically through both the PlayerDescription snapshot parser and the live 0x02CF private-update path — no wiring gap existed — and the retail show/hide gate (Level >= 200 && MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles Visible on both 0x100005C5/0x100005C6, but the label's caption and the value's composed number format could not be recovered this slice (retail's SetText source resolves through a Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT string-table sweep found no match) — content stays unbound rather than guessed. AP-109 narrowed accordingly (register row updated in the same commit, not deleted — the two open items above remain). Tests: CharacterStatControllerTests (heritage composition + live update, name stays plain, level int/"???" with authored — not constant — color, PK line shows resolved text in authored color, luminance visibility across five level/luminance combinations) and CharacterSheetProviderTests (PK key-by-status resolution including a combined-flag case, no-resolver ⇒ null, Level null-vs-present, title resolution + live refresh on both title events + unsubscribe-on-dispose, luminance Int64 read-through).

CT4 fix round (Opus dual-lens review, 2026-08-25). 2 BLOCKERS: (1) the luminance caption/value strings were RECOVERED by PE-byte-decoding the raw retail binary (caption UTF-16 "Luminance:" at @0x007c3dd4, value narrow "%s / %s" at @0x007c3dcc, both immediately following gmStatManagementUI::UpdatePKStatus's own vftable slots — the CT4 landing's "could not be recovered" claim is FALSIFIED), so the pair now binds real text (each number formatted through a new shared FormatXp helper — .ToString("N0", InvariantCulture), also now used by Total XP / XP-to-next- level, replacing their un-invariant .ToString("N0")), and the hide path switched from Visible = false to retail's own UIElement_Text::ClearAllText mechanism (@0x004f0e31/@0x004f0e3c — empty the LinesProvider, leave layout); (2) CharacterIdentityText.StripLeadingArticle is deleted — retail AppendTexts the resolved title VERBATIM (@0x004f0990), and 26 real ACE CharacterTitle entries begin with "The", so every one of them was being mangled; the dead CharacterSheet.Race fallback (no retail producer — the InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0 at @0x004f08db) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now classifies off the live ClientObject.PublicWeenieBitfield PWD bits (0x20/0x02000000, ACCWeenieObject::IsPK/IsPKLite @0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw PropertyInt 134 — PropertyInt 134 carries ACE's own PlayerKillerStatus enum bit layout, not the PWD layout, so the deleted 0x4 | 0x8 combined-flag test case asserted a non-retail answer (PropertyInt 134 already drives the correct PWD bits via PlayerKillerStatusBitfield.Apply, so this is a re-source, not new wiring); (4) the register's AP-109 row restores CT3's Titles-page narrowing paragraph (CT4's edit had compressed it to a bare pointer phrase), corrects the rank-prefix item's source to PropertyInt 0x1E (AllegianceRank) read live off the qualities bundle — NOT RuntimeAllegianceState, which is a DIFFERENT UI's (SocialAllegiancePageController) own documented substitute — corrects the title-table size from the originally-estimated 22 functions/~200 strings to the ACTUAL 17 functions/~170 strings (AllegianceSystem::GetTitle's dispatch switch read directly: Gearknight/Tumerok author only a male function reused both ways, Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades the row's evidence claim to "synthetic-layout binding tests plus a small number of InstalledDat string/DID pins" rather than implying a connected/live gate; (5) CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors gains the luminance pair's own occurrence-count + font/color pins, matching the pattern every other header id already uses. Also landed this round: an InstalledDat pin (GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain) proving CharacterIdentityText.GenderDisplayName/HeritageGroupDisplayName match the live retail EnumMapper chain (master map category 1 → ClientEnumToID[0x10000001]/[0x10000002] → EnumMapper DIDs 0x2200000A/0x2200000B) byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses — both are correct; the mechanism divergence (hardcoded table vs. live DAT read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic RetailDataIdResolver.Resolve helper (not a new "GetDIDByEnum helper" to write) as the unification seam; RetailAppraisalNameResolver.ResolveHeritage's independent re-implementation of the same three overrides is noted there too, for CT5. CharacterSheetProvider.BuildSheet's level read switched from a GetInt + Ints.ContainsKey double dictionary lookup to one TryGetValue.

CT5 — Row alignment + value gutter. REVIEW-CLOSED 2026-08-25: landed f532f28c, Opus review (0 blockers, 4 should-fix incl. the authored Normal-state row band 0x06004CC2), fix round 0a37a28e. Reconcile our hand-built attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves the scrollbar gutter.

CT6 — Resize + scrollbar contract. CT6 research lead (Fable, 2026-08-24, follows CT1 correction (a)): gmPanelUI::ResizeTo @0x004BC6E0 is a bare tailcall to UIElement::ResizeTo — no clamp there, so the minimum lives in the generic Resizebar drag path reading element attributes, and the authoring to probe is the PANEL-HOST layout 0x2100006E's slot elements (Character slot per RetailPanelCatalog, sibling of social 0x1000018F) — NOT the character layout root CT1 probed. First CT6 step: probe the host slots' min/max + resize authoring, then read UIElement_Resizebar::StartMouseResizing @0x0046B7E0's clamp source verbatim. Original scope: Character window Y-resizable to the authored minimum; the stat list shows its scrollbar when the resized viewport overflows (the full-track/disabled behavior from the 2026-08-24 scrollbar work applies as-is); authored min/max constraint enforcement verified as the STANDARD path for every registered window (one shared mechanism in RetailWindowFrame/RetailWindowManager, no per-window special cases).

CT7 — Connected gate. Test script (docs/research/2026-08-24-campaign-ct-test-script.md), owner drive: titles round trip against ACE (earn/set/display), header lines vs retail side-by-side, resize behavior, row alignment screenshots.

Review protocol

Per slice: Sonnet implements → Opus dual-lens review (lens 1 retail-faithfulness vs the cited decomp anchors; lens 2 architecture — GUI-class standardization, Runtime ownership boundaries, no controller-side state) → fix round → full hermetic suite green. Commits to the worktree branch as slices land; no gitea push until the owner directs it.