diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bc0424b2..c300f8dd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,94 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge) + +**Status:** DONE 2026-08-16/17 (overnight hover/UI round, Batch A bug 2). + +**Symptom (user screenshot):** the Config tab's Sound/Camera/Graphics/Rendering +Quality sections rendered with the Apply/Reset/Defaults footer sitting +mid-panel and further rows (Full Screen, Sync With Refresh Rate, Screen +Brightness, Adaptive Degrade, the quality dropdowns...) drawing BELOW the +window's bottom edge, outside the panel frame — not clipped to the window, +not reachable by scrolling. The user noted seeing this class of bug before +("another bug that I saw before as well") — a related but distinct symptom, +the CONTENT-behind-the-footer bleed-through, was already fixed as #381; this +is content escaping the WHOLE window, not just showing through the footer +strip. + +**Root cause — a stale anchor-baseline capture, not a missing clip.** +Live-DAT measured (`0x2100006E` slot `0x1000018D`): the merged tab-host root +is authored 300×362 (retail's real default window size — matches the floaty +frame's own 310×372 root), but the Config page slot underneath +(`0x10000213`) keeps its own larger authored design geometry, 298×575 +against a 300×600 design canvas — retail's real +`UIElement::UpdateForParentSizeChange @0x00462640` four-edge policy +(`L=T=R=B=1`, "preserve original margin on every edge") correctly shrinks +that slot to ~298×337 on the first `ApplyAnchor` layout pass, and the Config +ListBox (`0x10000200`, 276×560) shrinks right behind it via the SAME +per-element `UiLayoutPolicy` mechanism — both verified stable over repeated +simulated frames. The actual bug: `UiTemplateListBox.Viewport` (the +`UiScrollablePanel` that hosts + clips every row) is a PROGRAMMATIC C# +element seeded at `ConfigOptionsPageController.Bind` time — BEFORE the +tree's first real draw frame, i.e. before the ListBox has ever shrunk. Its +legacy `Left|Top|Right|Bottom` anchor baseline is captured lazily, on ITS +own first `ApplyAnchor` call, which lands AFTER the ListBox has already +shrunk earlier in that SAME frame (parent-before-child draw order) — so the +capture measures a NEGATIVE bottom margin (`parentH(297) - (0+560) = -263`) +that `ComputeAnchoredRect`'s stretch math preserves FOREVER: the viewport +stayed locked at its original 560px design height, clipping rows to a bound +retail never actually gave the window on screen. Rows past the real ~297px +stayed "visible" per the cull test and painted straight through the footer +and past the window's real bottom edge. + +**Fix (mechanism, not a workaround):** `UiTemplateListBox.Viewport`'s getter +now calls `_viewport.CaptureCurrentAnchorBaseline()` immediately after +seeding it — forcing the anchor capture to happen NOW, while the viewport's +own Width/Height still exactly equal a zero-margin baseline against its +CURRENT (pre-shrink) parent, instead of lazily on the first real draw frame +against an ALREADY-shrunk parent. `ComputeAnchoredRect` then tracks whatever +height the ListBox actually ends up at after its own `LayoutPolicy` runs, on +every subsequent frame — exactly #372's original intent (#372 fixed the 0×0 +collapse case; this is #372's sequel for the "ListBox itself later shrinks" +case, which #372's own fixture never exercised because its harness ListBox +had no parent to shrink it). +`src/AcDream.App/UI/UiTemplateListBox.cs`. + +**Tests:** `UiTemplateListBoxViewportTests.Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout` +(synthetic two-level `UiLayoutPolicy` parent chain using the live-DAT-measured +298×575/276×560 numbers) and two `ConfigOptionsPageControllerTests` fixture +regressions +(`ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass`, +`ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass`) +against the REAL production `ConfigOptionsPageController.Bind` path and the +committed `options_panel_2100006E_1000018D.json` fixture. All three fail +pre-fix (red-green confirmed by temporarily reverting the fix) and pass +post-fix. Full App suite (5437/3 skips), Runtime (1735/0), and the complete +solution (14,575 tests) pass with the fix in place. + +**Blast-radius note (task-required):** the fix is scoped to +`UiTemplateListBox`'s own lazily-created viewport — it does not touch +`UiScrollablePanel`, `UiElement.ApplyAnchor`, or `ComputeAnchoredRect` +themselves, so chat's transcript scrolling and every other +`UiScrollablePanel`/`UiItemList` consumer (inventory grids, spell/component +catalogs, Chat tab's own filter blocks) are unaffected — confirmed by the +full solution run passing with no new failures anywhere outside the two +files this fix touches. The ONLY other `UiTemplateListBox` consumers are the +Character and Chat Options tabs, which share the identical +Bind-before-first-frame ordering and are now protected by the SAME fix. + +**Live check not performed:** the fix is proven via live-DAT-measured +geometry (real installed DAT numbers feeding both the regression tests and +this writeup) plus the fixture path that mirrors the exact production +`RetailUiRuntime.MountOptionsPanel` sequence, but the actual visual +Config-tab-in-window check was not done live (no interactive desktop +consent available this session — see #409's own note on the same +constraint). Owed: open Options -> Config with `ACDREAM_RETAIL_UI=1` and +confirm the footer and every row stay inside the window frame, with the +scrollbar reaching every row. + +--- + ## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state **Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs index f4dfaecc..f10241ff 100644 --- a/src/AcDream.App/UI/UiTemplateListBox.cs +++ b/src/AcDream.App/UI/UiTemplateListBox.cs @@ -177,6 +177,42 @@ public sealed class UiTemplateListBox : UiDatElement Height = Height, }; base.AddChild(_viewport); + + // #412-class fix (2026-08-16, overnight hover/UI round, Batch A bug 2): + // #372's seed above only fixed the 0×0 collapse for a ListBox whose OWN + // size never changes after the viewport is created. It does NOT hold for + // the Options panel's real mount: this ListBox (0x10000200 etc.) is a + // DAT-imported element carrying its own retail four-edge UiLayoutPolicy + // (UIElement::UpdateForParentSizeChange @0x00462640), and a page + // controller's Bind (which lazily creates this viewport, calling + // AddItemFromTemplateList) runs BEFORE the tree's first real draw frame — + // i.e. before ANY ApplyAnchor pass has ever run. The Options tab-host's + // page slot (298×575 authored) is taller than its actual 300×362 mounted + // container, so on the FIRST draw frame the slot's LayoutPolicy shrinks + // it top-down (e.g. to ~298×337), and THIS ListBox — also LayoutPolicy- + // driven, recomputed fresh every call, no capture-staleness of its own — + // shrinks right behind it (e.g. to ~282×297) in the SAME frame, BEFORE + // its per-child loop ever reaches the viewport below it. The viewport + // above was seeded at BIND time against the ListBox's PRE-shrink size + // (276×560) but its own legacy Left|Top|Right|Bottom anchor baseline is + // only CAPTURED lazily, on ITS first ApplyAnchor call — which lands AFTER + // the ListBox has already shrunk in that same frame. That capture then + // measures a NEGATIVE bottom margin (parentH(297) - (0+560) = -263) which + // ComputeAnchoredRect's stretch math preserves forever (h = parentH - mB - + // mT = 297 - (-263) - 0 = 560): the viewport is permanently locked at its + // ORIGINAL oversized height, clipping its rows to a bound retail never + // actually gave it on screen. Every row past the real ~297px stays + // "visible" per LayoutScrollableChildren's cull test and paints straight + // through the footer and past the window's real bottom edge — the exact + // "dozens of rows below the window frame" symptom (#412-class report: + // Full Screen/Sync/Screen Brightness/Adaptive Degrade/quality dropdowns + // drawing outside the panel). Forcing the capture to happen NOW, while + // Width/Height still exactly equal the ListBox's CURRENT (pre-shrink, but + // zero-margin) size, makes the captured margins (0,0,0,0) instead of + // negative — ComputeAnchoredRect then tracks whatever height the ListBox + // ACTUALLY ends up at after its own LayoutPolicy runs, on every frame + // after this one, exactly like #372 intended. + _viewport.CaptureCurrentAnchorBaseline(); } return _viewport; } diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index c1cd9b66..d0374b5d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -1108,4 +1108,119 @@ public sealed class ConfigOptionsPageControllerTests + $"built widget rendered {actual.Value}."); } } + + // ── #412-class regression: Config tab content escaping the window frame ── + // + // 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's + // screenshot showed the Config tab's Apply/Reset/Defaults footer sitting + // mid-panel with further rows (Full Screen, Sync With Refresh Rate, + // Screen Brightness, Adaptive Degrade, the quality dropdowns...) drawing + // BELOW the window's bottom edge, outside the panel frame. Live-DAT + // measured root cause: the tab host's authored page slot (0x10000213, + // 298x575) is taller than its actual mounted container (the merged + // 0x1000018D root, authored 300x362 — retail's own + // UIElement::UpdateForParentSizeChange @0x00462640 four-edge policy + // shrinks it correctly on the first ApplyAnchor pass). The Config ListBox + // (0x10000200, 276x560) shrinks right behind it via the SAME per-element + // UiLayoutPolicy. But UiTemplateListBox.Viewport (the UiScrollablePanel + // that actually hosts + clips every row) is a programmatic C# element + // seeded at Bind time — BEFORE any ApplyAnchor pass has ever run — with + // the ListBox's THEN-current (pre-shrink) 276x560 size. Its own legacy + // Left|Top|Right|Bottom anchor baseline is captured lazily, on its first + // ApplyAnchor call, which lands AFTER the ListBox has already shrunk in + // that same frame — producing a negative captured bottom margin that + // ComputeAnchoredRect's stretch math preserves forever: the viewport + // stayed locked at its original 560px height, well past the real ~297px + // available, so rows drew (and were culled) against a bound retail never + // actually gave the window on screen. Fixed in UiTemplateListBox.Viewport + // by forcing the capture immediately after seeding, while the viewport's + // own Width/Height still exactly equal a zero-margin baseline. + + [Fact] + public void ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass() + { + // Pins the LIVE-DAT-measured authored facts this whole bug turns on: + // the tab host's merged root is the SLOT's own (cropped) 300x362 + // extent, but the Config page slot underneath keeps ITS OWN larger + // authored design geometry (298x575, drawn against a 300x600 design + // canvas) until a layout pass actually reflows it. + (OptionsPanelController controller, _, bool bound) = BindReal(); + Assert.True(bound); + + Assert.Equal(300f, controller.TabPanel.Width); + Assert.Equal(362f, controller.TabPanel.Height); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + Assert.Equal(575f, configSlot.Height); + Assert.NotNull(configSlot.LayoutPolicy); + } + + [Fact] + public void ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass() + { + (OptionsPanelController controller, _, bool bound) = BindReal(); + Assert.True(bound); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + UiElement viewport = Assert.Single(listBox.Children); + + // Drive the SAME top-down ApplyAnchor walk DrawSelfAndChildren runs + // every real frame — parent before children, all the way down — + // TWICE, to prove the result is a stable steady state and not an + // artifact of a single simulated pass. + for (int frame = 0; frame < 2; frame++) + ApplyAnchorRecursive(controller.TabPanel); + + // The viewport must track the REAL (shrunk) ListBox extent, not stay + // locked at its original oversized 560px design height. + Assert.True( + viewport.Height <= listBox.Height + 0.5f, + $"viewport height {viewport.Height} exceeds its ListBox's actual " + + $"height {listBox.Height} — rows will draw/cull past where " + + "the window actually is (the #412-class bug)."); + + // Nothing in the Config page may extend past the slot's own bottom + // edge, and the slot itself may not extend past the mounted window's + // own bottom edge — the exact "content escapes the window frame" + // symptom the user's screenshot showed. + float slotBottom = configSlot.Top + configSlot.Height; + Assert.True( + slotBottom <= controller.TabPanel.Height + 0.5f, + $"Config slot bottom {slotBottom} exceeds the mounted window's " + + $"own height {controller.TabPanel.Height}."); + + foreach (uint footerId in new[] + { + ConfigOptionsPageController_ApplyButtonId, + ConfigOptionsPageController_ResetButtonId, + ConfigOptionsPageController_DefaultsButtonId, + }) + { + UiElement? btn = UiElement.FindDescendant(configSlot, footerId); + Assert.NotNull(btn); + float bottom = btn!.Top + btn.Height; + Assert.True( + bottom <= slotBottom + 0.5f, + $"footer 0x{footerId:X8} bottom {bottom} exceeds the Config " + + $"slot's own bottom {slotBottom}."); + } + } + + // Apply/Reset/Defaults element ids — ConfigOptionsPageController's own + // constants of the same name are private; mirrored here rather than + // widening that class's surface just for this test. + private const uint ConfigOptionsPageController_ApplyButtonId = 0x100001FCu; + private const uint ConfigOptionsPageController_ResetButtonId = 0x100001FDu; + private const uint ConfigOptionsPageController_DefaultsButtonId = 0x100001FEu; + + private static void ApplyAnchorRecursive(UiElement e) + { + foreach (UiElement child in e.Children) + { + child.ApplyAnchor(e.Width, e.Height); + ApplyAnchorRecursive(child); + } + } } diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs index 98ceeba6..02c6a176 100644 --- a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs @@ -76,4 +76,75 @@ public sealed class UiTemplateListBoxViewportTests Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug"); Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug"); } + + /// + /// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug + /// 2): the Options panel's Config tab escaped past the window frame — the + /// footer sitting mid-panel with further rows drawing below the window's + /// bottom edge. #372's own fixture above never exercises this because + /// gives the ListBox no parent — its own size + /// never changes after the viewport is seeded. The real Options mount is + /// different: this ListBox is itself a DAT-imported + /// carrying a real from its authored parent + /// (the Config page slot), and a page controller's Bind — which lazily + /// creates this viewport — runs BEFORE the tree's first draw frame, i.e. + /// before the ListBox has ever shrunk to fit its actual (smaller than + /// authored) container. This reproduces that ordering with a real + /// LayoutPolicy-driven parent standing in for the page slot. + /// + [Fact] + public void Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout() + { + // A stand-in for the Config page slot: authored 298×575 against an + // authored 300×600 design canvas (live-DAT-measured values), but its + // real mounted container is only 300×362 — exactly retail's + // UIElement::UpdateForParentSizeChange four-edge policy (L=T=R=B=1, + // "preserve original margin on every edge"). + var slotPolicy = new UiLayoutPolicy( + leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1, + originalChild: UiPixelRect.FromPositionAndSize(2, 25, 298, 575), + originalParent: UiPixelRect.FromPositionAndSize(0, 0, 300, 600)); + var slot = new UiPanel + { + Left = 2, Top = 25, Width = 298, Height = 575, + LayoutPolicy = slotPolicy, + }; + var root = new UiPanel { Width = 300, Height = 362 }; + root.AddChild(slot); + + // The ListBox itself ALSO carries a real LayoutPolicy (live-DAT + // measured: authored 276×560 against the slot's own 298×575 design + // extent) — this is what shrinks it out from under the viewport. + var box = MakeListBox(276f, 560f); + var listBoxPolicy = new UiLayoutPolicy( + leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1, + originalChild: UiPixelRect.FromPositionAndSize(0, 0, 276, 560), + originalParent: UiPixelRect.FromPositionAndSize(0, 0, 298, 575)); + box.LayoutPolicy = listBoxPolicy; + slot.AddChild(box); + + // Seed the viewport with rows BEFORE any layout pass has ever run — + // exactly ConfigOptionsPageController.Bind's own ordering (it runs + // before RetailWindowFrame.Mount's first draw frame). + box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f }); + UiScrollablePanel viewport = box.ViewportForTest!; + + // Drive ONE simulated draw-frame's top-down ApplyAnchor walk — the + // SAME order DrawSelfAndChildren runs every frame: parent before + // children, all the way down. + slot.ApplyAnchor(root.Width, root.Height); // slot shrinks: 575 -> ~337 + box.ApplyAnchor(slot.Width, slot.Height); // listbox shrinks: 560 -> ~297 (still ahead of the viewport) + viewport.ApplyAnchor(box.Width, box.Height); // the viewport's FIRST EVER ApplyAnchor call + + // Pre-fix: the viewport's legacy anchor baseline captured a NEGATIVE + // bottom margin against the ALREADY-SHRUNK ListBox (parentH(~297) - + // (0+560) < 0), which ComputeAnchoredRect's stretch math preserves + // forever — the viewport stayed locked at its original 560px height, + // clipping rows to a bound retail never actually gave it on screen. + Assert.Equal(box.Height, viewport.Height, 3); + Assert.True( + viewport.Height < 400f, + $"viewport height {viewport.Height} did not shrink with its ListBox " + + "(560 == the pre-fix stale-capture bug)"); + } }