26 KiB
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)
- Attribute/skill row icons misaligned vs retail.
- 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.
- The character window is resizable in Y down to an authored minimum in retail; ours is not.
- 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.
- Level number color slightly off vs retail.
- 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.
- 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 @0x0049A610binds: display-title text0x1000052F, "Set as Display Title" button0x10000535, title ListBox0x10000532. Registers notice handlers for the title-table / add-title / set-display-title notices.- Rows carry the title id in attribute
0x1000008E;AddTitleToList @0x0049A840resolves the display string viaCharacterTitleTable::GetCharacterTitleFromID(DAT title-string table — CT2 locates the DID) and inserts SORTED (FindSortedInsertPosition @0x0049A760). It writes the resolved title text into row child0x10000537and stamps the row's id viaSetAttribute_Enum(row, 0x1000008E, titleId), inserting the row viaAddItemFromTemplateList(listBox, 0, insertPos)— CT3 mirrors this exact write shape when populating0x10000532. - 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 index0xFFFFFFFF→GetItemreturns null →SetState(0xd). Selection change (msg 4/0x43) re-runs it. - Clicking
0x10000535sendsCM_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
CharacterTitleevent0x0029(already in ourGameEventType):u32 =1, u32 displayTitleId, u32 count, count × u32 titleId(GameEventCharacterTitle.cs). - Inbound
UpdateTitleevent0x002B:u32 titleId, u32 setAsDisplay(GameEventUpdateTitle.cs). - Outbound
TitleSetGameAction (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)throughAllegianceData::GetFullName @0x005B6950(read verbatim): whenAllegianceSystem::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, whenCharacterTitleTable::GetCharacterTitleFromID(m_titleID)resolves,AppendText(separator @data_794358)+AppendText(titleString)→ "Female Aluvian War Mage". PE-read RECOVERED (2026-08-24): the separator@data_794358is a single space" "; the allegiance-rank separator@data_794098is 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-wayIsPK/IsPKLite/ neither → StringInfo from table enum0x10000001(= StringTable0x23000001, same compute_str_hash mechanism the chat labels use) with keysID_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+PkStatusseams exist inCharacterStatController.Bind— content contract wrong). GameEventType.CharacterTitle/UpdateTitleenum entries exist and now (CT2, landed) have a parser, aRuntimeCharacterTitleStateowner, and an outboundTitleSetbuilder — see CT2's paragraph below.- The character window registers with
DatConstraintSource— authored min/max plumbing exists inRetailWindowFrame; 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):
- CT3 must refresh the display-title TEXT from
TableReplacedas well asDisplayTitleChanged— retail'sRecvNotice_UpdateCharacterTitleTableunconditionallyRefresh()es on every0x0029arrival, not only when the display id differs. - 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.
- Retail's fallback display text when a title id doesn't resolve is the
hardcoded literal
"Unknown"(Refresh @0x0049abc0), not a StringTable key —CharacterTitleResolver.Resolvereturningnullis the correct signal for CT3 to substitute that literal. - The deduped client-side add contract (F1 above) — CT3's title-list
row rendering must not assume every
TitleAddedfiring corresponds to a wire arrival; the reverse still holds (every genuine new row has aTitleAddedfiring).
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 @0x005b6950 → AllegianceSystem::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. 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.