feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract
CT6 (Campaign CT slice 6): the resize clamp source is the SHARED gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's own root and not the Character/Skills slot 0x1000018E either — live probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are direct children of the host, not the content parent. Decomp chain: UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...), stashing drag state on that parent; UIElement::MouseResizeElement @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F) off that same element every mouse-move. RetailUiRuntime.MountCharacter now imports the host element and passes it as RetailWindowFrame.Options.DatConstraintSource, matching the existing MountSideVitals pattern. CharacterStatController.RebuildActiveList now wraps BOTH the Attributes and Skills tabs' rows in the same UiScrollablePanel viewport (previously only Skills got one; Attributes rows had no clipping/scrolling and the shared scrollbar was force-hidden — owner report 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. This surfaced and fixed a real #372/#412-class anchor-baseline bug: the viewport's Left|Top|Bottom anchor was capturing its baseline margins lazily on its own first ApplyAnchor call, which happens AFTER the ListBox has already grown from its raw DAT height to its mounted height, permanently capping the viewport short on every later resize. Fixed with an eager CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox .Viewport's own lazy getter. CharacterTitlesController.Bind gained the same defensive Anchors = Left|Top|Bottom fallback for the Titles ListBox that CharacterStatController already had (a no-op on the real DAT — both the Titles page and its ListBox already carry a real authored LayoutPolicy that stretches correctly). Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight, set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read identically by interactive drag, RetailWindowManager.ResizeTo, and RetailWindowLayoutPersistence's restore clamp. No gaps found; no register row (every number is a live-probed authored DAT value or a structural correctness fix, nothing inferred). Tests: CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar, CharacterTitlesControllerTests .TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips, RetailWindowFrameTests .NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds (shared-mechanism regression pin), CharacterPanelLiveDatTests .PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat pin). Existing attribute-row tests updated from list.Children to Descendants(list) for the new nested-viewport shape (the pattern skill rows already needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4cbbdaf4bf
commit
ec50455a63
10 changed files with 741 additions and 74 deletions
|
|
@ -754,40 +754,73 @@ public static class CharacterStatController
|
|||
currentAttributeRows.Clear();
|
||||
currentSkillRows.Clear();
|
||||
|
||||
if (activeTab[0] == CharacterStatTab.Attributes)
|
||||
// CT6 (2026-08-25): both tabs now stack their rows inside a
|
||||
// UiScrollablePanel viewport child of statList — the SAME
|
||||
// shrink-and-scroll contract the Skills tab already had.
|
||||
// UiScrollablePanel.LayoutScrollableChildren (called every
|
||||
// OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN
|
||||
// current Height every frame; the viewport's Anchors
|
||||
// (Left|Top|Bottom, a child of statList) track statList's live
|
||||
// Height as the window resizes, so Scroll.HasOverflow flips live
|
||||
// with no extra wiring. Previously only Skills got this
|
||||
// treatment — Attributes added rows directly to statList with no
|
||||
// clipping/scrolling and the shared scrollbar was force-hidden,
|
||||
// which is the "we never show the scrollbar on Attributes" gap
|
||||
// (owner report item 2) and the reason a shrunk window could not
|
||||
// show the overflowing attribute/vital rows at all.
|
||||
bool isSkills = activeTab[0] == CharacterStatTab.Skills;
|
||||
float contentW = isSkills
|
||||
? SkillViewportWidth(statList, skillScrollbar)
|
||||
: RowContentWidth(statList);
|
||||
var viewport = new UiScrollablePanel
|
||||
{
|
||||
if (skillScrollbar is not null)
|
||||
{
|
||||
skillScrollbar.Model = null;
|
||||
skillScrollbar.Visible = false;
|
||||
}
|
||||
currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel,
|
||||
allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
|
||||
activeListEntries.AddRange(currentAttributeRows);
|
||||
Left = 0f,
|
||||
Top = 0f,
|
||||
Width = contentW,
|
||||
Height = statList.Height,
|
||||
LineHeight = (int)RowHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
};
|
||||
statList.AddChild(viewport);
|
||||
// #372-class fix (same mechanism as UiTemplateListBox.Viewport's
|
||||
// own lazy getter): RebuildActiveList runs during Bind, BEFORE
|
||||
// the window's first anchor pass ever reflows statList up from
|
||||
// its raw DAT-authored height (160px) to its actual mounted
|
||||
// height. Left to its lazy default, viewport's Left|Top|Bottom
|
||||
// anchor would capture its baseline margins on ITS OWN first
|
||||
// ApplyAnchor call — which happens AFTER statList has already
|
||||
// grown — measuring a bogus non-zero bottom margin (e.g.
|
||||
// "398 grown list − 160 stale viewport = 238px margin") that then
|
||||
// permanently caps the viewport 238px short on every future
|
||||
// resize. Capturing NOW, while viewport.Height still exactly
|
||||
// equals statList's own CURRENT (pre-reflow, zero-margin)
|
||||
// height, gives a true (0,0,0,0) baseline that then correctly
|
||||
// full-stretches on every later resize.
|
||||
viewport.CaptureCurrentAnchorBaseline();
|
||||
activeListEntries.Add(viewport);
|
||||
|
||||
if (isSkills)
|
||||
{
|
||||
BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
|
||||
allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
|
||||
}
|
||||
else
|
||||
{
|
||||
float contentW = SkillViewportWidth(statList, skillScrollbar);
|
||||
var viewport = new UiScrollablePanel
|
||||
{
|
||||
Left = 0f,
|
||||
Top = 0f,
|
||||
Width = contentW,
|
||||
Height = statList.Height,
|
||||
LineHeight = (int)RowHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
};
|
||||
statList.AddChild(viewport);
|
||||
activeListEntries.Add(viewport);
|
||||
currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel,
|
||||
allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
|
||||
}
|
||||
|
||||
BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
|
||||
allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
|
||||
|
||||
if (skillScrollbar is not null)
|
||||
{
|
||||
skillScrollbar.Model = viewport.Scroll;
|
||||
skillScrollbar.Visible = true;
|
||||
}
|
||||
// Always bound + visible for whichever tab is active — retail's
|
||||
// authored scrollbar gutter (0x1000023E, 281..297 within the
|
||||
// 300px list) is reserved regardless of content, and UiScrollbar
|
||||
// itself already draws the correct full-track "disabled" thumb
|
||||
// when Model.HasOverflow is false (HideWhenDisabled defaults to
|
||||
// false — see UiScrollbar.IsPresentationVisible), so no
|
||||
// per-tab visibility toggle is needed here any more.
|
||||
if (skillScrollbar is not null)
|
||||
{
|
||||
skillScrollbar.Model = viewport.Scroll;
|
||||
skillScrollbar.Visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,18 @@ public sealed class CharacterTitlesController : IDisposable
|
|||
// (CharacterManagementUiController.cs:463 sets its own row height
|
||||
// the same way for the same reason).
|
||||
listBox.LineHeight = 24;
|
||||
// CT6 (2026-08-25): the Titles list (authored H=455 inside the
|
||||
// 575px page — CT1 ground truth §3) must shrink/grow with the
|
||||
// window the same way CharacterStatController's attribute/skill
|
||||
// list does — same compatibility-anchor pattern, only applied when
|
||||
// the imported element carries no authored edge policy of its own
|
||||
// (an authored LayoutPolicy always wins). UiTemplateListBox's own
|
||||
// internal viewport (created lazily inside AddItemFromTemplateList)
|
||||
// already carries the #372-class eager-baseline-capture fix, so
|
||||
// once the ListBox itself reflows, its scrollbar (bound below) picks
|
||||
// up the new content/view relationship for free.
|
||||
if (listBox.LayoutPolicy is null)
|
||||
listBox.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
|
||||
|
||||
uint scrollbarElementId = listBox.ScrollbarElementId;
|
||||
UiElement? scrollbarElement = scrollbarElementId == 0
|
||||
|
|
|
|||
|
|
@ -4109,6 +4109,24 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
TitleTemplateResolver,
|
||||
_bindings.Character.SendSetTitle);
|
||||
|
||||
// CT6 (2026-08-25 live probe): 0x2100002E's own content root
|
||||
// (0x10000227) authors NO min/max (CT1 correction (a)) — retail's
|
||||
// actual resize target is the SHARED gmPanelUI host (0x100005FE in
|
||||
// LayoutDesc 0x2100006E). UIElement_Resizebar::StartMouseResizing
|
||||
// @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...)
|
||||
// and stashes the drag state (m_DragStart*/m_currentBorder) ON THAT
|
||||
// PARENT; UIElement::MouseResizeElement @0x00461130 then reads
|
||||
// GetAttribute_Int(this, 0x3C..0x3F) off the SAME element every
|
||||
// mouse-move. The bottom Resizebar (0x10000660) is a DIRECT CHILD of
|
||||
// the host, not of the content parent (0x10000180) — confirmed live:
|
||||
// host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310
|
||||
// MaxHeight=1000 (Min==Max width: no horizontal Resizebar is
|
||||
// authored, matching ResizeX=false below).
|
||||
ElementInfo? hostConstraint;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
hostConstraint = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, 0x2100006Eu, 0x100005FEu);
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
layout.Root,
|
||||
|
|
@ -4123,6 +4141,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ResizeY = true,
|
||||
ResizableEdges = ResizeEdges.Bottom,
|
||||
ConstrainResizeToParent = true,
|
||||
DatConstraintSource = hostConstraint,
|
||||
Visible = false,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue