Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, 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 only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
44 KiB
Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E)
Status: RESEARCH COMPLETE 2026-08-24. No production code changed in this
slice. Findings feed CT2–CT6 (docs/plans/2026-08-24-character-panel-parity-campaign.md).
Method: temporary Assert.Fail probe tests (deleted before commit; the
pattern is preserved in tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs
and its sibling CharacterPanelLiveDatTests.cs written by this slice) against
the installed DAT set (%USERPROFILE%\Documents\Asheron's Call), driven
through LayoutImporter.ImportInfos / ElementInfo
(src/AcDream.App/UI/Layout/ElementReader.cs,
src/AcDream.App/UI/Layout/LayoutImporter.cs).
1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E)
All header elements are DUPLICATED — the imported 0x2100002E tree carries
two structurally identical copies of the whole header block, one reached
through the Attributes page chain (0x10000227 > 0x1000022B > 0x10000226 > 0x10000230) and one through the Skills page chain (... > 0x1000022C > ...). The two copies are geometrically and stylistically IDENTICAL —
this is the "duplicated stat-management branches" quirk documented on
PrepareSkillScrollbar in CharacterStatController.cs. Caveat: this
"identical" claim rests on the deleted probe tests, not the committed
pins. CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors
only asserts font/color/margin equality across the two copies (and the
2-count) — it does not assert X/Y/W/H geometric equality between them.
Treat "geometrically identical" as probe-session observation, not a
pinned fact, until a future slice adds a geometry-equality assertion.
Coordinate frame: every X,Y below is parent-relative, not
page-relative — e.g. Name 0,0 means the top-left of its immediate
parent container (0x10000230), not the top-left of the Attributes/
Skills page. Cross-reference
docs/research/2026-06-25-character-window-faithful-spec.md for the
page-relative numbers if you need the header block's position within
the page itself; do not mix the two frames when placing elements.
| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins |
|---|---|---|---|---|---|---|---|---|---|
| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 |
| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 |
| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | 0 |
| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | (1, 0.9490196, 0.49803922, 1) | true | 0 |
| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 |
| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — |
| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 |
| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 |
| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 |
| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 |
Header block container (0x10000230) is 300×110 inside the shared prototype layout 0x21000045 — the character content column itself is only 230px wide (name/heritage/PK/XP all live in that 230px column), with a 5px vertical divider (0x10000239, X=230, W=5) separating it from the level box (X=235..300, matching the plan's "Level area (65,50)" spec).
Confirmations vs. the owner report / plan:
- Item 4 (PK line pure white): CONFIRMED —
0x10000233FontColor is exactly white, not the "color off" state the owner reported. The bug is purely inCharacterStatController.Bind's runtime color choice (Body= parchment(0.92,0.90,0.82,1)), not a DAT-reading gap. CT4 must switch the PK-line color toVector4.One. - Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is
(1, 0.9490196, 0.49803922, 1)(a pale gold, ~RGB 255/242/127) with Outline=true.CharacterStatController.Goldis currently(1, 0.82, 0.36, 1)(a deeper orange-gold) with no outline applied. CT4 should read the DAT FontColor + Outline directly instead of hand-picking a runtime color, matching howLevelId's dat FontDid is already honored.
2. Stat list (0x1000023D) + row templates
0x1000023D (Type 5 ListBox) is duplicated the same harmless way as the
header (once per Attributes/Skills page chain, identical geometry both
times): X=0 Y=112 W=300 H=160, ScrollbarElementId=0x1000023E
(scrollbar at X=281 W=16 H=160, i.e. always reserved, whether or not it's
shown). Its authored TemplateList (dat property 0x64) names FIVE
same-layout-family entries, all in LayoutDesc 0x21000045:
0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C
These are NOT reachable by walking ImportInfos(dats, 0x21000045u)'s built
tree — LayoutImporter.ImportInfos intentionally filters out same-layout
template-list targets (the #375 fix documented in
LayoutImporter.ImportInfos's own comment: "retail never instantiates a
template-list element as a live widget… building them here parked two live
prototype rows… over and outside the framed panel"). The correct read path
— and the one CT5 must use — is the targeted single-root overload:
LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId), the same
seam UiTemplateListBox's TemplateResolver already uses for other
authored row templates.
Dumping all five with that overload:
0x10000248 — the ONE shared data-row template (icon + name + value)
Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20
StateMedia[Normal] File=0x06004CC2 DrawMode=1
StateMedia[Highlight] File=0x06000F93 DrawMode=1
Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime)
Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white
Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white
This is the single row template used for BOTH the attribute rows AND the
skill rows (retail's gmAttributeUI/gmSkillUI share it). Ground truth
for item 1 (icon alignment) and item 2 (value-column gutter):
- Icon: 20×20, flush at the row's left edge (X=0), full row height.
Current code (
CharacterStatController.IconSize = 16f,RowPadX = 4f) draws a 16×16 icon at X=4 — smaller AND offset from retail's flush-left 20×20. This is the "icons misaligned" bug (item 1). - Name column: X=25, W=150 (fixed pixel widths, not a width fraction).
Current code computes
nameX = RowPadX + IconSize + IconGap(4+16+6=26, off by one from retail's 25) andnameW = width * 0.60(a content-relative fraction retail does not use at all — retail's name column is a FIXED 150px regardless of the 282px row width). - Value column: X=175, W=100, right-justified. Value's right edge sits at X=275. The row template itself is 282px wide, so there is a 7px gap between the value's right edge and the row's own right edge — this is the "authored margin between the value column and the border" the owner reported (item 2), confirmed as exactly 7px at the row-template level.
- The bare authored rectangles (no derived arithmetic — see the
caveat below): ListBox
0x1000023DisX=0 Y=112 W=300 H=160; its scrollbar0x1000023EisX=281 W=16 H=160; the data row template isW=282; the value column inside the row isX=175 W=100, right-justified, 7px from the row's own right edge (282 - (175+100) = 7). - These numbers do NOT compose into a tidy "gutter" story — do not
infer one.
300 - 281 = 19, not the row's 282px width's complement (300 - 282 = 18); and the 282px row actually OVERLAPS the 281px scrollbar band by 1px (281 < 282). An earlier draft of this doc described the row as "inset by 18px to clear an always-reserved scrollbar gutter" and summed 18+7=25 — that decomposition does not close against the authored numbers above and is inference, not fact; withdrawn. CT5 must implement the authored numbers directly (row width 282 — matches the existingSkillContentWidth = 282fconstant already in the code; value column X=175 W=100; scrollbar X=281 W=16), never a derivedlistWidth - 18or similar formula. - Row background:
Normalstate file0x06004CC2. The description "generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED — no cross-reference sweep for other consumers of0x06004CC2was run this slice; only the file id itself, as authored on this specific template, is pinned.Highlightstate file0x06000F93.
SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged
Retail's selected attribute/skill row draws the row template's Highlight
state media — 0x06000F93, not CharacterStatController's current
0x06001397u. This is no longer a hedge; the decomp confirms the
mechanism end to end:
gmAttributeUI::UpdateSelection @0x0049DEE0callsSetState(selected ? 6 : 1)on the row.InfoRegion::SetState @0x004F0EE0forwards that state to the row element instantiated from template0x10000248— the exact template this slice dumped, whose Highlight-state file is0x06000F93.- State
6ISUIStateId.Highlight— so the selected row draws0x10000248's ownHighlightmedia, not a separately-chosen sprite.
CharacterStatController.cs's comment near lines 111–113 ("matches
retail... sprite 0x06001397 visual intent") is falsified for this
element. CT5 must correct RowHighlightSprite to 0x06000F93 for the
STAT rows and should reconsider UseSelectionBars/HighlightBg, which
currently emulate the wrong sprite's art (a translucent gold tint tuned
to look like 0x06001397's dark bars, not 0x06000F93's actual look).
0x06001397 is not a phantom constant — it is legitimate ELSEWHERE.
The spellbook row prototype 0x10000343 has a separate selected-overlay
CHILD element 0x10000342 whose media IS 0x06001397
(UIElement_UIItem::SetSelectedState @0x004E1240 mechanism — a
different code path from InfoRegion::SetState, and a different visual
composition: an overlay child, not a state-swap on the row itself). CT5
must correct the STAT rows ONLY and must NOT touch SpellbookRowStyle.cs
or its tests — 0x06001397 is correct there.
0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions
Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90
Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86
Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98
Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89
These are single full-width caption bars (no icon/name/value split), 280px
wide (2px narrower than the data row — no scrollbar-gutter inset needed
since they never scroll independently). Their sprites are an EXACT match
for the existing constants already in CharacterStatController.cs:
| Constant | Value | Probe file | Match |
|---|---|---|---|
SkillHeaderSpecializedSprite |
0x06000F90 | 0x10000249 | YES |
SkillHeaderTrainedSprite |
0x06000F86 | 0x1000024A | YES |
SkillHeaderUntrainedSprite |
0x06000F98 | 0x1000024B | YES |
SkillHeaderUnusableSprite |
0x06000F89 | 0x1000024C | YES |
These four are correctly ported already; no CT5 work needed here.
Row-height divergence (CT5 gold, found this fix round)
The row template above authors H=20 (line Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20, already pinned by
CharacterPanelLiveDatTests.AttributeRowTemplate_...). Current code
matches this for skill rows (CharacterStatController.SkillRowHeight = 20f) but NOT for attribute rows, which use a separate
CharacterStatController.RowHeight = 22f constant. CT5 must fix the
attribute-row path to 20px; there is no authored basis for 22 anywhere
in the row template.
Row instantiation + icon-DID anchors (CT5 gold, found this fix round)
InfoRegion::InfoRegion @0x004F1450 instantiates each stat row via
AddItemFromTemplateList(listBox, 0, ...) — template index 0, i.e.
0x10000248, the shared data row confirmed above — and binds
0x1000012A (label), 0x1000012B (value), 0x10000129 (icon) via
UIRegion::SetImageByDID(icon, did, 3) (icon draw mode 3). Per-attribute
icon DIDs come from DBObj::GetDIDByEnum(statEnum, category 0x10000002) in gmAttributeUI::PostInit @0x0049DB70 — a THIRD consumer
of the GetDIDByEnum master-map mechanism documented in §5 below
(alongside the title EnumMapper/StringTable pair and RetailKeyNames),
which is enough precedent that CT2/CT5 should factor a shared
GetDIDByEnum(enumValue, category) helper instead of hardcoding a third
independent DID pair.
gmSkillUI::RebuildSkillList @0x0049C3A0 adds template indices 1–4
(0x10000249..0x1000024C) for the section-header captions, confirming
the section-header order already pinned by
SkillSectionHeaderTemplates_MatchExistingSpriteConstants above:
Specialized, Trained, Untrained, Unusable.
3. Titles page (0x10000539 subtree, imported as part of 0x2100002E)
0x10000539 (Type 0x10000046, the Titles page container) is NOT
duplicated like the Attributes/Skills content — it appears once, as a
direct child of the tab-control root 0x10000227 (siblings with the
Attributes/Skills tab buttons and the Titles tab button 0x10000538).
Geometry: X=0 Y=25 W=300 H=575 (fills the window below the 25px tab bar).
| Element | Id | X,Y | W×H | Notes |
|---|---|---|---|---|
| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption |
| Current display title text | 0x1000052F | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white |
| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 |
| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption |
| Title ListBox | 0x10000532 | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 |
| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared RetailScrollbarChrome media (thumb/up/down ids identical to the base skin pinned by ScrollbarSkinLiveDatTests) |
| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 |
| "Set as Display Title" button | 0x10000535 | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=Ghosted (matches the plan's UpdateButtons ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/Ghosted states each |
Title row template — LayoutDesc 0x2100005E, element 0x10000536
Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24
StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3
StateMedia[Highlight] File=0x06001AAF DrawMode=1
Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white
A single-line text row, no icon column (titles have no per-row icon in
retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the
ListBox content width exactly (0x10000532 is 270 wide, with its
scrollbar 0x10000533 living OUTSIDE that width at X=280 — unlike the
stat list, the title row template does NOT need its own internal
scrollbar-gutter inset because the ListBox width itself already excludes
the scrollbar column).
Same targeted-root import gotcha as the stat templates applies here:
LayoutImporter.ImportInfos(dats, 0x2100005Eu) (the whole-layout
overload) returns the SAME element 0x1000052D, but does NOT surface
0x10000536 as a reachable child. 0x1000052D is not a throwaway
container — per the BaseElement/BaseLayoutId table in
docs/research/2026-06-25-character-window-faithful-spec.md (line
~19), 0x1000052D is the authored BaseElement that the Titles page
root 0x10000539 inherits its content from (0x10000539's
BaseLayoutId is 0x2100005E, BaseElement is 0x1000052D) — it is
the real authored Titles-page content, just reached by a different path
than the live-mounted tree. Use
LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u) (the
targeted single-root overload) to reach the row template 0x10000536
underneath it.
4. Window min/max constraints
Character window (0x2100002E) root
LayoutImporter.ImportInfos(dats, 0x2100002Eu) returns element
0x10000227 (Type 0x8, TabControl) as the tree root — this IS the
top-level element retail's RetailUiRuntime.MountCharacter() mounts via
RetailWindowFrame.Mount(..., layout.Root, ...). It authors NO
MinWidth/MinHeight/MaxWidth/MaxHeight properties (dat properties
0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of MinW/MinH/MaxW/MaxH
blank for 0x10000227 and every element under it, including the footer,
header, and Titles page).
Chat window (0x2100006F) root, for comparison
LayoutImporter.ImportInfos(dats, 0x2100006Fu) returns element
0x10000600 (Type 0x10000050, a self-contained "window" element that
directly includes its own dragbar (Type 2), border frame (Type 3), and
FOUR resize-grip corners (Type 9) as children — none of which the
character layout's root has). It DOES author constraints:
MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.
Correction to the plan
The plan's "Already in-tree" section states: "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." Read literally this implies the character window's
RetailWindowFrame.Mount call already sets DatConstraintSource. It
does not. RetailUiRuntime.MountCharacter() (src/AcDream.App/UI/RetailUiRuntime.cs,
~line 4043) constructs RetailWindowFrame.Options with ResizeY = true,
ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true —
but no DatConstraintSource, MinHeight, or MaxHeight field at
all, unlike e.g. MountSideVitals() (~line 1474) which explicitly sets
DatConstraintSource = info from its own imported root. This is
consistent with what CT1 also found in the DAT itself: 0x2100002E's root
authors no size constraints to plumb through in the first place — chat's
window-frame elements are its own self-contained LayoutDesc, while
0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome
supplied by a separate mechanism.
Treat the "Already in-tree" plan bullet as inaccurate: start CT6 from the decomp for the window-frame class instead of assuming the wiring is already 90% done. The paragraph below replaces this doc's earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the verified mechanism.
Verified resize mechanism (2026-08-24, supersedes the hypothesis above)
UIElement::ResizeTo @0x00463C30 clamps ONLY via element attributes —
0x3C (clamp-max-height), 0x3E (clamp-min-height), 0x3D
(clamp-max-width), 0x3F (clamp-min-width) — read off this, the
element actually being resized. A decomp-wide grep for writers of those
four attributes turns up NOTHING: no runtime code ever sets them at
runtime. The clamp source is exclusively authored DAT properties on
whichever element ResizeTo is called against, full stop. There is no
hardcoded SetMinSize call anywhere in the class hierarchy; the earlier
"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a
guess and is WRONG.
The element ResizeTo is called against is not 0x2100002E's own root.
Per docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md
(lines ~83-107) and the slot table in
docs/research/2026-08-11-fa-panel-structure.md (row for 0x1000018E),
retail's Character/Skills tab content is one child slot inside the
SHARED gmPanelUI host, LayoutDesc 0x2100006E:
gmPanelUI host 0x100005FE 310 x 372
content parent 0x10000180 300 x 362 (anchored all edges)
Character/Skills slot 0x1000018E panel id 11, 300 x 362
top-center Dragbar 0x1000065C Type 2
bottom-center Resizebar 0x10000660 Type 9
gmPanelUI::ResizeTo @0x004BC6E0 is a bare tailcall into
UIElement::ResizeTo — the HOST is what gets resized (via its
Resizebar), not 0x2100002E's content root; the content root's own
absent MinHeight/MaxHeight (confirmed above) is therefore consistent
with retail's actual mechanism, not evidence of a missing DAT property.
NOT PROBED by CT1. The host elements above (0x100005FE,
0x10000180, 0x1000018E, 0x1000065C, 0x10000660) were read from
the cited pseudocode doc, not re-probed live against the installed DAT
this session. CT6's first step is to probe those host slots directly
(min/max + resize authoring) before porting anything, then read
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 verbatim for the
drag-time clamp application.
Size tension for CT3/CT6 to resolve. 0x2100002E's own root is
authored 300×600 (the Titles page alone is 300×575, plus the 25px
tab bar = 600 — §3), but retail mounts that content into the host's
300×362 slot (0x1000018E). CT3's title ListBox height (0x10000532
is 270×455 per §3) and CT6's resize contract both assume a taller
available area than the host slot's authored 362px. This doc does not
resolve which number governs at runtime (scroll-clipped content inside a
fixed slot vs. the slot itself growing to accommodate) — CT6 must
resolve it from the decomp before implementing the resize contract, not
infer it from either number in isolation.
5. The title-string table (DAT ground truth for CharacterTitleTable::GetCharacterTitleFromID)
Decomp chain (named-retail, docs/research/named-retail/acclient_2013_pseudo_c.txt)
CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 does not
read a StringTable directly. It goes through TWO independent
enum-mapper indirections:
EnumMapper::GetString(0x10000006, titleId, &rawName)(the static 3-arg overload @0x0041ac40) — internally callsDBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)to resolve the title EnumMapper object's DID, then dispatches onMasterDBMap::DivineType(0x24 = EnumMapper) to callEnumMapper::GetString(titleId, &rawName)on it, giving a raw canonical string name (NOT yet localized/hashed).compute_str_hash(rawName)(ELF-style hash, already ported byte-exact asDatStringResolver.ComputeHash— see its own citation ofcompute_str_hash @ 0x00413110).StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)(@0x0042c760) — internally callsDBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)to resolve the title StringTable's DID.StringInfo::GetString(&info)(@0x0042e760→InqString→StringTableMetaLanguage::UnescapeString) resolves the final localized text — the sameStringTable.Strings[hash]lookupDatStringResolver.Resolve(tableId, stringId)already performs.
DBObj::GetDIDByEnum(enumValue, category) (@0x004153a0 →
DBCache::GetDIDFromEnum @0x00413940) is itself a two-level indirection
through a master map object (this->m_MasterMapID): look up category
in the master map to get an intermediate category-map DID, then look up
enumValue in THAT map to get the final DID. This is the exact same
mechanism already ported (empirically, not by name) as RetailKeyNames'
0x2300000A/0x2300000B/0x23000007 constants
(src/AcDream.App/UI/Layout/RetailKeyNames.cs, citing "DBCache::GetDIDFromEnumStatic
category 4") — this slice confirms those three constants ARE exactly the
category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so
the existing RetailKeyNames port is independently cross-validated by
this investigation.
Live-DAT resolution (verified end-to-end this session)
The master-map / category-map tables and the RetailKeyNames
cross-validation below are UNPINNED probe output — they come from the
same deleted Assert.Fail probe tests as the rest of this doc and are
not backed by a committed InstalledDatFact assertion (unlike the
TitleStringTable_ResolvesWarMageEndToEnd pin, which DOES commit the
final two DIDs and the end-to-end string resolution). Treat the
category-4 dump and the RetailKeyNames match column as this session's
observation, re-derivable from the DAT but not regression-guarded.
DatReaderWriter.DBObjs.EnumIDMap (ACE's historical name: DidMapper,
file-type byte 0x25) is the object type both master and category maps
use; DatReaderWriter.DBObjs.EnumMapper (file-type byte 0x22 on the
installed dat) is the flat id→string table type.
Master map, DID 0x25000000 (ClientEnumToID/ClientEnumToName,
22 entries) — the categories relevant here:
| category enum | name | category-map DID |
|---|---|---|
| 1 | EMAPPER | 0x25000001 |
| 4 | STRINGTABLE | 0x25000004 |
Category 1 (EMAPPER) map, DID 0x25000001 — relevant entry:
| enum | name | DID |
|---|---|---|
| 0x10000006 | CharacterTitle | 0x22000041 |
Category 4 (STRINGTABLE) map, DID 0x25000004 — full dump (12 entries),
confirming the RetailKeyNames constants along the way:
| enum | name | DID | cross-check |
|---|---|---|---|
| 0x00000003 | KeyMap | 0x23000007 | = RetailKeyNames.DelimiterTableId ✓ |
| 0x00000004 | KeyNameOverride | 0x2300000A | = RetailKeyNames.KeyNameTableId ✓ |
| 0x00000005 | MetakeyNameOverride | 0x2300000B | = RetailKeyNames.MetaKeyNameTableId ✓ |
| 0x10000007 | CharacterTitle | 0x2300000E | (this slice's target) |
| 0x10000001 | UI | 0x23000001 | |
| 0x10000002 | UI_Pregame | 0x23000002 | |
| 0x10000003 | Preference | 0x23000003 | |
| 0x10000004 | UI_Options | 0x23000004 | |
| 0x10000006 | Options | 0x2300000D | |
| 0x00000002 | Calendar | 0x23000006 | |
| 0x00000006 | CommandSetup | 0x2300000C | |
| 0x00000007 | ActionDescription | 0x23000005 | |
| 0x00000008 | ServerEngine | 0x23000010 |
So: the title EnumMapper is DID 0x22000041; the title StringTable is
DID 0x2300000E.
End-to-end verification (ACE's CharacterTitle enum, WarMage = 13)
EnumMapper(0x22000041).IdToStringMap has 873 entries, including:
titleId 0 -> ID_CharacterTitle_Invalid
titleId 1 -> ID_CharacterTitle_Adventurer
titleId 5 -> ID_CharacterTitle_Life_Mage
titleId 13 -> ID_CharacterTitle_War_Mage
titleId 14 -> ID_CharacterTitle_Wayfarer
ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05
DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage"
Byte-exact confirmation the chain is understood correctly end to end — titleId 13 round-trips through the EnumMapper canonical-name lookup, the retail hash function, and the StringTable localization lookup to produce exactly "War Mage".
What CT2 needs to port
- Two
GetDIDByEnum-shaped lookups (master map0x25000000→ category map → target DID) — CT2 can either hardcode the two resolved DIDs (0x22000041for the EnumMapper,0x2300000Efor the StringTable, the wayRetailKeyNameshardcodes its three) or port the two-level indirection generically. A THIRD consumer has now appeared (found this fix round):gmAttributeUI::PostInit @0x0049DB70resolves per-attribute icon DIDs viaDBObj::GetDIDByEnum(statEnum, category 0x10000002)— see the "Row instantiation + icon-DID anchors" note in §2. WithRetailKeyNames(category 4) and the title chain (categories 1 and 4) already hardcoding resolved DIDs, this third independent category (0x10000002) is the point where CT2/CT5 should factor a sharedGetDIDByEnum(enumValue, category)helper instead of adding a fourth ad-hoc hardcoded pair. EnumMapper.IdToStringMap[titleId]→ raw canonical name (already readable viadats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>).DatStringResolver.ComputeHash(rawName)(already exists, no new code).DatStringResolver.Resolve(0x2300000Eu, hash)(already exists, no new code) for the final localized display string.
No new DAT-reading primitives are required — EnumMapper/EnumIDMap are
already exposed by DatReaderWriter.DBObjs, and DatStringResolver
already does steps 3–4 for other consumers.
Corrections to the plan (summary)
- Window constraints are NOT already 90% wired — and the clamp
mechanism is now VERIFIED, not guessed. The plan's "Already in-tree"
bullet claims
DatConstraintSourceregistration for the character window; the actualMountCharacter()call sets no such field, and the DAT layout itself authors no MinHeight/MaxHeight on its root to source one from even if it were wired.UIElement::ResizeTo @0x00463C30clamps only via element attributes0x3C–0x3F, which nothing writes at runtime — the clamp source is always authored DAT properties on the resized element, full stop, and the resized element is the SHAREDgmPanelUIhost (0x2100006E, slot0x1000018E), not0x2100002E's own root. See §4's "Verified resize mechanism" for the full chain and the unresolved 300×600-vs-300×362 size tension CT6 must still resolve. - The row-template elements are not walkable via the normal
ImportInfos(dats, layoutId)overload.#375's prototype-skip logic deliberately excludes same-layout template-list targets from the built tree. CT5 must useImportInfos(dats, layoutId, elementId)(the targeted single-root overload) to read0x10000248(LayoutDesc 0x21000045) and0x10000536(LayoutDesc 0x2100005E) — documented here so CT5 doesn't waste a cycle rediscovering the same "NOT FOUND" dead end this slice hit first. RowHighlightSpriteIS wrong — SEALED, not merely flagged. The DAT's row-template Highlight state is0x06000F93, reached viagmAttributeUI::UpdateSelection'sSetState(6)→InfoRegion::SetStateon the row itself; the current constant inCharacterStatController.csis0x06001397, which belongs to a DIFFERENT mechanism (the spellbook row's selected-overlay child,UIElement_UIItem::SetSelectedState). CT5 must correct the STAT rows'RowHighlightSpriteto0x06000F93and must NOT touchSpellbookRowStyle.cs— see §2's "SEALED VERDICT" note for the full anchor chain.- Everything else in the plan's "Retail recon" section (the Titles page element roster, the header element ids, the PostInit binding order) checks out exactly against the live DAT — no other corrections.
- Several findings above are probe-session observations, not
committed pins — flagged this fix round so CT2–CT6 don't cite them
as regression-guarded facts: the header block's "two copies
geometrically identical" claim (only fonts/colors are pinned, not
full geometry — §1), the
0x06004CC2"generic panel chrome" characterization (§2), and the master-map/category-map dump plus theRetailKeyNamescross-validation table (§5). The title chain's final two DIDs and end-to-end string resolution ARE pinned (TitleStringTable_ResolvesWarMageEndToEnd).
§CT6 — the shared-host resize clamp (2026-08-25 live probe)
Verdict: the resize clamp source is the shared gmPanelUI host
(0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's own root
and not the Character/Skills slot 0x1000018E either. Probed with a
temporary test dumping layout 0x2100006E via both the whole-layout
walk and the targeted single-root overload (deleted before commit; the
pattern is preserved by the committed pin
CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract).
Host element 0x100005FE
Type=0x1000002F (gmPanelUI) X=0 Y=0 W=310 H=372
MinWidth=310 MaxWidth=310 (fixed — no horizontal Resizebar authored)
MinHeight=372 MaxHeight=1000
17 children, including (all DIRECT children of the host, siblings of
the content parent, not nested under it):
0x10000180 content parent Type=3 X=5 Y=5 W=300 H=362
0x1000065C top-center Dragbar Type=2 X=5 Y=0 W=300 H=5
0x10000660 bottom-center Resizebar Type=9 X=5 Y=367 W=300 H=5
(+ 14 border/corner chrome pieces, 0x10000653-0x10000662)
MinHeight == 372 == the host's own authored default height: retail's
Character/Skills window can only be resized TALLER (up to 1000px), never
shorter than its own authored default — this IS the "resizable in Y down
to an authored minimum" the owner reported; 372 is that floor, not an
arbitrary smaller number.
The Character/Skills slot 0x1000018E (the SAME structural role CT1
probed for 0x2100002E's standalone root 0x10000227, but reached
through the shared host this time) itself authors no MinWidth/
MinHeight/MaxWidth/MaxHeight — confirming the clamp is exclusively the
HOST's, not layered again on the slot:
0x1000018E Type=8 (TabControl) X=0 Y=0 W=300 H=362
Min=(null,null) Max=(null,null)
children: 0x10000228/29 (tab buttons), 0x1000022A (close button),
0x10000538 (Titles tab), 0x10000539 (Titles page),
0x1000022B/2C (Attributes/Skills pages) — same ids `0x2100002E`
imports, reached here via `0x1000018E`'s BaseElement inheritance
from `0x10000227` (same mechanism CT1 §3 documents for the Titles
row template's `0x1000052D`/`0x10000536` pair).
Decomp chain confirming which element the drag clamp applies to
UIElement_Resizebar::StartMouseResizing @0x0046B7E0:eax_1 = this->vtable->GetParent()thenUIElement::StartResizing(eax_1, border, x, y)— the drag state (m_DragStartWidth/Height,m_currentBorder) is stashed on the RESIZEBAR'S PARENT, confirmed live to be the host0x100005FE(§ above), not the content parent.UIElement::StartResizing @0x0045fca0: pure state setup (m_DragStartX/Y/Width/Height,m_currentBorder) onthis— no clamp read here.UIElement::MouseResizeElement @0x00461130: the actual per-mouse-move resize application. ReadsGetAttribute_Int(this, 0x3F)(min width),0x3D(max width),0x3E(min height),0x3C(max height) — all offthis, the SAME elementStartResizingwas called against. Since that element is the host (per the GetParent() call above), the host's own authored 0x3C..0x3F values are what govern every live drag.
This matches — and completes — the "Verified resize mechanism" section
already in this doc (UIElement::ResizeTo @0x00463C30's equivalent
clamp for the programmatic path): both the interactive drag
(MouseResizeElement) and the programmatic call (ResizeTo) read
0x3C..0x3F off the SAME element, and that element is always whichever
one is actually being resized — the host, never the character content
root or the slot.
Production wiring (RetailUiRuntime.MountCharacter)
MountCharacter now imports ElementInfo? hostConstraint = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu) (the same
targeted single-root overload CT1 established for row templates) and
passes it as RetailWindowFrame.Options.DatConstraintSource.
CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25): this section
originally claimed the NineSlice chrome inset (2 * RetailChromeSprites.Border = 10px) "is added automatically by
RetailWindowFrame.ResolveConstraint, giving the mounted outer frame
MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG.
Host 0x100005FE is not a bare content element our own NineSlice
wrapper adds chrome to — per the geometry dumped above, it IS retail's
own complete outer window frame: 5px bevel + the 300×362 content parent
(0x10000180) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values
are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px
inset on top of an already chrome-inclusive source double-counted the
bevel: the mounted window's clamp said MinWidth=320 while its actual
mounted outer width was only 310 — the window opened already violating
its own minimum, silently "fixed" at runtime only because
RetailWindowManager.ResizeTo's main-panel geometry sync forcibly
widened it to 320 despite ResizeX=false, which would have produced a
visible 15px right-bevel seam against the other eight main panels
sharing that sync.
Fix: a new opt-out,
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame (default
false, preserving every other window's existing content-plus-chrome
behavior), tells ResolveConstraint to apply a chrome inset of 0 when
the DAT source is itself already the outer frame. MountCharacter sets
it true for hostConstraint. The mounted outer clamps are now EXACTLY
the host's four raw values, no composed arithmetic: MinWidth=
MaxWidth=310, MinHeight=372, MaxHeight=1000. A new mount-time
invariant in RetailWindowFrame.Mount (throws if the just-mounted outer
extent falls outside its own just-computed clamp) guards against this
class of bug recurring for any window this path mounts.
ResizeX=false/ResizableEdges=Bottom (already correct in the
pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring
exactly — no change needed there.
Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25)
The "Size tension" flagged earlier in this doc (§4, "Correction to the
plan") — 0x2100002E's own root authored 300×600 vs. the host slot's
300×362 — is now resolved: 372px outer (362px content) is the number
that governs the MOUNTED DEFAULT, and 600 is the content's own
authored design canvas that retail scroll-clips into the much smaller
host slot, never the size the window actually opens at. Concretely:
0x2100002E's 300×600 root exists because its Titles page alone is
authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is
real, authored geometry, and the Titles page correctly stretches to fill
whatever height the mounted window offers via its own LayoutPolicy
(confirmed below). But retail never displays that full 600px canvas at
once outside of the Titles tab's own internal scroll: the shared
gmPanelUI host's content parent (0x10000180) is fixed at 300×362,
and 372 (362 + the 10px chrome bevel) is exactly the host's own
authored MinHeight — i.e., retail's Character/Skills window OPENS at
its own resize floor and can only be dragged taller, never shorter.
Pre-fix, MountCharacter left Options.ContentHeight unset, so it fell
back to content.Width/content.Height — the raw 600px canvas — giving
a stale 610px (600 + 10 chrome) mounted default that was never retail's
actual opening size and was 238px taller than the true floor. Fixed by
setting Options.ContentHeight = 362f explicitly (the same host
content-parent height this section already probed and cited, not a new
number). At the corrected 372px default, the authored page composition
(header 112px + list 160px + divider + footer) is the true 362px design;
the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px
list immediately, so the stat list's scrollbar is active from the moment
the window opens — this is retail-correct (see §S2 below for what
"active" means given the corrected HideWhenDisabled finding), not a
regression introduced by the fix.
Titles-page list reflow
The Titles page's own container (0x10000539) and its ListBox
(0x10000532) both already carry a REAL authored LayoutPolicy in the
committed/installed layout (live-verified: LayoutPolicy is not null
for both) that correctly stretches with the mounted content's height —
no code was needed to make the PAGE itself reflow.
CORRECTED (CT6 fix round, S3, 2026-08-25): this section originally
went on to claim "the only missing piece was the ListBox's own
compatibility fallback (Anchors = Left|Top|Bottom, engaged only when
LayoutPolicy is null — a no-op on the real DAT, but needed for
synthetic/test layouts...), added in CharacterTitlesController.Bind."
That framing was wrong: both 0x10000532 and 0x10000539 author
HasOriginalParentSize=true (the field DatWidgetFactory gates
LayoutPolicy assignment on), confirmed both on the real installed DAT
and the committed fixture — LayoutImporter/DatWidgetFactory ALWAYS
assigns a real LayoutPolicy to these elements, so the if (listBox.LayoutPolicy is null) branch never ran anywhere, not even in
the "synthetic/test layouts" case it was written to cover. It was dead
code, not a harmless no-op fallback. Deleted from
CharacterTitlesController.Bind; a new
CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize
pin asserts HasOriginalParentSize on both 0x10000532 and 0x10000539
to guard the deletion against future DAT drift.
Correction to the scrollbar-visibility finding (S2, 2026-08-25)
The CT6 landing notes (in the campaign plan ledger) claimed
UiScrollbar's own IsPresentationVisible/IsModelDisabled "already
draw the correct full-track 'disabled' thumb when content fits
(HideWhenDisabled defaults false)." That had the authored default
BACKWARDS. Both 0x1000023E (the shared Attributes/Skills list
scrollbar) and 0x10000533 (the Titles list's own scrollbar) author
property 0x79 (HideWhenDisabled) TRUE in the committed fixture —
verified directly against tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json,
property "121" (=0x79) carries "BoolValue": true on both elements. A
fitting list HIDES the bar entirely (IsPresentationVisible false); it
does not leave a full-track "disabled" thumb on screen. The CODE in
CharacterStatController.RebuildActiveList was already correct —
.Visible = true only keeps the bar bound in the tree, and
IsPresentationVisible is the actual show/hide computation — only the
description was wrong. Given the S4 correction above (mount default is
now the compact 372px floor), the practical consequence is: the stat
list's scrollbar is VISIBLE and interactive from the moment the window
opens (rows overflow at the default), and DISAPPEARS once the window is
grown enough that all rows fit — the opposite of what the uncorrected
description implied.
A latent anchor-baseline bug this slice surfaced and fixed
CharacterStatController.RebuildActiveList's (and, before CT6, only the
Skills tab's) UiScrollablePanel viewport is constructed with Height = statList.Height before the window's first anchor pass ever grows
statList from its raw DAT-authored height (160px) up to its actual
mounted height. Left alone, the viewport's own Left|Top|Bottom anchor
captures its baseline margins lazily on ITS OWN first ApplyAnchor call
— which happens AFTER statList has already grown — measuring a bogus
non-zero bottom margin that then permanently caps the viewport short on
every later resize (the exact #372/#412-class bug UiTemplateListBox .Viewport's own lazy getter already works around). Fixed by calling
viewport.CaptureCurrentAnchorBaseline() immediately after
statList.AddChild(viewport), while viewport.Height still exactly
equals statList's own current (pre-reflow, zero-margin) height. This
was previously unexercised/untested for Skills (no test asserted its
viewport's exact height against a real window resize) and is now proven
by CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar.
No register row
Every number in this section is either a live-probed authored DAT value or a structural wiring/anchor-capture-correctness fix — nothing here is inferred or approximated.
CORRECTED (CT6 fix round, N5, 2026-08-25): at initial landing, this
sentence was not actually true — the mounted 320/382/1010 clamp WAS an
inference (BLOCKER B1: the host's already chrome-inclusive values with a
second, redundant chrome inset composed on top). After the B1 fix
removes that composition, the mounted clamp is now literally the host's
own four probed values (310/310/372/1000) with zero arithmetic applied,
so the sentence holds for real. The S4 content-height default (362) is
likewise not a new inferred number — it is the same host content-parent
width/height (0x10000180, 300×362) this section already probed and
cited above, applied to Options.ContentHeight instead of being left
unset.