feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract

CT6 (Campaign CT slice 6): 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 — live
probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no
horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that
the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are
direct children of the host, not the content parent. Decomp chain:
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
UIElement::StartResizing(this->GetParent(), ...), stashing drag state on
that parent; UIElement::MouseResizeElement @0x00461130 then reads
GetAttribute_Int(this, 0x3C..0x3F) off that same element every
mouse-move.

RetailUiRuntime.MountCharacter now imports the host element and passes
it as RetailWindowFrame.Options.DatConstraintSource, matching the
existing MountSideVitals pattern.

CharacterStatController.RebuildActiveList now wraps BOTH the Attributes
and Skills tabs' rows in the same UiScrollablePanel viewport (previously
only Skills got one; Attributes rows had no clipping/scrolling and the
shared scrollbar was force-hidden — owner report item 2). The shared
scrollbar is now always bound + visible; UiScrollbar's own
IsPresentationVisible/IsModelDisabled already draw the correct
full-track "disabled" thumb when content fits. This surfaced and fixed
a real #372/#412-class anchor-baseline bug: the viewport's
Left|Top|Bottom anchor was capturing its baseline margins lazily on its
own first ApplyAnchor call, which happens AFTER the ListBox has already
grown from its raw DAT height to its mounted height, permanently
capping the viewport short on every later resize. Fixed with an eager
CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox
.Viewport's own lazy getter.

CharacterTitlesController.Bind gained the same defensive
Anchors = Left|Top|Bottom fallback for the Titles ListBox that
CharacterStatController already had (a no-op on the real DAT — both the
Titles page and its ListBox already carry a real authored LayoutPolicy
that stretches correctly).

Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight,
set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read
identically by interactive drag, RetailWindowManager.ResizeTo, and
RetailWindowLayoutPersistence's restore clamp. No gaps found; no
register row (every number is a live-probed authored DAT value or a
structural correctness fix, nothing inferred).

Tests: CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar,
CharacterTitlesControllerTests
.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips,
RetailWindowFrameTests
.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds
(shared-mechanism regression pin), CharacterPanelLiveDatTests
.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat
pin). Existing attribute-row tests updated from list.Children to
Descendants(list) for the new nested-viewport shape (the pattern skill
rows already needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 02:21:56 +02:00
parent 4cbbdaf4bf
commit ec50455a63
10 changed files with 741 additions and 74 deletions

View file

@ -399,8 +399,75 @@ enforcement verified as the STANDARD path for every registered window
(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`,
no per-window special cases).
**CT6 landing notes (2026-08-25, implementation).** Live probe (dumped
+ deleted, pattern preserved by the new
`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`
pin) confirmed the research lead's hypothesis exactly: the shared
`gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors
MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372,
MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar
(`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent
— matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s
`GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s
`GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The
Character/Skills slot `0x1000018E` itself authors no constraints of its
own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports
that host element and passes it as `DatConstraintSource`; the mounted
outer frame clamps at MinWidth=MaxWidth≈320, MinHeight≈382,
MaxHeight≈1010 after the NineSlice chrome inset. Full derivation +
decomp anchors: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`
§CT6.
`CharacterStatController.RebuildActiveList` now wraps BOTH the
Attributes and Skills tabs' rows in the same `UiScrollablePanel`
viewport (previously only Skills got one; Attributes rows were added
directly to the ListBox with no clipping/scrolling and the shared
scrollbar was force-hidden — the owner's item 2). The shared scrollbar
is now always bound + visible; `UiScrollbar`'s own
`IsPresentationVisible`/`IsModelDisabled` already draw the correct
full-track "disabled" thumb when content fits (`HideWhenDisabled`
defaults false), so no per-tab visibility toggle is needed any more.
This surfaced and fixed a real, previously-unexercised `#372`/
`#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom`
anchor was capturing its baseline margins lazily on its OWN first
`ApplyAnchor` call, which happens AFTER the ListBox has already grown
from its raw DAT height (160px) to its mounted height — measuring a
bogus non-zero margin that permanently capped the viewport short on
every later resize. Fixed with an eager
`viewport.CaptureCurrentAnchorBaseline()` call right after
`AddChild`, mirroring the identical fix already shipped in
`UiTemplateListBox.Viewport`'s own lazy getter. `CharacterTitlesController.Bind`
gained the same defensive `Anchors = Left|Top|Bottom` fallback for the
Titles ListBox (`0x10000532`) that `CharacterStatController` already
had for its own list — live-verified as a no-op on the real DAT (both
the Titles page container and its ListBox already carry a real authored
`LayoutPolicy` that stretches correctly on its own), but matching the
established pattern for synthetic/test layouts.
STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement
.MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at
`RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit
overrides, are the ONLY clamp fields — read identically by the
interactive drag path (`UiRoot`'s resize handling), the programmatic
path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s
main-panel geometry sync and this slice's tests exercise), and the
persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`).
`RetailWindowFrame.Mount` remains the single production mount path (no
window bypasses it). New regression pin:
`RetailWindowFrameTests.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds`
proves the same mechanism still clamps chat-shaped constraints after
Character was wired onto it. Tests: `CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`
(window-level: clamp at authored min/max, list shrink, scrollbar
overflow flip, footer stays bottom-docked, grow-back restore) and
`CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips`
(same contract for the Titles list) plus the pre-existing 126+22-test
suites, all updated where the new nested-viewport DOM shape required it
(`Descendants(list)` instead of `list.Children` — the shape Skills rows
already needed). No register row: every number is either a live-probed
authored DAT value or a structural anchor-capture-correctness fix,
nothing inferred.
**CT7 — Connected gate.** Test script
(`docs/research/2026-08-24-campaign-ct-test-script.md`), owner drive:
(`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive:
titles round trip against ACE (earn/set/display), header lines vs
retail side-by-side, resize behavior, row alignment screenshots.

View file

@ -563,3 +563,127 @@ already does steps 34 for other consumers.
`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`. Chrome
inset (`2 * RetailChromeSprites.Border` = 10px, NineSlice) is added
automatically by `RetailWindowFrame.ResolveConstraint`, giving the
mounted outer frame MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010.
`ResizeX=false`/`ResizableEdges=Bottom` (already correct in the
pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring
exactly — no change needed there.
### 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. 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 and matches the
identical pattern already used for `CharacterStatController`'s
`statList`), added in `CharacterTitlesController.Bind`.
### 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.

View file

@ -73,13 +73,53 @@ Useful ACE console helpers: title grants come from quests/admin — check
## 4. Resize + scrollbar (CT6)
*(Section finalized after CT6 lands — placeholder items below.)*
Ground truth (2026-08-25 live probe against layout `0x2100006E`, host
`0x100005FE``docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`
§CT6): the resize clamp is authored on the SHARED `gmPanelUI` host, not
the character content itself. Host authors **MinWidth=MaxWidth=310**
(fixed width — no horizontal Resizebar) and **MinHeight=372,
MaxHeight=1000**. With the NineSlice chrome's 10px inset, the MOUNTED
window's outer bounds are MinHeight≈382px, MaxHeight≈1010px, width
fixed ≈320px.
1. Resize the character window vertically: it clamps at the authored
minimum; the stat list shows its scrollbar when the shortened
viewport overflows, with retail hover/press states on the bar.
2. Other windows (chat, social) still clamp at their own authored
minimums — regression check.
1. **Grab the bottom edge and drag up (shrink).** PASS: the window
stops shrinking at its authored floor (≈382px outer / the point where
further dragging has no visible effect) — it does NOT collapse
arbitrarily small, and it does NOT refuse to shrink at all (the
pre-CT6 bug). Retail comparison: drag retail's own Character/Skills
window to its floor side-by-side; both should bottom out at
proportionally the same point relative to their starting size.
2. **Keep dragging down (grow).** PASS: the window keeps growing until
its authored ceiling (≈1010px outer) — same side-by-side comparison
against retail's own ceiling.
3. **Left/right edges do not resize.** Only the bottom edge (and top
Dragbar for moving, not resizing) responds — matches retail's
fixed-width authoring (no horizontal Resizebar).
4. **Scrollbar hand-off, Attributes tab.** At the default window size
the 9 attribute/vital rows fit without a visible scroll gap (bar
shows retail's full-track "disabled" thumb — present, not hidden,
per the 2026-08-24 scrollbar work's contract). Shrink the window
until the rows overflow: the bar's thumb shrinks proportionally and
becomes interactive (drag, click-to-page, wheel-scroll all move the
list). PASS: this is the owner's item 2 fix — previously the
scrollbar never appeared on Attributes at all.
5. **Scrollbar hand-off, Skills tab.** Same shrink/overflow check —
should already have worked pre-CT6; confirm no regression.
6. **Scrollbar hand-off, Titles tab.** With several earned titles,
shrink the window until the Titles list (authored 455px, inside the
575px page) overflows its available space — the list's own scrollbar
(`0x10000533`) takes over the same way.
7. **Footer stays bottom-docked.** While shrinking/growing on the
Attributes/Skills tabs, the footer (raise buttons / selected-stat
info) stays pinned to the bottom edge — it does not float mid-window
or get clipped early.
8. **Grow back restores.** Drag back to the original size: the lists
return to showing all rows without scrolling (scrollbar reverts to
the full-track disabled state) and the window returns to its
original proportions.
9. Other windows (chat, social) still clamp at their own authored
minimums — regression check (chat: min 300×100, max 2000×2000 per
`CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`).
## 5. Regression sweep (5 minutes)