From ec50455a63f488982a8ba94ded946d5f0afb8806 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 02:21:56 +0200 Subject: [PATCH] feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...6-08-24-character-panel-parity-campaign.md | 69 ++++- ...2026-08-24-campaign-ct-dat-ground-truth.md | 124 +++++++++ .../2026-08-25-campaign-ct-test-script.md | 52 +++- .../UI/Layout/CharacterStatController.cs | 91 ++++--- .../UI/Layout/CharacterTitlesController.cs | 12 + src/AcDream.App/UI/RetailUiRuntime.cs | 19 ++ .../UI/Layout/CharacterPanelLiveDatTests.cs | 57 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 242 +++++++++++++++--- .../Layout/CharacterTitlesControllerTests.cs | 95 +++++++ .../UI/Layout/RetailWindowFrameTests.cs | 54 ++++ 10 files changed, 741 insertions(+), 74 deletions(-) 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() {