fix(CT6): fix round — chrome-inclusive host clamp (BLOCKER B1) + 372px mount default

Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:

BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, 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 only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.

S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.

S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.

S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.

N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.

N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.

CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.

Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 02:53:57 +02:00
parent ec50455a63
commit 996cd73675
11 changed files with 552 additions and 120 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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`).

View file

@ -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;

View file

@ -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

View file

@ -43,9 +43,30 @@ public static class RetailWindowFrame
/// <summary>
/// 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 <see cref="DatConstraintSourceIsOuterFrame"/> is set.
/// </summary>
public ElementInfo? DatConstraintSource { get; init; }
/// <summary>
/// True when <see cref="DatConstraintSource"/> 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
/// <c>gmPanelUI</c> host (<c>0x100005FE</c>, LayoutDesc
/// <c>0x2100006E</c>) — 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.
/// </summary>
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

View file

@ -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,

View file

@ -305,6 +305,38 @@ public sealed class CharacterPanelLiveDatTests
Assert.Equal("Ghosted", setDisplayButton.DefaultStateName);
}
/// <summary>
/// CT6 fix round (S3, 2026-08-25): guards the deletion of
/// <c>CharacterTitlesController.Bind</c>'s Anchors fallback
/// (<c>if (listBox.LayoutPolicy is null) listBox.Anchors = ...</c>)
/// against DAT drift. That fallback was proven UNREACHABLE — both the
/// Titles page container (<c>0x10000539</c>) and its ListBox
/// (<c>0x10000532</c>) author <c>HasOriginalParentSize=true</c>, the
/// field <c>DatWidgetFactory</c> gates real <c>LayoutPolicy</c>
/// assignment on (<c>DatWidgetFactory.cs</c>: <c>if
/// (info.HasOriginalParentSize) e.LayoutPolicy =
/// CreateLayoutPolicy(info)</c>), so <c>LayoutPolicy</c> 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.
/// </summary>
[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);
}
/// <summary>
/// The title row template (<c>0x10000536</c> in LayoutDesc
/// <c>0x2100005E</c>) — a single-line text row, no icon column, 24px

View file

@ -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 <see cref="RetailWindowFrameTests"/> already uses), matching
/// production's actual wire-up in <c>RetailUiRuntime.MountCharacter</c>
/// (which sources the SAME host element live from the DAT).
///
/// <para>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 <see cref="RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame"/>
/// the same way production's <c>MountCharacter</c> does, and the mounted
/// clamp equals the host's four raw values EXACTLY, with no chrome inset
/// composed on top.</para>
/// </summary>
[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<UiScrollablePanel>(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.

View file

@ -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();

View file

@ -146,17 +146,25 @@ public sealed class RetailWindowFrameTests
/// enforced identically by <c>RetailWindowManager.ResizeTo</c> (this
/// test), the interactive drag path in <c>UiRoot</c>, and the persisted-
/// geometry restore clamp in <c>RetailWindowLayoutPersistence.Apply</c>.
/// Chat (<c>0x2100006F</c>) was the FIRST window to author real DAT
/// min/max (min 300/100, max 2000/2000 per
/// <c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>);
/// this test proves an interactive-shaped <c>ResizeTo</c> 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.
///
/// <para>CT6 fix round (2026-08-25, N4 rename): this test's ORIGINAL name
/// (<c>NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds</c>)
/// overclaimed. It mounts a content-shaped root (490×100, roughly chat's
/// own default extent) with a synthetic height-only constraint source
/// (<see cref="Constraints"/> 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 <c>Chrome = RetailWindowChrome.Imported</c> (its
/// own root already IS the complete window, chrome included — see
/// <c>RetailUiRuntime.MountChat</c>), so the real chat contract needs
/// ZERO chrome inset, the opposite of what this test's NineSlice
/// wrapping exercises. See
/// <see cref="Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset"/>
/// below for the genuine chat-shaped pin (Chrome=Imported, chat's real
/// 300/100/2000/2000 four-sided constraint).</para>
/// </summary>
[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);
}
/// <summary>
/// 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
/// (<c>0x2100006F</c>, root <c>0x10000600</c>) mounts with
/// <c>Chrome = RetailWindowChrome.Imported</c> — its own root already IS
/// the complete retail window, own dragbar/border/resize-grip children
/// included (<c>RetailUiRuntime.MountChat</c>) — and authors real
/// four-sided constraints, MinWidth=300, MinHeight=100, MaxWidth=2000,
/// MaxHeight=2000
/// (<c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>).
/// Because Imported chrome applies NO wrapper (<c>inset = 0</c>), 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.
/// </summary>
[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;
}
/// <summary>
/// Synthetic four-sided constraint matching chat's real authored DAT
/// values (<c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>):
/// MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.
/// </summary>
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;
}
}