diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md new file mode 100644 index 00000000..8de1aad3 --- /dev/null +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -0,0 +1,139 @@ +# 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`). +- `UpdateButtons @0x0049A500`: the display button GHOSTS (state 0xd) + when the selected row's title id == `mDisplayTitle`; Normal (1) + otherwise. 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#. + +### 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; no + parser, no state owner, no outbound builder. +- 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.** 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.** Parse `0x0029`/`0x002B`; +locate the DAT title-string table `GetCharacterTitleFromID` reads and +port the lookup; `RuntimeCharacterState` owns the title set + display +title (J4.3 owner; clears at generation reset); outbound `TitleSet` +builder behind a typed Runtime command; ordered change events for UI +and headless bots (#368 contract: hosts observe the same owner). +Conformance tests against ACE's writer shapes. + +**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.** 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**.