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() {