# 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 `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". Recover the separator literal with the PE-read recipe (`claude-memory/reference_pe_byte_decode.md`). - **Level** (`0x1000023B`): `InqInt(0x19)` present → `Formatted(@data_7a0184)`; absent → literal `@data_7b0f34`. - **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. LANDED 2026-08-24.** 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` — matching retail's own unconditional-add / gated-display-set contract (`Handle_Social__AddOrSetCharacterTitle @0x00564260`). Outbound `TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle` on both hosts (`DirectGameRuntimeCommandAdapter` direct-send, `CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/ `LiveSessionCommandRouter` queue) with **NO optimistic local mutation** — 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 no-local-mutation command test (`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin (`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/ 5/13/14) all pass. **CT3 — Titles page UI.** 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. **CT4 — Header identity block.** 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. **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**.