diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md
index 2286ab22..1e6878e6 100644
--- a/docs/plans/2026-08-24-character-panel-parity-campaign.md
+++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md
@@ -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.
diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
index ddb933ab..656e03af 100644
--- a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
+++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
@@ -563,3 +563,127 @@ already does steps 3–4 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.
diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md
index 1e38407e..39e9c56a 100644
--- a/docs/research/2026-08-25-campaign-ct-test-script.md
+++ b/docs/research/2026-08-25-campaign-ct-test-script.md
@@ -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)
diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index 4fc8a449..bf675617 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -754,40 +754,73 @@ public static class CharacterStatController
currentAttributeRows.Clear();
currentSkillRows.Clear();
- if (activeTab[0] == CharacterStatTab.Attributes)
+ // CT6 (2026-08-25): both tabs now stack their rows inside a
+ // UiScrollablePanel viewport child of statList — the SAME
+ // shrink-and-scroll contract the Skills tab already had.
+ // UiScrollablePanel.LayoutScrollableChildren (called every
+ // OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN
+ // current Height every frame; the viewport's Anchors
+ // (Left|Top|Bottom, a child of statList) track statList's live
+ // Height as the window resizes, so Scroll.HasOverflow flips live
+ // with no extra wiring. Previously only Skills got this
+ // treatment — Attributes added rows directly to statList with no
+ // clipping/scrolling and the shared scrollbar was force-hidden,
+ // which is the "we never show the scrollbar on Attributes" gap
+ // (owner report item 2) and the reason a shrunk window could not
+ // show the overflowing attribute/vital rows at all.
+ bool isSkills = activeTab[0] == CharacterStatTab.Skills;
+ float contentW = isSkills
+ ? SkillViewportWidth(statList, skillScrollbar)
+ : RowContentWidth(statList);
+ var viewport = new UiScrollablePanel
{
- if (skillScrollbar is not null)
- {
- skillScrollbar.Model = null;
- skillScrollbar.Visible = false;
- }
- currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel,
- allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
- activeListEntries.AddRange(currentAttributeRows);
+ Left = 0f,
+ Top = 0f,
+ Width = contentW,
+ Height = statList.Height,
+ LineHeight = (int)RowHeight,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
+ };
+ statList.AddChild(viewport);
+ // #372-class fix (same mechanism as UiTemplateListBox.Viewport's
+ // own lazy getter): RebuildActiveList runs during Bind, BEFORE
+ // the window's first anchor pass ever reflows statList up from
+ // its raw DAT-authored height (160px) to its actual mounted
+ // height. Left to its lazy default, viewport's Left|Top|Bottom
+ // anchor would capture its baseline margins on ITS OWN first
+ // ApplyAnchor call — which happens AFTER statList has already
+ // grown — measuring a bogus non-zero bottom margin (e.g.
+ // "398 grown list − 160 stale viewport = 238px margin") that then
+ // permanently caps the viewport 238px short on every future
+ // resize. Capturing NOW, while viewport.Height still exactly
+ // equals statList's own CURRENT (pre-reflow, zero-margin)
+ // height, gives a true (0,0,0,0) baseline that then correctly
+ // full-stretches on every later resize.
+ viewport.CaptureCurrentAnchorBaseline();
+ activeListEntries.Add(viewport);
+
+ if (isSkills)
+ {
+ BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
+ allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
}
else
{
- float contentW = SkillViewportWidth(statList, skillScrollbar);
- var viewport = new UiScrollablePanel
- {
- Left = 0f,
- Top = 0f,
- Width = contentW,
- Height = statList.Height,
- LineHeight = (int)RowHeight,
- Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
- };
- statList.AddChild(viewport);
- activeListEntries.Add(viewport);
+ currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel,
+ allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
+ }
- BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
- allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
-
- if (skillScrollbar is not null)
- {
- skillScrollbar.Model = viewport.Scroll;
- skillScrollbar.Visible = true;
- }
+ // Always bound + visible for whichever tab is active — retail's
+ // authored scrollbar gutter (0x1000023E, 281..297 within the
+ // 300px list) is reserved regardless of content, and UiScrollbar
+ // itself already draws the correct full-track "disabled" thumb
+ // when Model.HasOverflow is false (HideWhenDisabled defaults to
+ // false — see UiScrollbar.IsPresentationVisible), so no
+ // per-tab visibility toggle is needed here any more.
+ if (skillScrollbar is not null)
+ {
+ skillScrollbar.Model = viewport.Scroll;
+ skillScrollbar.Visible = true;
}
}
diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs
index 77f86951..bbdfe610 100644
--- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs
@@ -182,6 +182,18 @@ public sealed class CharacterTitlesController : IDisposable
// (CharacterManagementUiController.cs:463 sets its own row height
// the same way for the same reason).
listBox.LineHeight = 24;
+ // CT6 (2026-08-25): the Titles list (authored H=455 inside the
+ // 575px page — CT1 ground truth §3) must shrink/grow with the
+ // window the same way CharacterStatController's attribute/skill
+ // list does — same compatibility-anchor pattern, only applied when
+ // the imported element carries no authored edge policy of its own
+ // (an authored LayoutPolicy always wins). UiTemplateListBox's own
+ // internal viewport (created lazily inside AddItemFromTemplateList)
+ // already carries the #372-class eager-baseline-capture fix, so
+ // once the ListBox itself reflows, its scrollbar (bound below) picks
+ // up the new content/view relationship for free.
+ if (listBox.LayoutPolicy is null)
+ listBox.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
uint scrollbarElementId = listBox.ScrollbarElementId;
UiElement? scrollbarElement = scrollbarElementId == 0
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index dc8539a3..59f50533 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -4109,6 +4109,24 @@ public sealed class RetailUiRuntime : IDisposable
TitleTemplateResolver,
_bindings.Character.SendSetTitle);
+ // CT6 (2026-08-25 live probe): 0x2100002E's own content root
+ // (0x10000227) authors NO min/max (CT1 correction (a)) — retail's
+ // actual resize target is the SHARED gmPanelUI host (0x100005FE in
+ // LayoutDesc 0x2100006E). UIElement_Resizebar::StartMouseResizing
+ // @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...)
+ // and stashes the drag state (m_DragStart*/m_currentBorder) ON THAT
+ // PARENT; UIElement::MouseResizeElement @0x00461130 then reads
+ // GetAttribute_Int(this, 0x3C..0x3F) off the SAME element every
+ // mouse-move. The bottom Resizebar (0x10000660) is a DIRECT CHILD of
+ // the host, not of the content parent (0x10000180) — confirmed live:
+ // host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310
+ // MaxHeight=1000 (Min==Max width: no horizontal Resizebar is
+ // authored, matching ResizeX=false below).
+ ElementInfo? hostConstraint;
+ lock (_bindings.Assets.DatLock)
+ hostConstraint = LayoutImporter.ImportInfos(
+ _bindings.Assets.Dats, 0x2100006Eu, 0x100005FEu);
+
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
layout.Root,
@@ -4123,6 +4141,7 @@ public sealed class RetailUiRuntime : IDisposable
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainResizeToParent = true,
+ DatConstraintSource = hostConstraint,
Visible = false,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
index 5935e61c..77737eaa 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
@@ -380,6 +380,63 @@ public sealed class CharacterPanelLiveDatTests
Assert.Equal(2000, tree.MaxHeight);
}
+ ///
+ /// CT6 (2026-08-25 live probe): the ACTUAL resize clamp source for the
+ /// Character/Skills window is the SHARED gmPanelUI host
+ /// (0x100005FE in LayoutDesc 0x2100006E) — not
+ /// 0x2100002E's own root (see the pin above) and not the
+ /// Character/Skills slot 0x1000018E either (asserted below to
+ /// author no constraints of its own). Decomp chain:
+ /// UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
+ /// UIElement::StartResizing(this->GetParent(), ...), stashing
+ /// the drag state on that PARENT; UIElement::MouseResizeElement
+ /// @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F)
+ /// off that SAME element every mouse-move. The bottom Resizebar
+ /// (0x10000660) and top Dragbar (0x1000065C) are both
+ /// DIRECT CHILDREN of the host (siblings of the content parent
+ /// 0x10000180), confirmed here — so the host's own authored
+ /// 0x3C..0x3F values are what RetailUiRuntime.MountCharacter
+ /// must plumb through as .
+ ///
+ [InstalledDatFact]
+ public void PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract()
+ {
+ using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
+ ElementInfo? host = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu);
+ Assert.NotNull(host);
+ Assert.Equal(310f, host!.Width);
+ Assert.Equal(372f, host.Height);
+
+ // MinWidth == MaxWidth: no horizontal Resizebar is authored on this
+ // host (only the top Dragbar and bottom Resizebar — MountCharacter's
+ // ResizeX=false matches this exactly). MinHeight == the host's own
+ // authored default height (372) — retail's Character/Skills window
+ // can only grow taller, never shrink below its own authored extent.
+ Assert.Equal(310, host.MinWidth);
+ Assert.Equal(310, host.MaxWidth);
+ Assert.Equal(372, host.MinHeight);
+ Assert.Equal(1000, host.MaxHeight);
+
+ // The Resizebar and Dragbar are DIRECT CHILDREN of the host, not the
+ // content parent (0x10000180) — the load-bearing parentage fact
+ // StartMouseResizing's GetParent() call depends on.
+ Assert.Contains(host.Children, c => c.Id == 0x10000660u && c.Type == 9u); // Resizebar
+ Assert.Contains(host.Children, c => c.Id == 0x1000065Cu && c.Type == 2u); // Dragbar
+ Assert.Contains(host.Children, c => c.Id == 0x10000180u); // content parent, a SIBLING
+
+ // The Character/Skills slot itself authors no constraints of its
+ // own — the clamp is exclusively the host's, not layered again on
+ // the slot.
+ ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Eu);
+ Assert.NotNull(slot);
+ Assert.Equal(300f, slot!.Width);
+ Assert.Equal(362f, slot.Height);
+ Assert.Null(slot.MinWidth);
+ Assert.Null(slot.MinHeight);
+ Assert.Null(slot.MaxWidth);
+ Assert.Null(slot.MaxHeight);
+ }
+
///
/// The title-string table chain
/// (CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0):
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index 10809fb5..eba50d8c 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -389,8 +389,10 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- // Rows are UiClickablePanel which inherits UiPanel, so OfType matches.
- var rows = list.Children.OfType().ToList();
+ // CT6: rows now stack inside a UiScrollablePanel viewport child of
+ // the ListBox (same shrink-and-scroll contract Skills already had),
+ // so Descendants (not Children) finds them.
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
}
@@ -402,7 +404,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
// All rows must have an OnClick wired (not null) and ClickThrough = false.
foreach (var row in rows)
@@ -420,7 +422,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
@@ -439,7 +441,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
string[] expectedNames =
@@ -465,7 +467,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
string ValueOf(UiPanel row) => row.Children.OfType().ToList()[^1].LinesProvider()[0].Text;
@@ -489,7 +491,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
var iconEl = rows[0].Children.OfType().First();
@@ -507,7 +509,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
@@ -532,7 +534,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var row = list.Children.OfType().First();
+ var row = Descendants(list).OfType().First();
AssertRowGeometry(row, expectedWidth: 282f);
}
@@ -547,7 +549,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var row = list.Children.OfType().ToList()[6];
+ var row = Descendants(list).OfType().ToList()[6];
AssertRowGeometry(row, expectedWidth: 282f);
}
@@ -699,7 +701,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Simulate a click on row 4 (Focus).
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
rows[4].OnClick!();
@@ -717,7 +719,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
}
@@ -732,7 +734,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
// Focus raise cost = 110 (SampleData fixture).
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
@@ -748,7 +750,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
}
@@ -763,7 +765,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
// UnassignedXp = 87_757_321_741L
var expected = (87_757_321_741L).ToString("N0");
@@ -783,7 +785,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
// Select Focus (row 4).
rows[4].OnClick!();
@@ -805,7 +807,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
// Select Endurance (row 1, value=10).
rows[1].OnClick!();
@@ -826,7 +828,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
// All rows start transparent.
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
@@ -846,7 +848,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
@@ -871,7 +873,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
rows[2].OnClick!();
Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite
@@ -891,7 +893,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: FakeResolve);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
@@ -933,7 +935,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!(); // select Focus
+ Descendants(list).OfType().ToList()[4].OnClick!(); // select Focus
Assert.True(btn1.Visible, "raise×1 visible on selection");
Assert.True(btn10.Visible, "raise×10 visible on selection");
@@ -960,7 +962,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, () => sheet);
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
@@ -980,7 +982,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[0].OnClick!(); // select Strength (cost=0)
+ Descendants(list).OfType().ToList()[0].OnClick!(); // select Strength (cost=0)
Assert.True(btn1.Visible, "raise button visible even when disabled");
Assert.Equal("Ghosted", btn1.ActiveState);
@@ -1000,7 +1002,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
@@ -1023,7 +1025,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
btn1.OnClick!();
@@ -1045,7 +1047,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
- list.Children.OfType().ToList()[6].OnClick!();
+ Descendants(list).OfType().ToList()[6].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
@@ -1074,7 +1076,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, () => sheet,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
- list.Children.OfType().ToList()[4].OnClick!();
+ Descendants(list).OfType().ToList()[4].OnClick!();
btn10.OnClick!();
Assert.Empty(requests);
@@ -1091,7 +1093,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
rows[4].OnClick!(); // select
Assert.True(btn1.Visible);
rows[4].OnClick!(); // deselect
@@ -1308,7 +1310,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
- var attributeRows = list.Children.OfType().ToList();
+ var attributeRows = Descendants(list).OfType().ToList();
Assert.NotEmpty(attributeRows);
Assert.All(attributeRows, row =>
{
@@ -1363,7 +1365,7 @@ public class CharacterStatControllerTests
Assert.Equal(12, SkillRows(list).Count);
ClickTab(layout, left: 0f);
- var rows = list.Children.OfType().ToList();
+ var rows = Descendants(list).OfType().ToList();
Assert.Equal(9, rows.Count);
Assert.Equal("Strength", rows[0].Children.OfType().ToList()[1].LinesProvider()[0].Text);
}
@@ -1835,7 +1837,7 @@ public class CharacterStatControllerTests
CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
- list.Children.OfType().ToList()[0].OnClick!(); // Strength = index 0
+ Descendants(list).OfType().ToList()[0].OnClick!(); // Strength = index 0
Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text);
}
@@ -1852,7 +1854,7 @@ public class CharacterStatControllerTests
CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
- list.Children.OfType().ToList()[1].OnClick!(); // Endurance = index 1
+ Descendants(list).OfType().ToList()[1].OnClick!(); // Endurance = index 1
Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text);
}
@@ -1869,7 +1871,7 @@ public class CharacterStatControllerTests
CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
- list.Children.OfType().ToList()[0].OnClick!();
+ Descendants(list).OfType().ToList()[0].OnClick!();
Assert.Equal("Strength: 200", title.LinesProvider()[0].Text);
}
@@ -2145,7 +2147,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[0].OnClick!(); // Strength
+ Descendants(list).OfType().ToList()[0].OnClick!(); // Strength
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
}
@@ -2161,7 +2163,7 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- list.Children.OfType().ToList()[4].OnClick!(); // Focus
+ Descendants(list).OfType().ToList()[4].OnClick!(); // Focus
var color = title.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
@@ -2308,10 +2310,20 @@ public class CharacterStatControllerTests
Assert.Equal((510f, 7f), (divider.Top, divider.Height));
Assert.Equal(3, footers.Count);
Assert.All(footers, footer => Assert.Equal((520f, 55f), (footer.Top, footer.Height)));
- Assert.False(
+ // CT6 (2026-08-25): the shared scrollbar is now always bound + visible,
+ // on Attributes as well as Skills — retail's authored gutter
+ // (0x1000023E) is reserved regardless of content, and UiScrollbar's
+ // own IsPresentationVisible/IsModelDisabled already draw the correct
+ // full-track "disabled" thumb when content fits (HideWhenDisabled
+ // defaults to false). The 9 attribute/vital rows (180px content) fit
+ // comfortably inside this fixture's 398px view, so HasOverflow is
+ // false here — a shrunk window is what flips it true (see the
+ // window-level resize test).
+ Assert.True(
scrollbar.Visible,
- $"pre-skills scrollbar: model={scrollbar.Model is not null}, " +
+ $"attributes-tab scrollbar: model={scrollbar.Model is not null}, " +
$"resolve={scrollbar.SpriteResolve is not null}, track=0x{scrollbar.TrackSprite:X8}");
+ Assert.NotNull(scrollbar.Model);
ClickTab(layout, left: 92f);
Assert.True(scrollbar.Visible);
@@ -2326,6 +2338,160 @@ public class CharacterStatControllerTests
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite);
}
+ ///
+ /// CT6 (2026-08-25): the window-level resize contract. Live-probed ground
+ /// truth (against layout 0x2100006E, host 0x100005FE):
+ /// UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
+ /// UIElement::StartResizing(this->GetParent(), ...) — the bottom
+ /// Resizebar (0x10000660) is a DIRECT CHILD of the shared
+ /// gmPanelUI host, not of the content parent — and
+ /// UIElement::MouseResizeElement @0x00461130 reads
+ /// GetAttribute_Int(this, 0x3C..0x3F) off that SAME element on every
+ /// mouse-move. The host authors MinWidth=310 MinHeight=372 MaxWidth=310
+ /// (fixed — no horizontal Resizebar authored) MaxHeight=1000. This test
+ /// mounts the real character content through
+ /// with a synthetic
+ /// carrying exactly those four probed values (the same synthetic-
+ /// pattern already uses), matching
+ /// production's actual wire-up in RetailUiRuntime.MountCharacter
+ /// (which sources the SAME host element live from the DAT).
+ ///
+ [Fact]
+ public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar()
+ {
+ var layout = FixtureLoader.LoadCharacter();
+ CharacterStatController.Bind(
+ layout,
+ SampleData.SampleCharacter,
+ spriteResolve: id => (id, 16, 16));
+
+ // Tall enough that ConstrainResizeToParent (production's own setting)
+ // never becomes the binding constraint below — this test isolates the
+ // AUTHORED host clamp, not the desktop-edge clamp.
+ var root = new UiRoot { Width = 1280, Height = 1400 };
+ RetailWindowHandle handle = RetailWindowFrame.Mount(
+ root,
+ layout.Root,
+ id => (id, 16, 16),
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.Character,
+ Chrome = RetailWindowChrome.NineSlice,
+ Left = 540f,
+ Top = 18f,
+ ResizeX = false,
+ ResizeY = true,
+ ResizableEdges = ResizeEdges.Bottom,
+ ConstrainResizeToParent = true,
+ DatConstraintSource = HostConstraints(),
+ ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
+ });
+
+ ApplyLayoutPass(handle.OuterFrame);
+
+ var page = layout.Root.Children.Single(
+ e => e.DatElementId == CharacterStatController.AttributesPageId);
+ var list = Descendants(page).Single(
+ e => e.DatElementId == CharacterStatController.ListBoxId);
+ var statLayout = list.Parent!;
+ var scrollbar = statLayout.Children.OfType().Single(
+ e => e.DatElementId == CharacterStatController.ListScrollbarId);
+ var footer = statLayout.Children.First(
+ e => e.DatElementId == CharacterStatController.FooterStateAId);
+ var viewport = Assert.IsType(list.Children.Single());
+
+ // Chrome inset = 2 * RetailChromeSprites.Border (5px) = 10; the DAT's
+ // 310/372/310/1000 host values become 320/382/320/1010 on the
+ // NineSlice-wrapped outer frame.
+ Assert.Equal(320f, handle.OuterFrame.MinWidth);
+ Assert.Equal(320f, handle.OuterFrame.MaxWidth);
+ Assert.Equal(382f, handle.OuterFrame.MinHeight);
+ Assert.Equal(1010f, handle.OuterFrame.MaxHeight);
+
+ float originalOuterHeight = handle.Height;
+ float originalListHeight = list.Height;
+ float originalFooterBottomGap = statLayout.Height - (footer.Top + footer.Height);
+ Assert.NotNull(scrollbar.Model);
+ viewport.LayoutScrollableChildren();
+ Assert.False(
+ scrollbar.Model!.HasOverflow,
+ "the fixture's authored default height fits all 9 rows without scrolling");
+
+ // Request far below the authored minimum — the clamp must hold at
+ // 382, not the requested value.
+ handle.ResizeTo(handle.Width, 50f);
+ Assert.Equal(382f, handle.Height);
+
+ ApplyLayoutPass(handle.OuterFrame);
+ viewport.LayoutScrollableChildren();
+
+ Assert.True(list.Height < originalListHeight, "the stat list must shrink with the window");
+ Assert.Equal((int)MathF.Floor(viewport.Height), scrollbar.Model!.ViewHeight);
+ Assert.True(
+ scrollbar.Model!.HasOverflow,
+ "9 rows (180px content) must overflow the shrunk view");
+
+ // The footer stays bottom-docked: same distance from the stat
+ // layout's own bottom edge before and after the shrink.
+ float shrunkFooterBottomGap = statLayout.Height - (footer.Top + footer.Height);
+ Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2);
+
+ // Request far above the authored maximum — the clamp must hold at
+ // 1010, not the requested value.
+ handle.ResizeTo(handle.Width, 5000f);
+ Assert.Equal(1010f, handle.Height);
+
+ ApplyLayoutPass(handle.OuterFrame);
+ viewport.LayoutScrollableChildren();
+ Assert.False(
+ scrollbar.Model!.HasOverflow,
+ "growing well past the content height restores no-overflow");
+
+ // Growing back to the ORIGINAL authored size restores the original
+ // list height and the no-overflow state.
+ handle.ResizeTo(handle.Width, originalOuterHeight);
+ ApplyLayoutPass(handle.OuterFrame);
+ viewport.LayoutScrollableChildren();
+
+ Assert.Equal(originalOuterHeight, handle.Height);
+ Assert.Equal(originalListHeight, list.Height, precision: 2);
+ Assert.False(scrollbar.Model!.HasOverflow);
+ }
+
+ /// Synthetic authored 0x3C..0x3F constraints matching the
+ /// 2026-08-25 live probe of the shared gmPanelUI host
+ /// (0x100005FE in LayoutDesc 0x2100006E): MinWidth=310
+ /// MinHeight=372 MaxWidth=310 (fixed — no horizontal Resizebar authored)
+ /// MaxHeight=1000. Same synthetic- shape as
+ /// 's own private helper.
+ private static ElementInfo HostConstraints()
+ {
+ var info = new ElementInfo();
+ var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
+ direct.Properties.Values[0x3Fu] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Integer,
+ IntegerValue = 310,
+ };
+ direct.Properties.Values[0x3Eu] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Integer,
+ IntegerValue = 372,
+ };
+ direct.Properties.Values[0x3Du] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Integer,
+ IntegerValue = 310,
+ };
+ direct.Properties.Values[0x3Cu] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Integer,
+ IntegerValue = 1000,
+ };
+ info.States[UiStateInfo.DirectStateId] = direct;
+ return info;
+ }
+
///
/// 2026-08-24 owner report: "hovering the scrollbar and arrows does
/// nothing" — root-level hover through the REAL mounted character
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
index 122d24b2..809452c1 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs
@@ -512,6 +512,101 @@ public sealed class CharacterTitlesControllerTests
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
}
+ // ── CT6 resize/scrollbar contract ──────────────────────────────────
+
+ ///
+ /// CT6 (2026-08-25): the Titles list (authored H=455 inside the 575px
+ /// page — CT1 ground truth §3) must shrink with the window and hand off
+ /// to its scrollbar the same way CharacterStatController's
+ /// attribute/skill list does. Mounts the real fixture through
+ /// (same shape as
+ /// RetailUiRuntime.MountCharacter) and shrinks the window well
+ /// below the list's authored height.
+ ///
+ [Fact]
+ public void TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips()
+ {
+ // 15 rows at the authored 24px pitch = 360px content — comfortably
+ // under the list's own authored 455px height (fits at the fixture's
+ // default mounted size, matching how CharacterStatController's own
+ // attribute list fits its default size), but well over what remains
+ // once the window is shrunk below.
+ uint[] earnedIds = Enumerable.Range(1, 15).Select(i => (uint)i).ToArray();
+ var names = earnedIds.ToDictionary(id => id, id => $"Title {id}");
+ Harness h = BindWithEarnedTitles(earnedIds, displayTitleId: 0u, names: names);
+ Assert.Equal(15, h.Rows.Count);
+
+ var root = new UiRoot { Width = 1280, Height = 1400 };
+ RetailWindowHandle handle = RetailWindowFrame.Mount(
+ root,
+ h.Layout.Root,
+ id => (id, 16, 16),
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.Character,
+ Chrome = RetailWindowChrome.NineSlice,
+ Left = 540f,
+ Top = 18f,
+ ResizeX = false,
+ ResizeY = true,
+ ResizableEdges = ResizeEdges.Bottom,
+ // No DatConstraintSource: this test exercises the LIST'S OWN
+ // reflow/scrollbar contract given an already-permitted
+ // resize, not the authored host clamp value itself (that is
+ // CharacterStatControllerTests.
+ // CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar's
+ // job) — an explicit generous MinHeight lets the window
+ // actually shrink instead of defaulting to its own mounted
+ // height (RetailWindowFrame.Mount's fallback when neither
+ // MinHeight nor DatConstraintSource is supplied).
+ MinHeight = 40f,
+ ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
+ });
+
+ ApplyAnchors(handle.OuterFrame);
+
+ var scrollbar = Assert.IsType(
+ h.Layout.FindElement(h.ListBox.ScrollbarElementId));
+ Assert.Same(h.ListBox.Scroll, scrollbar.Model);
+
+ float originalListHeight = h.ListBox.Height;
+ h.ListBox.ViewportForTest!.LayoutScrollableChildren();
+ Assert.False(
+ h.ListBox.Scroll.HasOverflow,
+ "the fixture's authored default height fits all 15 rows without scrolling");
+
+ // Shrink the window well below the list's own authored 455px height.
+ // (The Titles page container 0x10000539 carries its own authored
+ // LayoutPolicy that already stretches with the mounted content's
+ // height — verified live during this test's development — so only
+ // the ListBox's OWN Anchors, set above by CharacterTitlesController,
+ // were the missing piece.)
+ handle.ResizeTo(handle.Width, 200f);
+ ApplyAnchors(handle.OuterFrame);
+ h.ListBox.ViewportForTest!.LayoutScrollableChildren();
+
+ Assert.True(h.ListBox.Height < originalListHeight, "the Titles list must shrink with the window");
+ Assert.True(h.ListBox.Scroll.HasOverflow, "360px of rows must overflow the shrunk view");
+ Assert.Equal((int)MathF.Floor(h.ListBox.ViewportForTest!.Height), h.ListBox.Scroll.ViewHeight);
+
+ // Growing back restores the original height and the no-overflow state.
+ handle.ResizeTo(handle.Width, handle.AuthoredHeight);
+ ApplyAnchors(handle.OuterFrame);
+ h.ListBox.ViewportForTest!.LayoutScrollableChildren();
+
+ Assert.Equal(originalListHeight, h.ListBox.Height, precision: 2);
+ Assert.False(h.ListBox.Scroll.HasOverflow);
+ }
+
+ private static void ApplyAnchors(UiElement parent)
+ {
+ foreach (UiElement child in parent.Children)
+ {
+ child.ApplyAnchor(parent.Width, parent.Height);
+ ApplyAnchors(child);
+ }
+ }
+
// ── Lifecycle ───────────────────────────────────────────────────────
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
index 3d670338..84af5098 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
@@ -137,6 +137,60 @@ public sealed class RetailWindowFrameTests
Assert.Equal(0.75f, frame.Opacity);
}
+ ///
+ /// CT6 shared-mechanism regression pin: the authored-min/max clamp exists
+ /// in exactly ONE place — //
+ /// /,
+ /// set once at from
+ /// and then
+ /// enforced identically by RetailWindowManager.ResizeTo (this
+ /// test), the interactive drag path in UiRoot, and the persisted-
+ /// geometry restore clamp in RetailWindowLayoutPersistence.Apply.
+ /// Chat (0x2100006F) was the FIRST window to author real DAT
+ /// min/max (min 300/100, max 2000/2000 per
+ /// CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints);
+ /// this test proves an interactive-shaped ResizeTo call against a
+ /// chat-shaped mount still clamps at those authored bounds after CT6
+ /// wired a SECOND window (Character) onto the same mechanism — a
+ /// regression here would mean CT6 accidentally special-cased Character
+ /// instead of reusing the standard path.
+ ///
+ [Fact]
+ public void NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds()
+ {
+ var root = new UiRoot { Width = 1920, Height = 1080 };
+ var content = new UiPanel { Width = 490, Height = 100 };
+ var constraints = Constraints(minHeight: 100, maxHeight: 2000);
+
+ RetailWindowHandle handle = RetailWindowFrame.Mount(
+ root,
+ content,
+ NoTex,
+ new RetailWindowFrame.Options
+ {
+ WindowName = "chat",
+ Chrome = RetailWindowChrome.NineSlice,
+ Left = 10,
+ Top = 20,
+ ResizeX = false,
+ ResizeY = true,
+ DatConstraintSource = constraints,
+ });
+
+ // Request far below the authored minimum (100 + 10px chrome inset =
+ // 110) — the clamp must hold, not the requested value.
+ Assert.True(handle.ResizeTo(handle.Width, 5f));
+ Assert.Equal(110f, handle.Height);
+
+ // Request far above the authored maximum (2000 + 10 = 2010).
+ Assert.True(handle.ResizeTo(handle.Width, 50000f));
+ Assert.Equal(2010f, handle.Height);
+
+ // A request inside the bounds is honored exactly.
+ Assert.True(handle.ResizeTo(handle.Width, 500f));
+ Assert.Equal(500f, handle.Height);
+ }
+
[Fact]
public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter()
{