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 1e6878e6..7c3602bc 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -412,21 +412,74 @@ MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar `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. +that host element and passes it as `DatConstraintSource`. **CORRECTED +(CT6 fix round, BLOCKER B1):** this paragraph originally claimed the +mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382, +MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host +`0x100005FE` is not a content element our wrapper adds chrome to; it IS +retail's own outer window frame (5px bevel + 300×362 content parent +`0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are +already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset +on top double-counted the bevel, clamping MinWidth to 320 while the +window's actual mounted outer width stayed 310 — silently below its own +minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite +`ResizeX=false`. Fixed with a new +`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out +(chrome inset = 0 for constraint resolution when set — the value stays +DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets +it true. The mounted outer clamps are now EXACTLY what the host authors: +width fixed **310**, height **372..1000** — no composed arithmetic. A +new mount-time invariant in `RetailWindowFrame.Mount` (throws if the +just-mounted outer extent falls outside its own just-computed clamp) +would have caught B1 at the very first test run; it is now permanent for +every window this path mounts. + +**S4 (2026-08-25, campaign-lead ruling — which number governs the +default mount size):** `0x2100002E`'s own root is authored 300×600 (the +"Size tension" the earlier ground-truth doc left unresolved — CT3's +Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real +authored canvas, but it is the CONTENT's own design surface, not the +mounted default — retail scroll-clips it into the shared host's much +smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter` +left `ContentHeight` unset, so it fell back to the raw 600px canvas, +producing a stale 610px mounted default (600 + 10px chrome inset) that +was never retail's actual opening size. **372 (the host's own outer +frame, 362 content + 10 chrome) is the number that governs the mount +default** — it is also exactly the host's own authored MinHeight, so +retail's Character/Skills window opens AT its resize floor and can only +be dragged taller, never shorter. `MountCharacter` now sets +`Options.ContentHeight = 362f` explicitly to realize this. The authored +page composition (header 112 + list 160 + divider + footer) IS the +362px design; at that default the 9 attribute/vital rows (180px content) +OVERFLOW the 160px list, so the stat list's scrollbar is active +immediately on open — retail-correct, not a regression (see S2 below for +what "active" actually looks like). Persistence still restores a +user-chosen size within the 372..1000 clamp on top of this default. 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`/ +is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility +toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph +originally claimed `UiScrollbar`'s own `IsPresentationVisible`/ +`IsModelDisabled` "draw the correct full-track 'disabled' thumb when +content fits (`HideWhenDisabled` defaults false)" — that had the +authored default BACKWARDS. `0x1000023E` (this scrollbar) and +`0x10000533` (the Titles list's own scrollbar) both author property +`0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true` +on both elements' property 121/0x79 in the committed fixture). A fitting +list HIDES the bar entirely; it does not leave a full-track disabled +thumb visible. The code was already correct — `.Visible = true` only +keeps the bar in the tree, `IsPresentationVisible` does the actual +show/hide — only this description was wrong; fixed here, in +`CharacterStatController.RebuildActiveList`'s own comment, in the CT7 +script, and in `CharacterStatControllerTests`' comment, plus a new +`IsPresentationVisible` assertion pair added to the resize test (hidden +once growing makes the content fit, visible+interactive while +overflowing). 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 @@ -435,13 +488,19 @@ 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. +`UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix +round, S3):** `CharacterTitlesController.Bind` originally gained the +same defensive `if (listBox.LayoutPolicy is null) Anchors = +Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that +`CharacterStatController` already had for its own list. Both +`0x10000532` and the Titles page container `0x10000539` author +`HasOriginalParentSize=true` in the real DAT AND the committed fixture, +which makes `LayoutImporter`/`DatWidgetFactory` always assign a real +`LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a +harmless no-op "matching the established pattern for synthetic/test +layouts" as originally described. Deleted rather than left as dead code; +a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize` +on both elements to guard the deletion against future DAT drift. STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement .MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at `RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit @@ -451,10 +510,20 @@ 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` +window bypasses it). New regression pin (**renamed, CT6 fix round N4**: +the original name `NineSlice_ChatShapedConstraints_ +ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises +NineSlice inset ARITHMETIC on a content-shaped source (490×100, +height-only synthetic constraints) and never actually pinned chat's real +DAT contract, since no width constraints were even set): +`RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize` proves the same mechanism still clamps chat-shaped constraints after -Character was wired onto it. Tests: `CharacterStatControllerTests +Character was wired onto it. A new companion test, +`Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts +with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints +(matching production's actual `MountChat` wiring) and asserts no inset +applies — the true chat-contract pin the renamed test's name no longer +claims to be. 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 @@ -462,9 +531,16 @@ overflow flip, footer stays bottom-docked, grow-back restore) and (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. +already needed). No register row: **CORRECTED (CT6 fix round, N5)** — +before the B1 fix this sentence ("every number is either a live-probed +authored DAT value ... nothing inferred") was not actually true: the +mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive +values plus a second, redundant chrome inset composed on top). After B1 +removes that composition, the mounted clamp is now literally the host's +own four probed values with zero arithmetic applied — the sentence holds +for real. No register row for the S4 content-height default either: 362 +is the same host content-parent width/height CT6 already probed and +cited (`0x10000180`, 300×362), not a new number. **CT7 — Connected gate.** Test script (`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive: 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 656e03af..1c332e57 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 @@ -641,26 +641,122 @@ root or the slot. `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. +passes it as `RetailWindowFrame.Options.DatConstraintSource`. + +**CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25):** this section +originally claimed the NineSlice chrome inset (`2 * +RetailChromeSprites.Border` = 10px) "is added automatically by +`RetailWindowFrame.ResolveConstraint`, giving the mounted outer frame +MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG. +Host `0x100005FE` is not a bare content element our own NineSlice +wrapper adds chrome to — per the geometry dumped above, it IS retail's +own complete outer window frame: 5px bevel + the 300×362 content parent +(`0x10000180`) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values +are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px +inset on top of an already chrome-inclusive source double-counted the +bevel: the mounted window's clamp said MinWidth=320 while its actual +mounted outer width was only 310 — the window opened already violating +its own minimum, silently "fixed" at runtime only because +`RetailWindowManager.ResizeTo`'s main-panel geometry sync forcibly +widened it to 320 despite `ResizeX=false`, which would have produced a +visible 15px right-bevel seam against the other eight main panels +sharing that sync. + +**Fix:** a new opt-out, +`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` (default +`false`, preserving every other window's existing content-plus-chrome +behavior), tells `ResolveConstraint` to apply a chrome inset of 0 when +the DAT source is itself already the outer frame. `MountCharacter` sets +it `true` for `hostConstraint`. The mounted outer clamps are now EXACTLY +the host's four raw values, no composed arithmetic: **MinWidth= +MaxWidth=310, MinHeight=372, MaxHeight=1000.** A new mount-time +invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer +extent falls outside its own just-computed clamp) guards against this +class of bug recurring for any window this path mounts. `ResizeX=false`/`ResizableEdges=Bottom` (already correct in the pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring exactly — no change needed there. +### Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25) + +The "Size tension" flagged earlier in this doc (§4, "Correction to the +plan") — `0x2100002E`'s own root authored 300×600 vs. the host slot's +300×362 — is now resolved: **372px outer (362px content) is the number +that governs the MOUNTED DEFAULT**, and 600 is the content's own +authored design canvas that retail scroll-clips into the much smaller +host slot, never the size the window actually opens at. Concretely: +`0x2100002E`'s 300×600 root exists because its Titles page alone is +authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is +real, authored geometry, and the Titles page correctly stretches to fill +whatever height the mounted window offers via its own `LayoutPolicy` +(confirmed below). But retail never displays that full 600px canvas at +once outside of the Titles tab's own internal scroll: the shared +`gmPanelUI` host's content parent (`0x10000180`) is fixed at 300×362, +and 372 (362 + the 10px chrome bevel) is exactly the host's own +authored MinHeight — i.e., retail's Character/Skills window OPENS at +its own resize floor and can only be dragged taller, never shorter. +Pre-fix, `MountCharacter` left `Options.ContentHeight` unset, so it fell +back to `content.Width`/`content.Height` — the raw 600px canvas — giving +a stale 610px (600 + 10 chrome) mounted default that was never retail's +actual opening size and was 238px taller than the true floor. Fixed by +setting `Options.ContentHeight = 362f` explicitly (the same host +content-parent height this section already probed and cited, not a new +number). At the corrected 372px default, the authored page composition +(header 112px + list 160px + divider + footer) is the true 362px design; +the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px +list immediately, so the stat list's scrollbar is active from the moment +the window opens — this is retail-correct (see §S2 below for what +"active" means given the corrected `HideWhenDisabled` finding), not a +regression introduced by the fix. + ### 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`. +no code was needed to make the PAGE itself reflow. + +**CORRECTED (CT6 fix round, S3, 2026-08-25):** this section originally +went on to claim "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...), added in `CharacterTitlesController.Bind`." +That framing was wrong: both `0x10000532` and `0x10000539` author +`HasOriginalParentSize=true` (the field `DatWidgetFactory` gates +`LayoutPolicy` assignment on), confirmed both on the real installed DAT +and the committed fixture — `LayoutImporter`/`DatWidgetFactory` ALWAYS +assigns a real `LayoutPolicy` to these elements, so the `if +(listBox.LayoutPolicy is null)` branch never ran anywhere, not even in +the "synthetic/test layouts" case it was written to cover. It was dead +code, not a harmless no-op fallback. Deleted from +`CharacterTitlesController.Bind`; a new +`CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize` +pin asserts `HasOriginalParentSize` on both `0x10000532` and `0x10000539` +to guard the deletion against future DAT drift. + +### Correction to the scrollbar-visibility finding (S2, 2026-08-25) + +The CT6 landing notes (in the campaign plan ledger) claimed +`UiScrollbar`'s own `IsPresentationVisible`/`IsModelDisabled` "already +draw the correct full-track 'disabled' thumb when content fits +(`HideWhenDisabled` defaults false)." That had the authored default +BACKWARDS. Both `0x1000023E` (the shared Attributes/Skills list +scrollbar) and `0x10000533` (the Titles list's own scrollbar) author +property `0x79` (`HideWhenDisabled`) **TRUE** in the committed fixture — +verified directly against `tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json`, +property `"121"` (=0x79) carries `"BoolValue": true` on both elements. A +fitting list HIDES the bar entirely (`IsPresentationVisible` false); it +does not leave a full-track "disabled" thumb on screen. The CODE in +`CharacterStatController.RebuildActiveList` was already correct — +`.Visible = true` only keeps the bar bound in the tree, and +`IsPresentationVisible` is the actual show/hide computation — only the +description was wrong. Given the S4 correction above (mount default is +now the compact 372px floor), the practical consequence is: the stat +list's scrollbar is VISIBLE and interactive from the moment the window +opens (rows overflow at the default), and DISAPPEARS once the window is +grown enough that all rows fit — the opposite of what the uncorrected +description implied. ### A latent anchor-baseline bug this slice surfaced and fixed @@ -687,3 +783,15 @@ by `CharacterStatControllerTests 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. + +**CORRECTED (CT6 fix round, N5, 2026-08-25):** at initial landing, this +sentence was not actually true — the mounted 320/382/1010 clamp WAS an +inference (BLOCKER B1: the host's already chrome-inclusive values with a +second, redundant chrome inset composed on top). After the B1 fix +removes that composition, the mounted clamp is now literally the host's +own four probed values (310/310/372/1000) with zero arithmetic applied, +so the sentence holds for real. The S4 content-height default (362) is +likewise not a new inferred number — it is the same host content-parent +width/height (`0x10000180`, 300×362) this section already probed and +cited above, applied to `Options.ContentHeight` instead of being left +unset. 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 39e9c56a..3deadf1a 100644 --- a/docs/research/2026-08-25-campaign-ct-test-script.md +++ b/docs/research/2026-08-25-campaign-ct-test-script.md @@ -75,48 +75,61 @@ Useful ACE console helpers: title grants come from quests/admin — check 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. +§CT6, corrected by the CT6 fix round's BLOCKER B1): the resize clamp is +authored on the SHARED `gmPanelUI` host, not the character content +itself, and the host IS retail's own outer window frame — its authored +values are chrome-INCLUSIVE, not a content size our own chrome adds on +top of. Host authors **MinWidth=MaxWidth=310** (fixed width — no +horizontal Resizebar) and **MinHeight=372, MaxHeight=1000**. The +MOUNTED window's outer bounds are EXACTLY those same numbers: width +fixed **310px**, floor **372px**, ceiling **1000px** — no inset is +added on top (`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` +now tells the mount path this source already includes the bevel). +**Starting height:** the window OPENS at retail's authored default, +**372px** — its own resize floor. It cannot open any shorter; it can +only be dragged taller. 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. + stops shrinking at its authored floor (372px outer / the point where + further dragging has no visible effect) — since the window already + OPENS at that floor, this step should show no shrink at all (there + is no room below the default to shrink into). It does NOT collapse + arbitrarily small. Retail comparison: drag retail's own Character/ + Skills window down from its own default; it should likewise refuse + to shrink further immediately. 2. **Keep dragging down (grow).** PASS: the window keeps growing until - its authored ceiling (≈1010px outer) — same side-by-side comparison + its authored ceiling (1000px 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. + (372px) the 9 attribute/vital rows (180px content) OVERFLOW the + 160px list — the scrollbar is ACTIVE (visible + interactive) + IMMEDIATELY on open, not after shrinking. PASS: this is the owner's + item 2 fix — previously the scrollbar never appeared on Attributes + at all. Grow the window until the rows fit without scrolling: the + bar DISAPPEARS entirely (0x1000023E authors 0x79 hide-when-disabled + TRUE — a fitting list hides the bar, it does not leave a full-track + "disabled" thumb visible). Shrink back down and the bar reappears. +5. **Scrollbar hand-off, Skills tab.** Same immediate-overflow-at- + default check (a longer skills list only makes the overflow more + obvious); grow until it fits and confirm the bar disappears the same + way. +6. **Scrollbar hand-off, Titles tab.** With several earned titles, the + Titles list (authored 455px, inside the 575px page) is scroll-clipped + into the same 372px-default window and its own scrollbar + (`0x10000533`, also hide-when-disabled — fixture-verified) takes over + the same way: active when titles overflow, hidden when the window is + grown enough that they all fit. 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. +8. **Grow back restores.** Drag back down to the original default + (372px): the lists return to their default OVERFLOWING state + (scrollbar reactivates — this is the default, not "all rows fit") + 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`). diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index bf675617..550eb897 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -810,13 +810,18 @@ public static class CharacterStatController allRaise1, allRaise10, SetFooterSelected, iconDidResolve); } - // 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. + // Always BOUND for whichever tab is active — no per-tab visibility + // toggle is needed here. CT6 fix round (S2 correction): the + // scrollbar's own .Visible=true just keeps it in the tree; what + // actually shows or hides it on screen is UiScrollbar's + // IsPresentationVisible, and 0x1000023E authors 0x79 + // (HideWhenDisabled) TRUE (fixture-verified — the 121/0x79 + // property on the fixture's scrollbar element carries + // BoolValue=true). That means a fitting list HIDES the bar + // entirely, not a full-track "disabled" thumb left visible — the + // previous comment here had the authored default backwards. + // Content that overflows still makes the bar visible and + // interactive, same as always. if (skillScrollbar is not null) { skillScrollbar.Model = viewport.Scroll; diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs index bbdfe610..a4e7e912 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -183,17 +183,23 @@ public sealed class CharacterTitlesController : IDisposable // 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 + // 575px page — CT1 ground truth §3) shrinks/grows with the window + // the same way CharacterStatController's attribute/skill list does. + // CT6 fix round (S3 correction): the former Anchors fallback here + // ("if LayoutPolicy is null") was DEAD CODE — both 0x10000532 (this + // ListBox) and its page container 0x10000539 author + // HasOriginalParentSize=true in the real DAT (pinned by + // CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize), + // so LayoutImporter/DatWidgetFactory ALWAYS assigns a real + // LayoutPolicy to this element and the fallback branch never ran on + // either the installed DAT or the committed fixture. The actual + // reflow mechanism is that authored LayoutPolicy stretching with the + // mounted content's height — deleted rather than left as + // unreachable/misleading compatibility code. 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; + // 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. uint scrollbarElementId = listBox.ScrollbarElementId; UiElement? scrollbarElement = scrollbarElementId == 0 diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index d9b22a22..2db7d37f 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -43,9 +43,30 @@ public static class RetailWindowFrame /// /// Optional DAT element whose effective 0x3C..0x3F properties provide /// max-height, max-width, min-height, and min-width respectively. Explicit - /// option values win; wrapper chrome is added to DAT content constraints. + /// option values win; wrapper chrome is added to DAT content constraints + /// UNLESS is set. /// public ElementInfo? DatConstraintSource { get; init; } + + /// + /// True when is itself retail's own + /// OUTER window frame — chrome already included in its authored + /// 0x3C..0x3F values — rather than a content element the wrapper's own + /// chrome gets layered on top of. CT6 fix-round BLOCKER B1: the + /// Character window's resize clamp is authored on the shared + /// gmPanelUI host (0x100005FE, LayoutDesc + /// 0x2100006E) — that host IS retail's outer frame (5px bevel + + /// 300x362 content + 5px = 310x372), so its authored MinWidth/MinHeight/ + /// MaxWidth/MaxHeight ALREADY include the chrome our NineSlice wrapper + /// draws. Adding the wrapper's own chrome inset on top double-counts + /// the bevel and clamps the mounted window BELOW its own authored + /// minimum. Set true only when the DAT source element genuinely already + /// represents the complete outer frame, not a content root — the + /// default (false) preserves the normal content-plus-chrome behavior + /// every other window relies on. + /// + public bool DatConstraintSourceIsOuterFrame { get; init; } + public float? MinWidth { get; init; } public float? MinHeight { get; init; } public float? MaxWidth { get; init; } @@ -157,16 +178,36 @@ public static class RetailWindowFrame outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f); outerFrame.Visible = options.Visible; + int constraintInset = options.DatConstraintSourceIsOuterFrame ? 0 : inset; outerFrame.MinWidth = ResolveConstraint( - options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset); + options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, constraintInset); outerFrame.MinHeight = ResolveConstraint( - options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset); + options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, constraintInset); outerFrame.MaxWidth = Math.Max( outerFrame.MinWidth, - ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset)); + ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, constraintInset)); outerFrame.MaxHeight = Math.Max( outerFrame.MinHeight, - ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset)); + ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, constraintInset)); + + // Mount-time invariant (CT6 fix-round BLOCKER B1): a window must never + // open already violating its own clamp. This would have caught B1 at + // the first test run — the pre-fix Character mount computed outer + // bounds of 320/382/1010 (300x362 content + 10px chrome inset added + // TWICE) while its own MinWidth clamped to 320 but its actual outer + // WIDTH was only 310, silently sitting below its own minimum until + // RetailWindowManager.ResizeTo forcibly widened it despite ResizeX + // being false. + if (outerWidth < outerFrame.MinWidth || outerWidth > outerFrame.MaxWidth) + throw new InvalidOperationException( + $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer width " + + $"{outerWidth} is outside its own clamp [{outerFrame.MinWidth}, {outerFrame.MaxWidth}] " + + "— the window would open already violating its authored resize bounds."); + if (outerHeight < outerFrame.MinHeight || outerHeight > outerFrame.MaxHeight) + throw new InvalidOperationException( + $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer height " + + $"{outerHeight} is outside its own clamp [{outerFrame.MinHeight}, {outerFrame.MaxHeight}] " + + "— the window would open already violating its authored resize bounds."); // Capture the wrapper/content baseline at the mounted design extent now, // so a resize that occurs before the first draw uses the same margins as a diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 59f50533..6ef49d48 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4122,6 +4122,17 @@ public sealed class RetailUiRuntime : IDisposable // host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310 // MaxHeight=1000 (Min==Max width: no horizontal Resizebar is // authored, matching ResizeX=false below). + // + // CT6 fix round (2026-08-25, BLOCKER B1): host 0x100005FE is not a + // content element our NineSlice wrapper adds chrome to — it IS + // retail's own outer window frame (5px bevel + 300x362 content parent + // 0x10000180 + 5px = 310x372, verified by PanelHost_ + // AuthorsFixedWidthAndBottomOnlyResizeContract). Its authored + // 0x3C..0x3F values already include that bevel. Without + // DatConstraintSourceIsOuterFrame, RetailWindowFrame.ResolveConstraint + // added the wrapper's OWN 10px chrome inset on top — double-counting + // the bevel and clamping the mounted window's MinWidth to 320 while + // its actual outer width stayed 310, silently below its own minimum. ElementInfo? hostConstraint; lock (_bindings.Assets.DatLock) hostConstraint = LayoutImporter.ImportInfos( @@ -4137,11 +4148,25 @@ public sealed class RetailUiRuntime : IDisposable Chrome = RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, + // CT6 fix round (2026-08-25, S4 ruling): mount at retail's + // authored DEFAULT — the host's own content parent + // (0x10000180) is 300x362, not 0x2100002E's own 300x600 + // authoring canvas (which retail scroll-clips into that + // slot). 362 + the 10px NineSlice inset = 372, exactly the + // host's own authored MinHeight — matching the ground-truth + // finding that retail's Character/Skills window opens AT its + // resize floor and can only be dragged taller. The 9 + // attribute/vital rows (180px) overflow the resulting 160px + // list at this default; that is retail-correct, not a bug — + // the list's own scrollbar (0x1000023E, hide-when-disabled) + // activates immediately. + ContentHeight = 362f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, DatConstraintSource = hostConstraint, + DatConstraintSourceIsOuterFrame = true, 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 77737eaa..cdc613d1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -305,6 +305,38 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal("Ghosted", setDisplayButton.DefaultStateName); } + /// + /// CT6 fix round (S3, 2026-08-25): guards the deletion of + /// CharacterTitlesController.Bind's Anchors fallback + /// (if (listBox.LayoutPolicy is null) listBox.Anchors = ...) + /// against DAT drift. That fallback was proven UNREACHABLE — both the + /// Titles page container (0x10000539) and its ListBox + /// (0x10000532) author HasOriginalParentSize=true, the + /// field DatWidgetFactory gates real LayoutPolicy + /// assignment on (DatWidgetFactory.cs: if + /// (info.HasOriginalParentSize) e.LayoutPolicy = + /// CreateLayoutPolicy(info)), so LayoutPolicy is never + /// null for either element and the fallback branch never ran, on + /// either the installed DAT or the committed fixture. If a future + /// DAT revision ever authors either element WITHOUT + /// OriginalParentSize, this pin fails loudly — the signal to restore + /// a real fallback rather than silently losing Titles-list resize + /// behavior. + /// + [InstalledDatFact] + public void TitlesListAndPage_AuthorHasOriginalParentSize() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + + ElementInfo page = Assert.Single(Flatten(tree!), e => e.Id == 0x10000539u); + Assert.True(page.HasOriginalParentSize); + + ElementInfo listBox = Assert.Single(page.Children, c => c.Id == 0x10000532u); + Assert.True(listBox.HasOriginalParentSize); + } + /// /// The title row template (0x10000536 in LayoutDesc /// 0x2100005E) — a single-line text row, no icon column, 24px diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index eba50d8c..70d0913e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -2310,15 +2310,21 @@ 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))); - // 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). + // CT6 (2026-08-25): the shared scrollbar is now always BOUND + // (.Model set, .Visible = true), on Attributes as well as Skills — + // retail's authored gutter (0x1000023E) is reserved regardless of + // content, so no per-tab visibility toggle is needed. CT6 fix round + // (S2 correction): 0x1000023E authors 0x79 (HideWhenDisabled) TRUE + // (fixture-verified), so a FITTING list actually HIDES the bar + // entirely via IsPresentationVisible — it does not draw a full-track + // "disabled" thumb the way the previous wording here implied. This + // fixture mounts the raw 600px-tall content directly (no + // RetailWindowFrame.Mount, no S4 372px default), so the 9 + // attribute/vital rows (180px content) fit comfortably inside its + // 398px view and HasOverflow is false here — a shrunk window (or the + // real S4-corrected 372px mount default) is what flips it true and + // makes the bar presentation-visible (see the window-level resize + // test, which asserts IsPresentationVisible both ways). Assert.True( scrollbar.Visible, $"attributes-tab scrollbar: model={scrollbar.Model is not null}, " + @@ -2355,6 +2361,14 @@ public class CharacterStatControllerTests /// pattern already uses), matching /// production's actual wire-up in RetailUiRuntime.MountCharacter /// (which sources the SAME host element live from the DAT). + /// + /// CT6 fix round (2026-08-25, BLOCKER B1): the host source IS + /// retail's own outer frame — its authored 310/372/310/1000 are already + /// chrome-inclusive (5px bevel + 300x362 content + 5px = 310x372) — so + /// this test now sets + /// the same way production's MountCharacter does, and the mounted + /// clamp equals the host's four raw values EXACTLY, with no chrome inset + /// composed on top. /// [Fact] public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar() @@ -2384,6 +2398,7 @@ public class CharacterStatControllerTests ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, DatConstraintSource = HostConstraints(), + DatConstraintSourceIsOuterFrame = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }); @@ -2400,13 +2415,14 @@ public class CharacterStatControllerTests 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); + // CT6 fix round (BLOCKER B1): the host source IS the outer frame + // already (DatConstraintSourceIsOuterFrame = true above), so no + // chrome inset is composed on top — the mounted clamp is EXACTLY the + // DAT's own 310/372/310/1000. + Assert.Equal(310f, handle.OuterFrame.MinWidth); + Assert.Equal(310f, handle.OuterFrame.MaxWidth); + Assert.Equal(372f, handle.OuterFrame.MinHeight); + Assert.Equal(1000f, handle.OuterFrame.MaxHeight); float originalOuterHeight = handle.Height; float originalListHeight = list.Height; @@ -2418,9 +2434,9 @@ public class CharacterStatControllerTests "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. + // 372, not the requested value. handle.ResizeTo(handle.Width, 50f); - Assert.Equal(382f, handle.Height); + Assert.Equal(372f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); @@ -2430,6 +2446,12 @@ public class CharacterStatControllerTests Assert.True( scrollbar.Model!.HasOverflow, "9 rows (180px content) must overflow the shrunk view"); + // S2 correction: 0x1000023E authors 0x79 (HideWhenDisabled) TRUE — + // an overflowing list must show its scrollbar VISIBLE and + // interactive, not merely bound-but-invisible. + Assert.True( + scrollbar.IsPresentationVisible, + "an overflowing list's scrollbar must be presentation-visible (interactive), not hidden"); // The footer stays bottom-docked: same distance from the stat // layout's own bottom edge before and after the shrink. @@ -2437,15 +2459,21 @@ public class CharacterStatControllerTests Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2); // Request far above the authored maximum — the clamp must hold at - // 1010, not the requested value. + // 1000, not the requested value. handle.ResizeTo(handle.Width, 5000f); - Assert.Equal(1010f, handle.Height); + Assert.Equal(1000f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); Assert.False( scrollbar.Model!.HasOverflow, "growing well past the content height restores no-overflow"); + // S2 correction: HideWhenDisabled TRUE means a fitting list HIDES its + // scrollbar entirely — it does NOT leave a full-track "disabled" + // thumb visible. + Assert.False( + scrollbar.IsPresentationVisible, + "content that fits after growing large must HIDE the scrollbar (0x79 HideWhenDisabled), not show a disabled thumb"); // Growing back to the ORIGINAL authored size restores the original // list height and the no-overflow state. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 809452c1..07f7beb2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -576,11 +576,15 @@ public sealed class CharacterTitlesControllerTests "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.) + // (CT6 fix round, S3 correction: BOTH the Titles page container + // 0x10000539 AND its own ListBox 0x10000532 carry a real authored + // LayoutPolicy — HasOriginalParentSize=true on both, pinned by + // CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize + // — that already stretches with the mounted content's height. The + // reflow this test proves comes entirely from that authored + // LayoutPolicy; CharacterTitlesController.Bind sets no Anchors of + // its own on the ListBox, since the "if LayoutPolicy is null" + // fallback it used to carry was dead code and has been deleted.) handle.ResizeTo(handle.Width, 200f); ApplyAnchors(handle.OuterFrame); h.ListBox.ViewportForTest!.LayoutScrollableChildren(); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index 84af5098..b6a1a653 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -146,17 +146,25 @@ public sealed class RetailWindowFrameTests /// 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. + /// + /// CT6 fix round (2026-08-25, N4 rename): this test's ORIGINAL name + /// (NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds) + /// overclaimed. It mounts a content-shaped root (490×100, roughly chat's + /// own default extent) with a synthetic height-only constraint source + /// ( sets ONLY 0x3E/0x3C — no width bounds at + /// all) — it exercises the NineSlice wrapper's chrome-inset ARITHMETIC on + /// a content-shaped source, not chat's actual authored DAT contract. Chat + /// itself mounts with Chrome = RetailWindowChrome.Imported (its + /// own root already IS the complete window, chrome included — see + /// RetailUiRuntime.MountChat), so the real chat contract needs + /// ZERO chrome inset, the opposite of what this test's NineSlice + /// wrapping exercises. See + /// + /// below for the genuine chat-shaped pin (Chrome=Imported, chat's real + /// 300/100/2000/2000 four-sided constraint). /// [Fact] - public void NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds() + public void NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize() { var root = new UiRoot { Width = 1920, Height = 1080 }; var content = new UiPanel { Width = 490, Height = 100 }; @@ -168,7 +176,7 @@ public sealed class RetailWindowFrameTests NoTex, new RetailWindowFrame.Options { - WindowName = "chat", + WindowName = "chat-shaped", Chrome = RetailWindowChrome.NineSlice, Left = 10, Top = 20, @@ -191,6 +199,59 @@ public sealed class RetailWindowFrameTests Assert.Equal(500f, handle.Height); } + /// + /// CT6 fix round (2026-08-25, N4): the true chat-contract pin the + /// renamed test above no longer claims to be. Chat's real LayoutDesc + /// (0x2100006F, root 0x10000600) mounts with + /// Chrome = RetailWindowChrome.Imported — its own root already IS + /// the complete retail window, own dragbar/border/resize-grip children + /// included (RetailUiRuntime.MountChat) — and authors real + /// four-sided constraints, MinWidth=300, MinHeight=100, MaxWidth=2000, + /// MaxHeight=2000 + /// (CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints). + /// Because Imported chrome applies NO wrapper (inset = 0), the + /// mounted clamp must equal those four DAT values EXACTLY — no chrome + /// inset composed on top, the same "authored value governs verbatim" + /// contract CT6's BLOCKER B1 fix restored for the Character window's + /// chrome-inclusive host source. + /// + [Fact] + public void Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset() + { + var root = new UiRoot { Width = 1920, Height = 1080 }; + var content = new UiPanel { Width = 410, Height = 100 }; + var constraints = ChatConstraints(); + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "chat", + Chrome = RetailWindowChrome.Imported, + Left = 10, + Top = 440, + ResizeX = true, + ResizeY = true, + DatConstraintSource = constraints, + }); + + Assert.Equal(300f, handle.OuterFrame.MinWidth); + Assert.Equal(100f, handle.OuterFrame.MinHeight); + Assert.Equal(2000f, handle.OuterFrame.MaxWidth); + Assert.Equal(2000f, handle.OuterFrame.MaxHeight); + + // No chrome inset: the clamp must hold at EXACTLY the authored + // 100/2000 bounds, not 110/2010 the way a NineSlice-wrapped source + // would. + Assert.True(handle.ResizeTo(handle.Width, 5f)); + Assert.Equal(100f, handle.Height); + + Assert.True(handle.ResizeTo(handle.Width, 50000f)); + Assert.Equal(2000f, handle.Height); + } + [Fact] public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter() { @@ -312,4 +373,37 @@ public sealed class RetailWindowFrameTests info.States[UiStateInfo.DirectStateId] = direct; return info; } + + /// + /// Synthetic four-sided constraint matching chat's real authored DAT + /// values (CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints): + /// MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000. + /// + private static ElementInfo ChatConstraints() + { + var info = new ElementInfo(); + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x3Fu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 300, + }; + direct.Properties.Values[0x3Eu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 100, + }; + direct.Properties.Values[0x3Du] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 2000, + }; + direct.Properties.Values[0x3Cu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 2000, + }; + info.States[UiStateInfo.DirectStateId] = direct; + return info; + } }