diff --git a/src/AcDream.App/UI/Layout/EffectsUiController.cs b/src/AcDream.App/UI/Layout/EffectsUiController.cs
index 3ab84179..dba834b5 100644
--- a/src/AcDream.App/UI/Layout/EffectsUiController.cs
+++ b/src/AcDream.App/UI/Layout/EffectsUiController.cs
@@ -101,6 +101,19 @@ public sealed class EffectsUiController : IRetainedPanelController
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
host.AddChild(list);
+ // #412-class: this list is created HERE, after Build, so its
+ // fill-anchor baseline would be captured lazily on its first
+ // ApplyAnchor -- which lands AFTER the host has already been
+ // resized by the restored window size in that same frame. The
+ // capture then measures a bottom margin of (hostH - authoredH)
+ // and ComputeAnchoredRect preserves it forever, pinning this list
+ // at its AUTHORED height inside a taller host: rows past that
+ // height are culled and the rest of the list paints as empty
+ // background. Capturing NOW, while Width/Height still exactly
+ // equal the host's, makes the margins (0,0,0,0) so the list
+ // tracks the host at every later size. Same fix, same reason, as
+ // UiTemplateListBox's own viewport seed.
+ list.CaptureCurrentAnchorBaseline();
}
return new EffectsUiController(
layout,
diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
index a4f52b15..f22a1c06 100644
--- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
+++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
@@ -211,6 +211,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
componentHost.AddChild(componentList);
+ // #412-class: this component list is created HERE, after Build, so its
+ // fill-anchor baseline would be captured lazily on its first
+ // ApplyAnchor -- which lands AFTER the host has already been
+ // resized by the restored window size in that same frame. The
+ // capture then measures a bottom margin of (hostH - authoredH)
+ // and ComputeAnchoredRect preserves it forever, pinning this component list
+ // at its AUTHORED height inside a taller host: rows past that
+ // height are culled and the rest of the list paints as empty
+ // background. Capturing NOW, while Width/Height still exactly
+ // equal the host's, makes the margins (0,0,0,0) so the list
+ // tracks the host at every later size. Same fix, same reason, as
+ // UiTemplateListBox's own viewport seed.
+ componentList.CaptureCurrentAnchorBaseline();
}
return new SpellbookWindowController(
diff --git a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
index 23fefebf..f588c917 100644
--- a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
@@ -314,4 +314,59 @@ public sealed class EffectsUiControllerTests
return found;
return null;
}
+
+ private static void ApplyLayoutPass(UiElement parent)
+ {
+ foreach (UiElement child in parent.Children)
+ {
+ child.ApplyAnchor(parent.Width, parent.Height);
+ ApplyLayoutPass(child);
+ }
+ }
+
+ ///
+ /// #412-class: the effects list must track its authored host at EVERY size,
+ /// including when the very first anchor pass runs after the window has
+ /// already been restored to a different size.
+ ///
+ ///
+ /// The real DAT list element is a UiTemplateListBox, not a
+ /// UiItemList, so the controller creates the item list itself and
+ /// attaches it — a lazily-created child whose fill-anchor baseline is
+ /// captured on first use. Every other test here supplies a synthetic
+ /// UiItemList as the list element and therefore never exercises that path,
+ /// which is why this shipped: the list stayed pinned at its authored 249px
+ /// inside a taller host, and everything below that painted as empty
+ /// background.
+ ///
+ [Fact]
+ public void EffectsList_TracksItsHostHeight_EvenWhenFirstLayoutFollowsAResize()
+ {
+ var table = SpellTable.LoadFromReader(new StringReader(
+ "Spell ID,Name,Flags [Hex]\n41,Boon,0x4\n"));
+ var spellbook = new Spellbook(table);
+ ImportedLayout layout = FixtureLoader.LoadPositiveEffects();
+
+ using EffectsUiController controller = EffectsUiController.Bind(
+ layout, spellbook, positive: true, () => 0d, NoTex, id => id,
+ Templates(), "SELECT A SPELL")!;
+
+ UiElement host = layout.FindElement(EffectsUiController.ListId)!;
+ UiItemList list = host.Children.OfType().Single();
+
+ // No layout pass has run yet — exactly the state a restored window is in
+ // when its persisted size is applied before the first draw frame.
+ layout.Root.Height = 660f;
+ ApplyLayoutPass(layout.Root);
+ Assert.Equal(host.Height, list.Height);
+
+ // ...and it keeps tracking in both directions afterwards.
+ layout.Root.Height = 200f;
+ ApplyLayoutPass(layout.Root);
+ Assert.Equal(host.Height, list.Height);
+
+ layout.Root.Height = 660f;
+ ApplyLayoutPass(layout.Root);
+ Assert.Equal(host.Height, list.Height);
+ }
}
diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs
index a30e43a5..c54b98c8 100644
--- a/tools/LayoutDump/Program.cs
+++ b/tools/LayoutDump/Program.cs
@@ -44,6 +44,20 @@ if (root is null)
Console.WriteLine($"layout 0x{ids[0]:X8}");
Print(root, 0);
+int resizeAt = Array.IndexOf(args, "--resize");
+if (resizeAt >= 0 && resizeAt + 2 < args.Length)
+{
+ // Reproduce a window resize exactly, without a running client: retail's
+ // raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640) is a
+ // pure function of the authored rects and the new parent size, which is
+ // what UiElement.ApplyAnchor feeds it every frame.
+ int rw = int.Parse(args[resizeAt + 1]);
+ int rh = int.Parse(args[resizeAt + 2]);
+ Console.WriteLine();
+ Console.WriteLine($"resized to {rw}x{rh}:");
+ PrintResized(root, UiPixelRect.FromPositionAndSize(0, 0, rw, rh), 0);
+}
+
if (args.Contains("--built"))
{
// What the importer actually PRODUCES, next to what the dat authored.
@@ -56,6 +70,32 @@ if (args.Contains("--built"))
}
return 0;
+void PrintResized(ElementInfo e, UiPixelRect parentRect, int depth)
+{
+ string pad = new(' ', depth * 2);
+ Console.WriteLine(
+ $"{pad}0x{e.Id:X8} type={e.Type,-10} "
+ + $"x={parentRect.X0,6} y={parentRect.Y0,6} "
+ + $"w={parentRect.Width,6} h={parentRect.Height,6}");
+
+ foreach (ElementInfo child in e.Children)
+ {
+ var authored = UiPixelRect.FromPositionAndSize(
+ (int)child.X, (int)child.Y, (int)child.Width, (int)child.Height);
+ var originalParent = child.HasOriginalParentSize
+ ? UiPixelRect.FromPositionAndSize(
+ 0, 0, (int)child.OriginalParentWidth, (int)child.OriginalParentHeight)
+ : UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height);
+
+ UiPixelRect next = UiLayoutPolicy.Apply(
+ child.Left, child.Top, child.Right, child.Bottom,
+ authored, originalParent, authored,
+ UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height));
+
+ PrintResized(child, next, depth + 1);
+ }
+}
+
void PrintBuilt(UiElement e, int depth)
{
string pad = new(' ', depth * 2);