Fixes the CT7 gate finding: on the Titles tab, the authored divider 0x10000530 escapes the Character window above its top edge at the CT6-correct 372px mounted default (computed Y ~ -178, matching the owner's screenshot). Retail clips child rendering to the intersected ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the element's screen Box2D plus a SmartArray<Box2D> of inherited clip rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82), and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw walk rendered children unclipped by default, so any authored element relying on clipping -- this divider, and the chat input row at small window sizes (the owner's earlier "text input sticks out on resize" report) -- became a visible artifact. Mechanism (element-level, reusing the existing clip-rect-stack infrastructure in UiRenderContext.PushClip/PopClip): - UiElement.ClipsChildren now defaults to TRUE for every element (was an opt-in used only by UiScrollablePanel/UiItemList). Each element's children draw AND hit-test clipped to the intersection of its own rect with the inherited ancestor clip; an element positioned outside its parent's box silently disappears, matching retail's non-empty-intersection gate. HitTest's existing early bounds check already implemented this shape for ClipsChildren=true elements -- flipping the default aligns hit-testing with the new draw-clip default in one property, per the plan's own point 4. - UiElement.ExpandsClipForPopup (default false) is the one opt-out: retail spawns a menu popup as a SEPARATE top-level region (UIElement_Menu::MakePopup), clipped only by the screen; acdream draws UiMenu's popup inline from the owning button in a second traversal (OnDrawOverlay, pre-existing -- its own doc comment already says "regardless of this element's position in the tree"). DrawOverlays now resets the accumulated clip to unbounded (UiRenderContext.PushClipUnbounded, sharing the existing clip stack) for exactly the OnDrawOverlay call of an opted-in element. UiMenu overrides ExpandsClipForPopup=>true, paired with ClipsChildren=>false so its own out-of-bounds OnHitTest union (the popup occupies ly<0 or ly>=Height depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the ONLY OnDrawOverlay override client-wide, so it is the only element needing ExpandsClipForPopup. RetailTooltipPresenter's popup and UiRoot's drag ghost both already escape structurally -- the tooltip mounts as an ordinary UiRoot CHILD (sibling of every window, clipped only by the canvas), and the drag ghost is drawn directly by UiRoot outside the tree entirely -- neither needed a code change, both are covered by new tests proving the invariant. UiResizeGrip and UiNineSlicePanel's frame/bevel draw entirely within their own [0,Width]x[0,Height] (grip flush at the window's own edges; the window's own Width/Height already represents the OUTER frame including its 5px bevel, so its ClipsChildren push already covers the frame's own content children correctly -- no negative insets found). UiScrollbar draws entirely within its own bounds (confirmed by reading OnDraw). Hit-testing: aligned with the new default via the single ClipsChildren flip (see above); UiMenu's own opt-out override keeps its popup hit-test union working, verified by the full UiMenuTests suite staying green. Divergence register: AD-113 filed for the ExpandsClipForPopup adaptation (inline popup drawing vs retail's separate top-level region). Fixed two pre-existing test-harness gaps the new default surfaced (both real bugs in the harnesses, not workarounds around the fix): - ChatLayoutConformanceTests' bottom-right-grip grow test read a STALE (pre-shrink) grip screen position because it drove two resize gestures back-to-back with no intervening Draw pass -- the only place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame draws every tick, so production never hits this; the test now inserts a real DrawSelfAndChildren pass between the two gestures, matching a real frame boundary. - VendorUiControllerTests' hand-built Items/Buying/Selling page containers were left at their bare 0x0 UiElement default (the harness never runs a real DAT-driven layout pass) -- harmless before ancestor clipping existed, but now hides every child of an unsized page. Sized them to the window's own content root, matching production's shape (a tab page fills the window body). Tests (all confirmed as genuine regression pins by temporarily reverting the relevant default/override and observing the exact predicted failure, then reverting back): - CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate repro against the real character_2100002E.json fixture through RetailWindowFrame.Mount at the CT6 372px default -- the divider renders nothing (computed Y ~ -173, matching the owner's ~-178); growing the window to 600px renders it at its authored spot. - ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad RendersOutsideTheWindowRect: no input-row quad escapes the chat window rect at three small sizes (300x100 sanity control, 120x40/80x30 genuine pre-fix overflow -- verified failing without the fix at Y=38/55 past the window edge). - UiAncestorClipTests (new file): the core mechanism against plain synthetic elements (culled-outside / clipped-at-the-edge / hit-test parity), UiMenu's popup escaping a tiny owning window (and staying clipped while closed), and the tooltip's structural immunity (mounts as a UiRoot sibling, unaffected by a tiny ancestor window). Verification: full solution build green; hermetic suite green (--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live& Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux& Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure", 14,000+ tests across every project); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure, 205+34+3+172 tests). CharacterTitlesControllerTests' existing suite and the full UiMenuTests/UiScrollbarTests suites are unaffected. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by this change and deliberately left out of this commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
274 lines
12 KiB
C#
274 lines
12 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();
|
|
}
|
|
}
|