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

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