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>
797 lines
44 KiB
Markdown
797 lines
44 KiB
Markdown
# 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 — `0x10000233` FontColor is
|
||
exactly white, not the "color off" state the owner reported. The bug is
|
||
purely in `CharacterStatController.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 to `Vector4.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.Gold` is 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 how `LevelId`'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) and `nameW = 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 `0x1000023D` is `X=0 Y=112 W=300 H=160`; its
|
||
scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is
|
||
`W=282`; the value column inside the row is `X=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 existing `SkillContentWidth = 282f`
|
||
constant already in the code; value column X=175 W=100; scrollbar
|
||
X=281 W=16), never a derived `listWidth - 18` or similar formula.
|
||
- Row background: `Normal` state file `0x06004CC2`. **The description
|
||
"generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED**
|
||
— no cross-reference sweep for other consumers of `0x06004CC2` was run
|
||
this slice; only the file id itself, as authored on this specific
|
||
template, is pinned. `Highlight` state file **`0x06000F93`**.
|
||
|
||
### 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 @0x0049DEE0` calls
|
||
`SetState(selected ? 6 : 1)` on the row.
|
||
- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row
|
||
element instantiated from template `0x10000248` — the exact template
|
||
this slice dumped, whose Highlight-state file is `0x06000F93`.
|
||
- State `6` IS `UIStateId.Highlight` — so the selected row draws
|
||
`0x10000248`'s own `Highlight` media, 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`:
|
||
|
||
```text
|
||
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:
|
||
|
||
1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static
|
||
3-arg overload @`0x0041ac40`) — internally calls
|
||
`DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the
|
||
**title EnumMapper object's DID**, then dispatches on
|
||
`MasterDBMap::DivineType` (0x24 = EnumMapper) to call
|
||
`EnumMapper::GetString(titleId, &rawName)` on it, giving a raw
|
||
canonical string name (NOT yet localized/hashed).
|
||
2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact
|
||
as `DatStringResolver.ComputeHash` — see its own citation of
|
||
`compute_str_hash @ 0x00413110`).
|
||
3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)`
|
||
(`@0x0042c760`) — internally calls
|
||
`DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the
|
||
**title StringTable's DID**.
|
||
4. `StringInfo::GetString(&info)` (`@0x0042e760` → `InqString` →
|
||
`StringTableMetaLanguage::UnescapeString`) resolves the final localized
|
||
text — the same `StringTable.Strings[hash]` lookup
|
||
`DatStringResolver.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
|
||
|
||
1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category
|
||
map → target DID) — CT2 can either hardcode the two resolved DIDs
|
||
(`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the
|
||
way `RetailKeyNames` hardcodes its three) or port the two-level
|
||
indirection generically. **A THIRD consumer has now appeared** (found
|
||
this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves
|
||
per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category
|
||
0x10000002)` — see the "Row instantiation + icon-DID anchors" note in
|
||
§2. With `RetailKeyNames` (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
|
||
shared `GetDIDByEnum(enumValue, category)` helper instead of adding a
|
||
fourth ad-hoc hardcoded pair.
|
||
2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already
|
||
readable via `dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>`).
|
||
3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code).
|
||
4. `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)
|
||
|
||
1. **Window constraints are NOT already 90% wired — and the clamp
|
||
mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree"
|
||
bullet claims `DatConstraintSource` registration for the character
|
||
window; the actual `MountCharacter()` 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 @0x00463C30`
|
||
clamps only via element attributes `0x3C`–`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 SHARED
|
||
`gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'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.
|
||
2. **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 use `ImportInfos(dats, layoutId, elementId)` (the
|
||
targeted single-root overload) to read `0x10000248`
|
||
(`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) —
|
||
documented here so CT5 doesn't waste a cycle rediscovering the same
|
||
"NOT FOUND" dead end this slice hit first.
|
||
3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The
|
||
DAT's row-template Highlight state is `0x06000F93`, reached via
|
||
`gmAttributeUI::UpdateSelection`'s `SetState(6)` →
|
||
`InfoRegion::SetState` on the row itself; the current constant in
|
||
`CharacterStatController.cs` is `0x06001397`, which belongs to a
|
||
DIFFERENT mechanism (the spellbook row's selected-overlay child,
|
||
`UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows'
|
||
`RowHighlightSprite` to `0x06000F93` and must NOT touch
|
||
`SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full
|
||
anchor chain.
|
||
4. 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.
|
||
5. **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 the
|
||
`RetailKeyNames` cross-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()` then `UIElement::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 host `0x100005FE` (§ above), not the content parent.
|
||
- `UIElement::StartResizing @0x0045fca0`: pure state setup
|
||
(`m_DragStartX/Y/Width/Height`, `m_currentBorder`) on `this` — no
|
||
clamp read here.
|
||
- `UIElement::MouseResizeElement @0x00461130`: the actual per-mouse-move
|
||
resize application. Reads `GetAttribute_Int(this, 0x3F)` (min width),
|
||
`0x3D` (max width), `0x3E` (min height), `0x3C` (max height) — all off
|
||
`this`, the SAME element `StartResizing` was 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.
|