CT7 gate regression (owner report): all MossTank plugin text vanished except button captions. A markup <label> authors position only, so UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip (UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing; markup buttons author w/h, which is why their captions survived. UiLabel now opts out of the self-clip — it is ClickThrough pure text whose real containment is its ancestors (the plugin panel/window, which are properly sized), the effective retail behavior for a text region whose box hugs its glyphs — and keeps a truthful box by measuring its current text each draw. Mechanism pin: an unsized label's subtree must render inside its sized parent (probe-child draw-capture test). Gate note recorded by the owner in the same round: the Titles-page divider IS visible inside the window in retail while scrolling — a retail quirk our clipped rendering now reproduces exactly. CT7 gate PASSED apart from this regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
425 lines
19 KiB
C#
425 lines
19 KiB
C#
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Campaign CT slice CT-GF1: mechanism-level tests for the client-wide retained-UI
|
|
/// ancestor clip (<see cref="UiElement.ClipsChildren"/>'s new default-true, porting
|
|
/// retail's <c>UIRegion::DrawHere @0x0069FA30</c> clip-rect-chain intersection) and its
|
|
/// one deliberate opt-out (<see cref="UiElement.ExpandsClipForPopup"/>, used by
|
|
/// <see cref="UiMenu"/>'s inline-drawn popup). The Titles-page divider gate repro lives
|
|
/// in <c>CharacterTitlesControllerTests</c> (the real owner-reported symptom); the chat
|
|
/// input-row regression pin lives in <c>ChatLayoutConformanceTests</c>. This file covers
|
|
/// the underlying mechanism directly with small synthetic trees.
|
|
/// </summary>
|
|
public sealed class UiAncestorClipTests
|
|
{
|
|
private sealed class TestElement : UiElement { }
|
|
|
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
|
{
|
|
public IGpuFrame? CurrentFrame => null;
|
|
}
|
|
|
|
private static (RecordingGpuDevice device, TextRenderer renderer, UiRenderContext ctx) MakeContext(
|
|
float w, float h)
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
|
renderer.Begin(new Vector2(w, h));
|
|
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
|
|
return (device, renderer, ctx);
|
|
}
|
|
|
|
private static bool AnyQuadAt(TextRenderer renderer, System.Func<float, float, bool> predicate)
|
|
{
|
|
foreach (var seg in renderer.DebugSpriteSegmentVerts)
|
|
{
|
|
for (int i = 0; i < seg.Verts.Count / 8; i++)
|
|
{
|
|
if (predicate(seg.Verts[i * 8], seg.Verts[i * 8 + 1]))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core mechanism, plain elements (no dat import involved): a child positioned
|
|
/// entirely outside its parent's [0,Width]x[0,Height] rect renders NOTHING — the
|
|
/// retail <c>UIRegion::DrawHere</c> "intersection empty -> skip the subtree" gate
|
|
/// (@0x0069FB8E). A sibling positioned INSIDE the parent still renders normally.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChildOutsideParentBounds_RendersNothing_SiblingInsideStillRenders()
|
|
{
|
|
var parent = new TestElement { Width = 50f, Height = 50f };
|
|
var outside = new UiSolidSpriteFill
|
|
{
|
|
Left = -100f, Top = -100f, Width = 20f, Height = 20f,
|
|
SpriteId = 7u,
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
var inside = new UiSolidSpriteFill
|
|
{
|
|
Left = 5f, Top = 5f, Width = 10f, Height = 10f,
|
|
SpriteId = 9u,
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
parent.AddChild(outside);
|
|
parent.AddChild(inside);
|
|
|
|
var (_, renderer, ctx) = MakeContext(200f, 200f);
|
|
parent.DrawSelfAndChildren(ctx);
|
|
|
|
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
|
|
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A child straddling the parent's edge is clipped to the visible sliver, not
|
|
/// culled outright and not drawn full-size — the intersected rect
|
|
/// <c>UIRegion::DrawHere</c> passes to <c>DrawSelf</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChildStraddlingParentEdge_ClipsToTheVisibleSliver()
|
|
{
|
|
var parent = new TestElement { Width = 50f, Height = 50f };
|
|
var straddling = new UiSolidSpriteFill
|
|
{
|
|
Left = 40f, Top = 10f, Width = 30f, Height = 10f, // spans x=[40,70), parent ends at 50
|
|
SpriteId = 3u,
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
parent.AddChild(straddling);
|
|
|
|
var (_, renderer, ctx) = MakeContext(200f, 200f);
|
|
parent.DrawSelfAndChildren(ctx);
|
|
|
|
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 3u);
|
|
float maxX = 0f;
|
|
for (int i = 0; i < seg.Verts.Count / 8; i++)
|
|
maxX = System.MathF.Max(maxX, seg.Verts[i * 8]);
|
|
Assert.True(maxX <= 50.01f, $"clipped quad's rightmost X ({maxX}) must not exceed the parent's edge (50)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hit-testing gets the SAME default: a point outside the parent's bounds never
|
|
/// reaches a child positioned there, even though the child's own local hit-test
|
|
/// would otherwise accept it (aligning HitTest with the new draw-clip default per
|
|
/// the CT-GF1 plan's point 4).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChildOutsideParentBounds_IsNeverHit()
|
|
{
|
|
var parent = new TestElement { Width = 50f, Height = 50f };
|
|
var outside = new TestElement { Left = -30f, Top = -30f, Width = 20f, Height = 20f };
|
|
parent.AddChild(outside);
|
|
|
|
UiElement? hit = parent.HitTest(-20f, -20f); // lands inside `outside`'s own local rect
|
|
|
|
Assert.Null(hit);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CT-GF1's one opt-out: <see cref="UiMenu"/>'s popup (drawn inline via
|
|
/// <c>OnDrawOverlay</c>) must keep escaping its owning window's ancestor clip —
|
|
/// retail's separate top-level popup region, see <see cref="UiElement.ExpandsClipForPopup"/>'s
|
|
/// doc comment and the AD-113 divergence register row. A small window (80x18, the
|
|
/// menu button's own size) sits well below the canvas top; the popup opens UPWARD
|
|
/// (the class default) and must still render there, well outside the window's own
|
|
/// [0,80]x[0,18] rect.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UiMenuPopup_StillRendersOutsideItsOwningWindow_AncestorClipDoesNotCutItOff()
|
|
{
|
|
var root = new TestElement { Width = 200f, Height = 200f };
|
|
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
|
|
var menu = new UiMenu
|
|
{
|
|
Width = 80f,
|
|
Height = 18f,
|
|
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
root.AddChild(window);
|
|
window.AddChild(menu);
|
|
|
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
|
|
Assert.True(menu.IsOpen);
|
|
|
|
// Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does
|
|
// in production, routing overlay draws to a SEPARATE buffer with no debug
|
|
// accessor) — the clip mechanism under test is layer-agnostic, so drawing to
|
|
// the normal buffer keeps DebugSpriteSegmentVerts usable here.
|
|
var (_, renderer, ctx) = MakeContext(200f, 200f);
|
|
root.DrawOverlays(ctx);
|
|
|
|
// The popup opens upward (bottom touches the button's top, y=0), so its
|
|
// absolute screen top is window.Top(150) minus its own outer height — well
|
|
// above window.Top. Assert at least one popup quad renders strictly above the
|
|
// owning window's own top edge, i.e. outside window's [0,18] local rect.
|
|
bool escapedAboveWindow = AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f);
|
|
Assert.True(
|
|
escapedAboveWindow,
|
|
"expected the open UiMenu popup to render above its owning window's top edge");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Companion negative check: with the SAME geometry but the popup left CLOSED, no
|
|
/// quad renders above the window at all — proving the escape above is specifically
|
|
/// about the OPEN popup's content, not a blanket unclipped draw for the whole menu.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UiMenuClosed_NothingRendersAboveItsOwningWindow()
|
|
{
|
|
var root = new TestElement { Width = 200f, Height = 200f };
|
|
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
|
|
var menu = new UiMenu
|
|
{
|
|
Width = 80f,
|
|
Height = 18f,
|
|
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
root.AddChild(window);
|
|
window.AddChild(menu);
|
|
Assert.False(menu.IsOpen);
|
|
|
|
// Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does
|
|
// in production, routing overlay draws to a SEPARATE buffer with no debug
|
|
// accessor) — the clip mechanism under test is layer-agnostic, so drawing to
|
|
// the normal buffer keeps DebugSpriteSegmentVerts usable here.
|
|
var (_, renderer, ctx) = MakeContext(200f, 200f);
|
|
root.DrawOverlays(ctx);
|
|
|
|
Assert.False(AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's hover tooltip is the OTHER content this codebase draws "regardless of
|
|
/// tree position" (see <see cref="RetailTooltipPresenter"/>'s class doc): it mounts
|
|
/// its popup as an ordinary <see cref="UiRoot"/> CHILD (a sibling of every window),
|
|
/// not nested inside whatever widget triggered it — so unlike <see cref="UiMenu"/>'s
|
|
/// popup, it needs no <see cref="UiElement.ExpandsClipForPopup"/> opt-out; it was
|
|
/// never inside the triggering window's ancestor-clip subtree to begin with. This
|
|
/// pins that structural invariant survives CT-GF1: hovering a target buried inside
|
|
/// a tiny (20x20) window still mounts and DRAWS the tooltip popup, unclipped by that
|
|
/// window's own bounds.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RetailTooltip_StillRendersOutsideATinyAncestorWindow_BecauseItMountsAtRootLevel()
|
|
{
|
|
const uint popupRootId = 0x900u;
|
|
const uint textChildId = 0x901u;
|
|
const uint popupLayoutDid = 0x21000041u;
|
|
const uint popupBgSprite = 42u;
|
|
|
|
ImportedLayout BuildPopup()
|
|
{
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = popupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = textChildId,
|
|
};
|
|
rootInfo.StateMedia[""] = (popupBgSprite, 1);
|
|
var textInfo = new ElementInfo
|
|
{
|
|
Id = textChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
|
|
};
|
|
return LayoutImporter.BuildFromInfos(
|
|
rootInfo, new[] { textInfo }, id => (id, 8, 8), null);
|
|
}
|
|
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var presenter = new RetailTooltipPresenter(root, (_, _) => BuildPopup());
|
|
|
|
// A tiny "window" ancestor (20x20) hosting the hover target as a nested child.
|
|
// If the tooltip were drawn from INSIDE this subtree, CT-GF1's new default
|
|
// ancestor clip would cut it off — the popup's mouse-anchored position (32px
|
|
// offset per PositionAtMouse) lands well outside a 20x20 rect.
|
|
var window = new TestElement { Left = 5f, Top = 5f, Width = 20f, Height = 20f };
|
|
var target = new TestElement
|
|
{
|
|
Left = 2f, Top = 2f, Width = 10f, Height = 10f,
|
|
AuthoredTooltipEnabled = true,
|
|
AuthoredTooltipText = "Rotate left.",
|
|
AuthoredTooltipRootElementId = popupRootId,
|
|
AuthoredTooltipLayoutDid = popupLayoutDid,
|
|
};
|
|
window.AddChild(target);
|
|
root.AddChild(window);
|
|
|
|
root.OnMouseMove(10, 10); // inside `target`, well inside the 20x20 window
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
// Mounted as a UiRoot SIBLING of `window`, not nested inside it.
|
|
UiElement popup = Assert.Single(root.Children, c => c != window);
|
|
Assert.Same(root, popup.Parent);
|
|
|
|
var (_, renderer, ctx) = MakeContext(800f, 600f);
|
|
root.DrawSelfAndChildren(ctx);
|
|
|
|
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == popupBgSprite);
|
|
|
|
presenter.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// CT-GF1 fix round (S4): pins the input half of the popup-escape mechanism the
|
|
/// draw-only tests above only cover visually. A menu mounted inside a SHORT owning
|
|
/// window on a REAL <see cref="UiRoot"/>, opened, has its popup's first row land
|
|
/// well ABOVE the window's own [0,Height) local rect — the escaped region. An
|
|
/// ordinary top-down <see cref="UiRoot.OnMouseDown"/> walk would reject a point
|
|
/// there before ever reaching the menu: the owning `window`'s own
|
|
/// <see cref="UiElement.ClipsChildren"/> default (true, CT-GF1) rejects any
|
|
/// out-of-bounds local coordinate in <see cref="UiElement.HitTest"/> BEFORE
|
|
/// recursing into its children, so the menu's own out-of-bounds
|
|
/// <c>OnHitTest</c> union is never consulted. <c>UiRoot</c>'s <c>PopupHit</c>
|
|
/// routing (#374) is what rescues this: while a popup is registered active, a
|
|
/// press/scroll/<see cref="UiRoot.WantsMouse"/> query is tested directly against
|
|
/// the popup element itself, bypassing the ancestor walk entirely.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EscapedPopupClick_ReachesTheMenu_ThroughAShortOwningWindow()
|
|
{
|
|
var root = new UiRoot { Width = 200f, Height = 200f };
|
|
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
|
|
string? picked = null;
|
|
var menu = new UiMenu
|
|
{
|
|
Width = 80f,
|
|
Height = 18f,
|
|
OpenUpward = true,
|
|
RowsPerColumn = 1, // one row -> a small, exactly-known popup rect
|
|
Items = new[] { new UiMenu.MenuItem("Row", (object?)"row") },
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
menu.OnSelect = p => picked = p as string;
|
|
window.AddChild(menu);
|
|
root.AddChild(window);
|
|
|
|
// Open the popup via a REAL click on the button face (screen space).
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 155);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 155);
|
|
Assert.True(menu.IsOpen);
|
|
|
|
// OuterW = ColumnWidth(191) + 2*Border(5) = 201; OuterH = 1*RowHeight(17) +
|
|
// 2*Border(5) = 27. Opens upward from the button's own screen top (150), so
|
|
// the popup spans screen Y = 150-27=123 .. 150 -- strictly above the owning
|
|
// window's own [150,168) rect, i.e. the escaped region.
|
|
const int rowScreenY = 135; // inside [123,150)
|
|
const int rowScreenX = 60; // inside [10,211)
|
|
Assert.True(rowScreenY < 150, "sanity: the row must sit above the window's own top edge");
|
|
|
|
// Without PopupHit, a plain top-down walk at this point would be rejected by
|
|
// `window`'s own ancestor-clip bounds check before ever reaching the menu --
|
|
// proven directly against the SAME tree/geometry, no popup registered.
|
|
Assert.Null(root.Pick(rowScreenX, rowScreenY));
|
|
|
|
// WantsMouse must recognize the escaped popup region too (S4), so a game
|
|
// action does not fire underneath an open dropdown.
|
|
root.OnMouseMove(rowScreenX, rowScreenY);
|
|
Assert.True(root.WantsMouse, "WantsMouse must see the escaped popup through PopupHit");
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, rowScreenX, rowScreenY);
|
|
root.OnMouseUp(UiMouseButton.Left, rowScreenX, rowScreenY);
|
|
|
|
Assert.Equal("row", picked);
|
|
Assert.False(menu.IsOpen);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CT-GF1 fix round (N2): the empty-clip subtree cull added to
|
|
/// <see cref="UiElement.DrawSelfAndChildren"/> (retail's <c>var_24</c> gate,
|
|
/// <c>UIRegion::DrawHere @0x0069FB8E</c>) early-outs once the intersected clip
|
|
/// goes empty -- e.g. a window whose own Width has collapsed to zero.
|
|
/// <see cref="UiElement.DrawOverlays"/> is a wholly SEPARATE traversal (the
|
|
/// second pass <see cref="UiRoot.Draw"/> runs after the main one) that shares no
|
|
/// clip-stack state with the cull above, so an open <see cref="UiMenu"/> popup
|
|
/// nested inside such a window must keep drawing there regardless.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UiMenuPopup_StillDraws_EvenWhenItsOwningWindowIsFullyClippedAway()
|
|
{
|
|
var root = new TestElement { Width = 200f, Height = 200f };
|
|
var window = new TestElement { Left = 10f, Top = 150f, Width = 0f, Height = 18f };
|
|
var menu = new UiMenu
|
|
{
|
|
Width = 80f,
|
|
Height = 18f,
|
|
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
|
|
SpriteResolve = id => (id, 8, 8),
|
|
};
|
|
root.AddChild(window);
|
|
window.AddChild(menu);
|
|
|
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
|
|
Assert.True(menu.IsOpen);
|
|
|
|
var (_, renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
// Main pass: `window`'s own zero-width clip is empty -- the cull skips its
|
|
// whole subtree (including the menu's own button face), so nothing draws.
|
|
root.DrawSelfAndChildren(ctx);
|
|
Assert.Empty(renderer.DebugSpriteSegmentVerts);
|
|
|
|
// Overlay pass: the SAME open popup still renders -- proves the cull above is
|
|
// scoped to DrawSelfAndChildren and never reaches DrawOverlays.
|
|
root.DrawOverlays(ctx);
|
|
Assert.NotEmpty(renderer.DebugSpriteSegmentVerts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CT7 gate regression (2026-08-25): the MossTank plugin's markup
|
|
/// labels lost ALL text under the completed self-clip — a markup
|
|
/// <label> authors position only, so UiLabel's box was 0x0 and the
|
|
/// element's own clip cropped every glyph. UiLabel now opts out of the
|
|
/// self-clip (its ancestors clip — the retail containment for a text
|
|
/// region whose box hugs its glyphs) and keeps a truthful measured box.
|
|
/// Button captions never regressed because markup buttons author w/h.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UnsizedMarkupLabel_InsideASizedPanel_DoesNotSelfClipItsSubtree()
|
|
{
|
|
var (device, renderer, ctx) = MakeContext(800f, 600f);
|
|
var panel = new UiPanel
|
|
{
|
|
Left = 50f, Top = 50f, Width = 200f, Height = 100f,
|
|
BackgroundColor = default, BorderColor = default,
|
|
};
|
|
// Exactly what MarkupDocument builds for <label x=".." y=".."> —
|
|
// position only, no Width/Height (the box is degenerate at draw
|
|
// time). Pre-fix, UiLabel inherited the ambient self-clip and its
|
|
// 0x0 push cropped everything it painted to nothing — the MossTank
|
|
// vanished-text regression. The mechanism pin below hangs a
|
|
// sprite-drawing probe child under the label: with the self-clip
|
|
// the probe cannot render; with UiLabel's opt-out it must.
|
|
var label = new UiLabel { Left = 10f, Top = 10f, Text = "MossTank status line" };
|
|
var probe = new UiPanel
|
|
{
|
|
Left = 0f, Top = 0f, Width = 100f, Height = 10f,
|
|
BackgroundSprite = 42u,
|
|
SpriteResolve = id => id == 42u ? (42u, 8, 8) : (0u, 0, 0),
|
|
BackgroundColor = default, BorderColor = default,
|
|
};
|
|
label.AddChild(probe);
|
|
panel.AddChild(label);
|
|
|
|
panel.DrawSelfAndChildren(ctx);
|
|
|
|
Assert.True(
|
|
AnyQuadAt(renderer, (x, y) => x >= 60f && x <= 160f && y >= 60f && y <= 70f),
|
|
"the unsized label self-clipped its subtree away (MossTank regression)");
|
|
// The box became truthful (fallback-measured without a font), not degenerate.
|
|
Assert.True(label.Width > 0f && label.Height > 0f,
|
|
$"label box stayed degenerate: {label.Width}x{label.Height}");
|
|
}
|
|
}
|