# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) **Status:** IMPLEMENTATION COMPLETE 2026-08-25 (+ gate-fix CT-GF1 review-closed: retail ancestor+self clip `989f6652`/`025108a8`/`90a0da68`) — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. **CT7 GATE PASSED 2026-08-25** — two rounds: full pass, then one regression (plugin markup text under the CT-GF1 self-clip, fixed `752782d0`) re-gated PASS. Owner retail observation recorded: the Titles divider IS visible inside retail's window while scrolling — a retail quirk the clipped rendering reproduces. NOT pushed to gitea (owner directive). **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:** [STALE-INTENT CORRECTION 2026-08-25, at Campaign AS's AS5 re-review: this retirement never happened — CT3/CT4 NARROWED AP-109 and Campaign AS AS5 later closed its rank-prefix residual; the row remains ACTIVE-narrowed to CT4's FormatXp sliver.] Original intent: retire 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 `0xFFFFFFFF` → `GetItem` 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 ""; 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 @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 `AppendText`s 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. REVIEW-CLOSED 2026-08-25: landed `ec50455a`, Opus review (1 blocker: chrome-inclusive host constraints double-counted; S4 default-height ruling = retail's 372px), fix round `996cd736`.** 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). **CT6 landing notes (2026-08-25, implementation).** Live probe (dumped + deleted, pattern preserved by the new `CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract` pin) confirmed the research lead's hypothesis exactly: the shared `gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372, MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar (`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent — matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s `GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s `GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The Character/Skills slot `0x1000018E` itself authors no constraints of its own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports that host element and passes it as `DatConstraintSource`. **CORRECTED (CT6 fix round, BLOCKER B1):** this paragraph originally claimed the mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382, MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host `0x100005FE` is not a content element our wrapper adds chrome to; it IS retail's own outer window frame (5px bevel + 300×362 content parent `0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset on top double-counted the bevel, clamping MinWidth to 320 while the window's actual mounted outer width stayed 310 — silently below its own minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite `ResizeX=false`. Fixed with a new `RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out (chrome inset = 0 for constraint resolution when set — the value stays DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets it true. The mounted outer clamps are now EXACTLY what the host authors: width fixed **310**, height **372..1000** — no composed arithmetic. A new mount-time invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer extent falls outside its own just-computed clamp) would have caught B1 at the very first test run; it is now permanent for every window this path mounts. **S4 (2026-08-25, campaign-lead ruling — which number governs the default mount size):** `0x2100002E`'s own root is authored 300×600 (the "Size tension" the earlier ground-truth doc left unresolved — CT3's Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real authored canvas, but it is the CONTENT's own design surface, not the mounted default — retail scroll-clips it into the shared host's much smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter` left `ContentHeight` unset, so it fell back to the raw 600px canvas, producing a stale 610px mounted default (600 + 10px chrome inset) that was never retail's actual opening size. **372 (the host's own outer frame, 362 content + 10 chrome) is the number that governs the mount default** — it is also exactly the host's own authored MinHeight, so retail's Character/Skills window opens AT its resize floor and can only be dragged taller, never shorter. `MountCharacter` now sets `Options.ContentHeight = 362f` explicitly to realize this. The authored page composition (header 112 + list 160 + divider + footer) IS the 362px design; at that default the 9 attribute/vital rows (180px content) OVERFLOW the 160px list, so the stat list's scrollbar is active immediately on open — retail-correct, not a regression (see S2 below for what "active" actually looks like). Persistence still restores a user-chosen size within the 372..1000 clamp on top of this default. Full derivation + decomp anchors: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` §CT6. `CharacterStatController.RebuildActiveList` now wraps BOTH the Attributes and Skills tabs' rows in the same `UiScrollablePanel` viewport (previously only Skills got one; Attributes rows were added directly to the ListBox with no clipping/scrolling and the shared scrollbar was force-hidden — the owner's item 2). The shared scrollbar is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph originally claimed `UiScrollbar`'s own `IsPresentationVisible`/ `IsModelDisabled` "draw the correct full-track 'disabled' thumb when content fits (`HideWhenDisabled` defaults false)" — that had the authored default BACKWARDS. `0x1000023E` (this scrollbar) and `0x10000533` (the Titles list's own scrollbar) both author property `0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true` on both elements' property 121/0x79 in the committed fixture). A fitting list HIDES the bar entirely; it does not leave a full-track disabled thumb visible. The code was already correct — `.Visible = true` only keeps the bar in the tree, `IsPresentationVisible` does the actual show/hide — only this description was wrong; fixed here, in `CharacterStatController.RebuildActiveList`'s own comment, in the CT7 script, and in `CharacterStatControllerTests`' comment, plus a new `IsPresentationVisible` assertion pair added to the resize test (hidden once growing makes the content fit, visible+interactive while overflowing). This surfaced and fixed a real, previously-unexercised `#372`/ `#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom` anchor was capturing its baseline margins lazily on its OWN first `ApplyAnchor` call, which happens AFTER the ListBox has already grown from its raw DAT height (160px) to its mounted height — measuring a bogus non-zero margin that permanently capped the viewport short on every later resize. Fixed with an eager `viewport.CaptureCurrentAnchorBaseline()` call right after `AddChild`, mirroring the identical fix already shipped in `UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix round, S3):** `CharacterTitlesController.Bind` originally gained the same defensive `if (listBox.LayoutPolicy is null) Anchors = Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that `CharacterStatController` already had for its own list. Both `0x10000532` and the Titles page container `0x10000539` author `HasOriginalParentSize=true` in the real DAT AND the committed fixture, which makes `LayoutImporter`/`DatWidgetFactory` always assign a real `LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a harmless no-op "matching the established pattern for synthetic/test layouts" as originally described. Deleted rather than left as dead code; a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize` on both elements to guard the deletion against future DAT drift. STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement .MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at `RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit overrides, are the ONLY clamp fields — read identically by the interactive drag path (`UiRoot`'s resize handling), the programmatic path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s main-panel geometry sync and this slice's tests exercise), and the persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`). `RetailWindowFrame.Mount` remains the single production mount path (no window bypasses it). New regression pin (**renamed, CT6 fix round N4**: the original name `NineSlice_ChatShapedConstraints_ ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises NineSlice inset ARITHMETIC on a content-shaped source (490×100, height-only synthetic constraints) and never actually pinned chat's real DAT contract, since no width constraints were even set): `RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize` proves the same mechanism still clamps chat-shaped constraints after Character was wired onto it. A new companion test, `Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints (matching production's actual `MountChat` wiring) and asserts no inset applies — the true chat-contract pin the renamed test's name no longer claims to be. Tests: `CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar` (window-level: clamp at authored min/max, list shrink, scrollbar overflow flip, footer stays bottom-docked, grow-back restore) and `CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips` (same contract for the Titles list) plus the pre-existing 126+22-test suites, all updated where the new nested-viewport DOM shape required it (`Descendants(list)` instead of `list.Children` — the shape Skills rows already needed). No register row: **CORRECTED (CT6 fix round, N5)** — before the B1 fix this sentence ("every number is either a live-probed authored DAT value ... nothing inferred") was not actually true: the mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive values plus a second, redundant chrome inset composed on top). After B1 removes that composition, the mounted clamp is now literally the host's own four probed values with zero arithmetic applied — the sentence holds for real. No register row for the S4 content-height default either: 362 is the same host content-parent width/height CT6 already probed and cited (`0x10000180`, 300×362), not a new number. **CT7 — Connected gate.** Test script (`docs/research/2026-08-25-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. ### CT-GF1 — client-wide retained-UI ancestor clip (gate finding + fix round) Landed `989f6652`: ports retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection as `UiElement.ClipsChildren`'s new client-wide default (true), fixing the CT7 gate's own first finding — the Titles page's authored divider `0x10000530` escaping the Character window above its top edge at the CT6-correct 372px mounted default. One opt-out (`UiElement.ExpandsClipForPopup`, `UiMenu`'s inline-drawn popup) plus new `UiAncestorClipTests` mechanism coverage. **Fix round** (Opus dual-lens review, 0 blockers / 7 SHOULD-FIX / 4 NOTE, all applied): moved the ambient clip to wrap `OnDraw` + children + `OnDrawAfterChildren` in one block — the literal `DrawHere` shape, clipping an element's own `DrawSelf` too, not just its children (`UIElement_Text:: DrawSelf @0x00467AA0`; `UIRegion::DrawSelf @0x0069F1A0`) — and deleted the two now-redundant ad-hoc self-clips it superseded (`UiText.DrawText`, `UiField.DrawMultiLine`); kept the one that clips to a genuinely smaller authored inner rect (`UiButton.DrawBlockLabel`'s `LabelBox`/`ValueBox`). Deleted `UiItemList`'s `ClipsChildren` override (inverted under the new default). Pinned the escaped-popup input path end to end (`UiRoot.PopupHit` routing, `WantsMouse`) with a new real-`UiRoot` test. Strengthened the Titles-divider regression test's positive half (exact-rect assertion + visible/hidden diff, not a bare Y-band check). Added a draw-capture regression sweep across Character/Chat/Vendor/Options mounted through their real controllers (`UiWindowDrawCaptureSweepTests`). `PushClipUnbounded` now resets to the screen rect, not `null` — retail's own popup region is screen-clipped, not truly unbounded (AD-113 amended). `UiRoot.ClipsChildren` now explicitly overrides false (the root's own region IS the screen — a safety net against a momentarily zero-sized root blanking the whole UI). Added the empty-clip subtree cull (retail's `var_24` gate), scoped to the main draw pass only — the popup's separate `DrawOverlays` traversal is provably unaffected (new coverage: a menu inside a fully-clipped window still draws its popup). **Owed:** the CT7 re-gate (script `docs/research/2026-08-25-campaign-ct-test- script.md`) still needs the owner's connected drive — this fix round landed on the automated side only. §5 of that script now also names the collapsed-toolbar check and the four highest-overflow windows (combat/ vitals bar, Options bottom-button row, map/house page, floaty chat) as explicit eyeball items for that same re-gate. ## 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**.