598 lines
39 KiB
Markdown
598 lines
39 KiB
Markdown
# 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:** 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
|
||
**"<Gender> <Heritage> <DisplayTitle>"** (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
|
||
"<RankTitle><sep @data_794098><Name>"; 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; "<Gender>
|
||
<Heritage> <DisplayTitle>"; 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**.
|