Applies all 11 items from the Opus dual-lens review of989f6652(0 blockers, 7 SHOULD-FIX, 4 NOTE): - S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right after PushAlpha and wraps OnDraw + the children walk + OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Deleted the two now-redundant ad-hoc self-clips this supersedes: UiText.DrawText and UiField.DrawMultiLine both pushed their own (0,0,Width,Height) — exactly what the new ambient clip already provides one level up. Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox, an authored INNER sub-rect that can be smaller than and offset from the button's own full rect — a genuine narrower viewport, not a redundant duplicate. - S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override — correct under the old opt-in-false default, inverted under the new default-true (an unconfigured list would stop clipping instead of clipping like everything else). - S4: pinned the escaped-popup input path end to end. New UiAncestorClipTests test mounts a menu inside a short window on a real UiRoot, opens it, and proves a click in the escaped popup region reaches the menu through UiRoot.PopupHit (a plain top-down walk is proven to reject the same point first). UiRoot.WantsMouse now also checks PopupHit — it previously only checked Captured/ HitTestTopDown, so a game action could fire underneath an open dropdown's escaped region. OnMouseDown/OnScroll already routed through PopupHit first (#374); unchanged. - S5: strengthened the Titles-divider regression test's positive half. The old assertion only checked SOME quad's Y fell in a band — vacuously true given other same-band content. Now asserts the divider's exact rect (X and Y), then diffs against the same rect with the divider hidden (Visible=false) to prove the quad was actually attributable to it. - S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/ Options mounted through their real production Bind entry points with a non-zero sprite resolver, drawn via RecordingGpuDevice, asserting a per-window vertex floor (~40-45% of this session's observed baseline: Character 588, Chat 162, Vendor 54, Options 240) plus one key sprite id read LIVE off the bound controller/element (never hardcoded). Character's key sprite (RetailChromeSprites. TopEdge) specifically exercises OnDrawAfterChildren, the exact path S2's caution note flagged. Inventory/Paperdoll/social/map-house skipped — no single fixture-driven top-level Bind entry point. - S6: added the CT-GF1 subsection to the campaign plan's ledger (989f6652+ this fix round; CT7 re-gate still owed). - S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect (0,0,ScreenSize), not null — retail's own popup region is SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region bounded by the screen), not truly unbounded. AD-113 amended. - N1: UiRoot overrides ClipsChildren => false — the root's own region IS the screen (the viewport already scissors it), so this is a safety net against a momentarily zero-sized root silently blanking the whole UI tree under the new ancestor-clip default. - N2: added the empty-clip subtree cull (retail's var_24 gate @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly separate traversal untouched by this change. New test proves a menu inside a fully-clipped (zero-width) window still draws its open popup via the overlay pass while the main pass draws nothing. - N3: CT7 script §5 now 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 the re-gate. - N4: verification below covers both the working tree and the clean committed tree. Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate @0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip); UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen- clipped popup region). Verification (both runs green, --filter "Lane!=InstalledDat& Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing& Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic& Status!=KnownFailure"): full Release solution build green; working tree 14,900+ tests across every project (one LandblockPresentation PipelineTests flake reproduced ONLY under full-solution parallel load, passes standalone and on rerun — unrelated to this change, streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT _TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core tests). Clean committed tree (git stash push -u the uncommitted owner probe + docs files, rerun, stash pop) reported in the session summary. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively (git add -p) so only this commit's own two hunks (ClipsChildren override, WantsMouse) landed; the probe hunk is untouched and stays uncommitted, same as before this fix round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
38 KiB
Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment)
Status: IMPLEMENTATION COMPLETE 2026-08-25 — 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. 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)
- Attribute/skill row icons misaligned vs retail.
- Retail keeps a margin between the value column and the border — the gutter reserved for the list scrollbar that appears when the window is resized shorter. We author no such margin and never show the scrollbar on Attributes.
- The character window is resizable in Y down to an authored minimum in retail; ours is not.
- Header identity block: retail shows the name, then " " (e.g. "Female Aluvian War Mage"), then "Non-Player Killer" / "Player Killer" / "Player Killer Lite" in pure white — on Attributes AND Skills. We show gender+heritage only, no PK line contract, color off.
- Level number color slightly off vs retail.
- Titles tab is inert (AP-109): retail lists all earned titles (sorted), shows the current display title, and lets the player set one ("Set as Display Title"); scrollbar with many titles; the header identity line updates live when the display title changes.
- All windows share retail's authored minimum-size behavior — resize clamps to the authored constraints everywhere.
Retail recon (verified 2026-08-24, this session)
Titles page — gmCharacterTitleUI
PostInit @0x0049A610binds: display-title text0x1000052F, "Set as Display Title" button0x10000535, title ListBox0x10000532. Registers notice handlers for the title-table / add-title / set-display-title notices.- Rows carry the title id in attribute
0x1000008E;AddTitleToList @0x0049A840resolves the display string viaCharacterTitleTable::GetCharacterTitleFromID(DAT title-string table — CT2 locates the DID) and inserts SORTED (FindSortedInsertPosition @0x0049A760). It writes the resolved title text into row child0x10000537and stamps the row's id viaSetAttribute_Enum(row, 0x1000008E, titleId), inserting the row viaAddItemFromTemplateList(listBox, 0, insertPos)— CT3 mirrors this exact write shape when populating0x10000532. - CORRECTED (CT1 fix round 2026-08-24):
UpdateButtons @0x0049A500— the display button is GHOSTED (state 0xd) UNLESS a row is SELECTED whose title id differs from the current display title; no selection → Ghosted. (Not "ghosts when selected == current" — that phrasing had the no-selection case backwards.) Verbatim mechanism: a no-match selection falls through to index0xFFFFFFFF→GetItemreturns null →SetState(0xd). Selection change (msg 4/0x43) re-runs it. - Clicking
0x10000535sendsCM_Social::Event_SetDisplayCharacterTitle(titleId)(ListenToElementMessage @0x0049A6D0). gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50→ the stat panel refreshes its header when the display title changes.
Wire (ACE cross-checked)
- Inbound
CharacterTitleevent0x0029(already in ourGameEventType):u32 =1, u32 displayTitleId, u32 count, count × u32 titleId(GameEventCharacterTitle.cs). - Inbound
UpdateTitleevent0x002B:u32 titleId, u32 setAsDisplay(GameEventUpdateTitle.cs). - Outbound
TitleSetGameAction (GameActionSetTitle.cs):u32 titleId. Retail sender:CM_Social::Event_SetDisplayCharacterTitle.
Header identity — gmStatManagementUI::PostInit @0x004EFD90
Binds name 0x10000231, heritage line 0x10000232, PK line
0x10000233, level 0x1000023B, total XP 0x10000235, XP-to-level
0x10000238 + meter 0x10000236, luminance pair 0x100005C5/C6, list
box 0x1000023D. The refresh (vtable slot, near
UpdateExperience @0x004F0A70) composes the heritage line WITH the
display title; the PK strings are exactly "Player Killer" /
"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite —
cross-anchor CharExamineUI::SetAppraiseInfo @0x004B45F0). CT5 reads
the composing function verbatim before writing a line of C#.
CT4 contract (read verbatim 2026-08-24, Fable)
gmStatManagementUI::UpdateCharacterInfo @0x004F0770:
- Name line (
0x10000231):ACCWeenieObject::GetObjectName(player, NAME_SINGULAR)throughAllegianceData::GetFullName @0x005B6950(read verbatim): whenAllegianceSystem::GetTitle(rank, heritage, gender)yields an allegiance rank title, the line is "<sep @data_794098>"; otherwise the plain name. The owner's retail screenshot (plain "Dww") is the rankless case. - Heritage line (
0x10000232):AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71, heritage=Int 0xBC, 0)("Female Aluvian"); then, whenCharacterTitleTable::GetCharacterTitleFromID(m_titleID)resolves,AppendText(separator @data_794358)+AppendText(titleString)→ "Female Aluvian War Mage". PE-read RECOVERED (2026-08-24): the separator@data_794358is a single space" "; the allegiance-rank separator@data_794098is likewise" ". - Level (
0x1000023B):InqInt(0x19)present →Formatted(@data_7a0184)="%d"; absent → literal@data_7b0f34="???"(both PE-read recovered 2026-08-24). - PK line (
0x10000233):UpdatePKStatus @0x004F00A0— three-wayIsPK/IsPKLite/ neither → StringInfo from table enum0x10000001(= StringTable0x23000001, same compute_str_hash mechanism the chat labels use) with keysID_StatManagement_Header_PKStatus_PK/_PKL/_NPK. - Related key family for the footer/meter (already-shipped surfaces —
audit only if CT1 finds drift):
ID_StatManagement_Footer_*,ID_StatManagement_Header_XPToLevelMeterInfinity.
Already in-tree
- Tab/page ids wired (
TabTitlesId 0x10000538,TitlesPageId 0x10000539); pages currently show retail-authored closed visuals. - Header labels partially bound (
StatHeaderLine+PkStatusseams exist inCharacterStatController.Bind— content contract wrong). GameEventType.CharacterTitle/UpdateTitleenum entries exist and now (CT2, landed) have a parser, aRuntimeCharacterTitleStateowner, and an outboundTitleSetbuilder — see CT2's paragraph below.- The character window registers with
DatConstraintSource— authored min/max plumbing exists inRetailWindowFrame; Y-resize for this window and the list-scrollbar contract do not.
Slices
CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed ca4100e7, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round e264d839.
Research: docs/research/2026-08-24-campaign-ct-dat-ground-truth.md;
9 InstalledDat pins in CharacterPanelLiveDatTests. Three corrections
now BINDING on later slices:
(a) the character root (0x10000227, Type-8 TabControl) authors NO
min/max constraints and MountCharacter wires no DatConstraintSource
— CT6 must first find retail's actual minimum mechanism in the decomp
(likely class behavior, not a DAT property);
(b) row templates are reachable ONLY via the targeted
ImportInfos(dats, layoutId, elementId) overload (0x10000248 stat
row in 0x21000045; 0x10000536 title row in 0x2100005E) — the
plain import's #375 prototype-skip hides them;
(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4),
name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the
282px row edge; row Highlight media is 0x06000F93 (code uses
0x06001397 — flagged, CT5 verifies).
Title chain verified end-to-end: titleId → EnumMapper 0x22000041
(canonical key, e.g. ID_CharacterTitle_War_Mage) → compute_str_hash →
StringTable 0x2300000E → text (id 13 = "War Mage").
Original scope: Live-DAT probe of layout
0x2100002E: attribute/skill row templates (icon x/y vs our hand-built
rows), the value-column right margin, header element fonts/colors
(level 0x1000023B color — item 5), Titles-page elements
(0x1000052F/32/35 geometry, row template, scrollbar), window
min/max constraints. Output: research doc + InstalledDat pins (the
tooltip/scrollbar-pin pattern). No production changes.
CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed bcfddc97, Opus review (0 blockers, 4 should-fix), fix round 544f8cb2. Parsed
0x0029 CharacterTitle (retail's CharacterTitleTable::UnPack @0x005c6e90 — the leading ACE 1u/retail-Pack-constant field is
discarded, matching retail's own read) and 0x002B UpdateTitle
(CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0: title id +
setAsDisplay). New sibling owner RuntimeCharacterTitleState
(RuntimeCharacterState.Titles) holds the earned-title set + display
title id, clears at generation reset (CaptureOwnership/IsConverged
extended with TitleCount/DisplayTitleIsDefault), and fires
TableReplaced/TitleAdded/DisplayTitleChanged. Outbound
TitleSet (0x002C) ships behind IRuntimeCharacterCommands.SetTitle
on both hosts (DirectGameRuntimeCommandAdapter direct-send,
CurrentGameRuntimeCommandAdapter via the LiveCommandBus/
LiveSessionCommandRouter queue) — verified against retail's own
CM_Social::Event_SetDisplayCharacterTitle @0x006a5720, which sends
the wire message and touches no local field; the display title updates
only from the server's own echo. No register row: this slice
introduces no retail deviation. App-layer CharacterTitleResolver
(src/AcDream.App/UI/Layout/CharacterTitleResolver.cs) ports
GetCharacterTitleFromID's EnumMapper(0x22000041) → hash →
StringTable(0x2300000E) chain for CT3/CT4 to consume; Runtime stays
id-only. Conformance tests against ACE's writer shapes
(tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs),
Runtime owner tests (RuntimeCharacterTitleStateTests.cs +
RuntimeCharacterStateTests.cs integration), a wire-send command test
(DirectGameRuntimeCommandAdapterTests.cs), and an InstalledDat pin
(CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14) all pass.
CT2 fix round (Opus dual-lens review, 2026-08-24). Four SHOULD-FIX
corrections landed. F1 (the important one): the NOTICE broadcast is
unconditional (retail's server-side SendNotice_AddCharacterTitle fires
regardless of prior membership), but the client-side table ADD is
DEDUPED — gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990
walks mTitleList and returns without effect when the id is already
present, only inserting + adding the row on a miss.
RuntimeCharacterTitleState.ApplyUpdateTitle (which models the CLIENT
receive side, not the server send side) now fires TitleAdded only on a
genuine new membership; the inverted pin is
ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision.
F3: the send-side titleId == 0 rejection is REMOVED from both
command adapters — retail's own send path
(Event_SetDisplayCharacterTitle @0x006a5720) packs whatever id it is
handed, and ACE accepts id 0 (CharacterTitle.Invalid is a defined enum
value); retail's actual protection is the UI ghost-when-current gate
(CT3's job), not a send-side rejection. No register row: removing the
guard makes acdream MORE retail-exact, not less. The fix round also
closed four SHOULD-FIX-adjacent items: A2 (ResetSession now publishes
TableReplaced unconditionally and DisplayTitleChanged when the
display id was non-zero before the clear, matching the
LocalPlayerState.Clear() precedent), A3 (RuntimeCharacterState .CaptureOwnership reads the new non-allocating Titles.Count instead of
EarnedTitleIds.Count), A4 (the whole mutation in ReplaceTable/
ApplyUpdateTitle now happens under one _gate hold, with change flags
computed inside the lock and events raised after release), and A5 (every
revision bump is now gated on an actual state change — a no-op wire
resend produces zero revision edges; TableReplaced itself still fires
unconditionally per retail's own Refresh() dispatch). A1
(CharacterTitleResolverLiveDatTests now honors ACDREAM_DAT_DIR
first), A6 (documented the EmitResult primaryObjectId-as-title-id
precedent inline), A7 (corrected the "third consumer" comment — CT1 §5
already records gmAttributeUI::PostInit's icon-DID lookup as that third
consumer; CT5 is where the shared GetDIDByEnum helper gets factored),
and A8 (CharacterTitleResolver now memoizes the final resolved string
per title id, the DAT-static equivalent of retail's lazy-hash cache on
the string buffer) round out the fix round.
CT3 anchors from the CT2 review (carried forward for CT3 to consume, not yet acted on):
- CT3 must refresh the display-title TEXT from
TableReplacedas well asDisplayTitleChanged— retail'sRecvNotice_UpdateCharacterTitleTableunconditionallyRefresh()es on every0x0029arrival, not only when the display id differs. - ACE sends NO echo when re-setting the already-current title — the Set-as-Display button must not wait for a confirmation that never arrives; retail prevents the send in the first place via the UI ghost-when-current gate.
- Retail's fallback display text when a title id doesn't resolve is the
hardcoded literal
"Unknown"(Refresh @0x0049abc0), not a StringTable key —CharacterTitleResolver.Resolvereturningnullis the correct signal for CT3 to substitute that literal. - The deduped client-side add contract (F1 above) — CT3's title-list
row rendering must not assume every
TitleAddedfiring corresponds to a wire arrival; the reverse still holds (every genuine new row has aTitleAddedfiring).
CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed 03e073b7, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round 4cc9448b; full hermetic suite re-verified green after a load-flake false alarm. Bind the authored page through the standard
GUI classes (UiTemplateListBox/UiScrollbar/UiButton — zero
bespoke widgets): sorted rows via the ported title-table lookup,
selection, ghost-when-current logic (state 0xd contract), display-title
text, Set-as-Display round trip, scrollbar. Retires half of AP-109.
CT3 fix round (Opus dual-lens review, 2026-08-24). BLOCKER: ported
Refresh @0x0049abc0's unconditional SetSelectedItem(nullptr, 1)
(@0x0049ac5a) — selection now clears on BOTH TableReplaced and
DisplayTitleChanged, regardless of whether the previously-selected id
is still earned in the new table, but deliberately survives
TitleAdded (RecvNotice_AddCharacterTitle @0x0049a990 splices one
row without ever touching m_pSelectedItem — a genuinely different
retail method from Refresh). SHOULD-FIX: AddTitleToList @0x0049A840's
early-outs (@0x0049a873/@0x0049a914) ported — an id of 0, or an id
CharacterTitleResolver.Resolve fails to resolve, now produces NO row
at all (the "Unknown" fallback literal belongs only to the
display-title text, never a row — this was previously ported
backwards); rows use the row template's own authored DefaultColor
instead of a hardcoded white, and each row/display-text UiText.Line[]
is built once per text change and cached instead of reallocated every
draw call. Notes also applied: corrected two comments that falsely
claimed the Titles page authors its own copies of the raise buttons
(verified against the fixture — it does not; the hide loop that
comment guarded is a defensive no-op, kept only for the
contentPage-not-found fallback path), switched the row sort from
List.Sort to a stable OrderBy/ThenBy (ties broken by title id),
wrapped the title-resolver delegate in the same DatLock the
row-template resolver already takes (RetailUiRuntime.MountCharacter),
and set the list box's authored 24px row height so wheel/line scroll
lands row-aligned.
CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed ed652ed8, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round e7e32409. Retail composition: name; "
"; PK status line — authored fonts/colors
(pure white per probe), live refresh on display-title change and PK
status, identical on Attributes AND Skills pages. Level color from the
authored element. Retires the rest of AP-109's UI half.
CT4 landing notes (2026-08-24). Verified the existing Label(...) seam
already covers both Attributes/Skills page copies — CharacterStatController
binds the SAME physically-visible container (contentPage = the Attributes
page chain) for both tabs; the Skills-page duplicate header subtree is never
shown (pinned by Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated,
a test that PREDATES CT4 — corrected at the CT4 fix round below, since the
original wording here implied CT4 wrote it fresh; the pre-existing test did
not cover PkStatusId until the fix round extended it).
All four header identity elements (Name/Heritage/PkStatus/Level) switched
from hand-picked Body/Gold runtime colors to the widget's own authored
DefaultColor (LabelAuthoredColor), matching CT1's live-DAT pin exactly —
the former "runtime color, dat carries none" comment was false. PK status now
resolves through StringTable 0x23000001 by key with a bitwise IsPK/IsPKLite
test (the prior exact-equality switch silently dropped combined-flag
values); live-DAT-verified authored strings: ID_StatManagement_Header_PKStatus_PK
→ "Player Killer", _PKL → "Player Killer Lite", _NPK → "Non-Player Killer"
(pinned in CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings).
Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal
"???" when absent (CharacterSheet.Level is now int?). The heritage
line's appended title now comes from CT2/CT3's RuntimeCharacterTitleState.DisplayTitleId
resolved through CharacterTitleResolver, refreshing live on both
TableReplaced and DisplayTitleChanged (CharacterSheetProvider's
ChangeBinding now subscribes to both). Name-line ruling: ships the
PLAIN-NAME case only — retail's allegiance rank-title prefix
(AllegianceData::GetFullName @0x005b6950 → AllegianceSystem::GetTitle @0x005b8dd0) needs a ~200-string, 22-function heritage×gender table
(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g.
GetAluvianMaleTitle @0x005b7bc0's "Yeoman"/"Baronet"/"Baron"/"Reeve"/
"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of
reasonable size for this slice; RuntimeAllegianceState already carries the
local player's own rank, so only the string table is missing. Luminance
(item 5): the DATA (CharacterSheet.AvailableLuminance/MaximumLuminance,
PropertyInt64 6/7) already flows generically through both the
PlayerDescription snapshot parser and the live 0x02CF private-update path
— no wiring gap existed — and the retail show/hide gate
(Level >= 200 && MaximumLuminance != 0, UpdateExperience @0x004F0A70) is
wired and toggles Visible on both 0x100005C5/0x100005C6, but the
label's caption and the value's composed number format could not be
recovered this slice (retail's SetText source resolves through a
Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT
string-table sweep found no match) — content stays unbound rather than
guessed. AP-109 narrowed accordingly (register row updated in the same
commit, not deleted — the two open items above remain). Tests:
CharacterStatControllerTests (heritage composition + live update, name
stays plain, level int/"???" with authored — not constant — color, PK line
shows resolved text in authored color, luminance visibility across five
level/luminance combinations) and CharacterSheetProviderTests (PK
key-by-status resolution including a combined-flag case, no-resolver ⇒ null,
Level null-vs-present, title resolution + live refresh on both title
events + unsubscribe-on-dispose, luminance Int64 read-through).
CT4 fix round (Opus dual-lens review, 2026-08-25). 2 BLOCKERS: (1) the
luminance caption/value strings were RECOVERED by PE-byte-decoding the raw
retail binary (caption UTF-16 "Luminance:" at @0x007c3dd4, value narrow
"%s / %s" at @0x007c3dcc, both immediately following
gmStatManagementUI::UpdatePKStatus's own vftable slots — the CT4 landing's
"could not be recovered" claim is FALSIFIED), so the pair now binds real
text (each number formatted through a new shared FormatXp helper —
.ToString("N0", InvariantCulture), also now used by Total XP / XP-to-next-
level, replacing their un-invariant .ToString("N0")), and the hide path
switched from Visible = false to retail's own UIElement_Text::ClearAllText
mechanism (@0x004f0e31/@0x004f0e3c — empty the LinesProvider, leave
layout); (2) CharacterIdentityText.StripLeadingArticle is deleted — retail
AppendTexts the resolved title VERBATIM (@0x004f0990), and 26 real ACE
CharacterTitle entries begin with "The", so every one of them was being
mangled; the dead CharacterSheet.Race fallback (no retail producer — the
InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0
at @0x004f08db) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now
classifies off the live ClientObject.PublicWeenieBitfield PWD bits
(0x20/0x02000000, ACCWeenieObject::IsPK/IsPKLite
@0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw
PropertyInt 134 — PropertyInt 134 carries ACE's own PlayerKillerStatus
enum bit layout, not the PWD layout, so the deleted 0x4 | 0x8 combined-flag
test case asserted a non-retail answer (PropertyInt 134 already drives the
correct PWD bits via PlayerKillerStatusBitfield.Apply, so this is a
re-source, not new wiring); (4) the register's AP-109 row restores CT3's
Titles-page narrowing paragraph (CT4's edit had compressed it to a bare
pointer phrase), corrects the rank-prefix item's source to PropertyInt
0x1E (AllegianceRank) read live off the qualities bundle — NOT
RuntimeAllegianceState, which is a DIFFERENT UI's (SocialAllegiancePageController)
own documented substitute — corrects the title-table size from the
originally-estimated 22 functions/~200 strings to the ACTUAL 17
functions/~170 strings (AllegianceSystem::GetTitle's dispatch switch read
directly: Gearknight/Tumerok author only a male function reused both ways,
Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades
the row's evidence claim to "synthetic-layout binding tests plus a small
number of InstalledDat string/DID pins" rather than implying a
connected/live gate; (5) CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors
gains the luminance pair's own occurrence-count + font/color pins, matching
the pattern every other header id already uses. Also landed this round: an
InstalledDat pin (GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain)
proving CharacterIdentityText.GenderDisplayName/HeritageGroupDisplayName
match the live retail EnumMapper chain (master map category 1 →
ClientEnumToID[0x10000001]/[0x10000002] → EnumMapper DIDs
0x2200000A/0x2200000B) byte-exact, including the two entries (10
"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses —
both are correct; the mechanism divergence (hardcoded table vs. live DAT
read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic
RetailDataIdResolver.Resolve helper (not a new "GetDIDByEnum helper" to
write) as the unification seam; RetailAppraisalNameResolver.ResolveHeritage's
independent re-implementation of the same three overrides is noted there
too, for CT5. CharacterSheetProvider.BuildSheet's level read switched from
a GetInt + Ints.ContainsKey double dictionary lookup to one
TryGetValue.
CT5 — Row alignment + value gutter. 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_AuthorsFixedWidthAndBottomOnlyResizeContractpin) confirmed the research lead's hypothesis exactly: the sharedgmPanelUIhost0x100005FE(LayoutDesc0x2100006E) 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 — matchingUIElement_Resizebar::StartMouseResizing @0x0046B7E0'sGetParent()call andUIElement::MouseResizeElement @0x00461130'sGetAttribute_Int(this, 0x3C..0x3F)reads off that same parent. The Character/Skills slot0x1000018Eitself authors no constraints of its own (confirmed, same pin).RetailUiRuntime.MountCharacternow imports that host element and passes it asDatConstraintSource. 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. Host0x100005FEis not a content element our wrapper adds chrome to; it IS retail's own outer window frame (5px bevel + 300×362 content parent0x10000180+ 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 untilRetailWindowManager.ResizeToforcibly widened it despiteResizeX=false. Fixed with a newRetailWindowFrame.Options.DatConstraintSourceIsOuterFrameopt-out (chrome inset = 0 for constraint resolution when set — the value stays DAT-sourced, only the redundant inset is skipped);MountCharactersets 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 inRetailWindowFrame.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.