feat(CT-GF1): port retail ancestor-clip to the retained UI tree

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>
This commit is contained in:
Erik 2026-08-25 06:29:15 +02:00
parent 6561d08fa8
commit 989f665214
8 changed files with 627 additions and 6 deletions

File diff suppressed because one or more lines are too long

View file

@ -544,11 +544,51 @@ public abstract class UiElement
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>
/// When true, descendant drawing and hit-testing are clipped to this element's
/// local bounds. Scrollable listboxes use this so edge rows can remain visible
/// at arbitrary pixel offsets without painting or receiving input outside the viewport.
/// Whether descendant drawing and hit-testing are clipped to this element's
/// local bounds. THIS IS THE DEFAULT (true) FOR EVERY ELEMENT — CT-GF1 port
/// of retail's ancestor-clip chain: <c>UIRegion::DrawHere @0x0069FA30</c> takes
/// the element's screen <c>Box2D</c> plus a <c>SmartArray&lt;Box2D&gt;</c> of
/// inherited clip rects, intersects them (the min/max clamp loop
/// @0x0069FAA7..0x0069FB82), and draws — <c>EraseSelf</c>/<c>DrawChildren</c>/
/// <c>DrawSelf</c> all receive the intersected rect — ONLY when the intersection
/// is non-empty (the <c>var_24</c> gate @0x0069FB8E). An element positioned
/// outside its parent's box therefore silently disappears in retail, exactly
/// like <see cref="UiRenderContext.PushClip"/>/<see cref="UiRenderContext.PopClip"/>
/// (already wrapping the child-draw and child-hit-test walks below) now does for
/// every element by default, not just the scrollable listboxes that opted in
/// before this default flipped (owner gate finding: the Titles page's authored
/// divider 0x10000530 escaped the Character window at the CT6-correct 372px
/// mounted default — retail clips it away; acdream drew it floating above the
/// window).
///
/// <para>
/// Override to <see langword="false"/> ONLY for a widget that must draw or accept
/// input beyond its own bounds by deliberate design — today just
/// <see cref="UiMenu"/>, whose popup (and its own out-of-bounds
/// <c>OnHitTest</c> override) stands in for retail's separate top-level popup
/// region; see <see cref="ExpandsClipForPopup"/> for the drawing half of that
/// opt-out and the divergence register row it cites.
/// </para>
/// </summary>
protected virtual bool ClipsChildren => false;
protected virtual bool ClipsChildren => true;
/// <summary>
/// True when this element's <see cref="OnDrawOverlay"/> content must ignore the
/// standard ancestor clip chain that <see cref="ClipsChildren"/> now threads through
/// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE
/// top-level regions (<c>UIElement_Menu::MakePopup</c>), so only the SCREEN clips
/// them — never an intervening window or panel's own client rect. Ours draws a
/// popup INLINE from its owning widget instead of reparenting to a new root (see
/// <see cref="OnDrawOverlay"/>'s own doc comment — that second traversal already
/// exists so popups composite "regardless of this element's position in the
/// tree"), so without this escape hatch the new default clip would wrongly cut off
/// a popup that legitimately extends outside its owning window — e.g. a dropdown
/// opened near the bottom of a short window. Default false (ordinary overlay
/// content, if any is ever added beyond <see cref="UiMenu"/>, stays clipped like
/// everything else). See the divergence register row this property's introducing
/// commit adds for the seam it stands in for.
/// </summary>
protected virtual bool ExpandsClipForPopup => false;
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
@ -658,7 +698,19 @@ public abstract class UiElement
ctx.PushAlpha(Opacity);
try
{
OnDrawOverlay(ctx);
// ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever
// ancestor clip the walk down to this element accumulated — see the
// property's own doc comment for the retail-parity rationale.
if (ExpandsClipForPopup)
{
ctx.PushClipUnbounded();
try { OnDrawOverlay(ctx); }
finally { ctx.PopClip(); }
}
else
{
OnDrawOverlay(ctx);
}
if (_children.Count > 0)
{
bool clipsChildren = ClipsChildren;

View file

@ -396,6 +396,31 @@ public sealed class UiMenu : UiElement
/// must NOT be built (an invisible label child would intercept the button click).</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// CT-GF1 opt-out: <see cref="UiElement.ClipsChildren"/>'s new client-wide default
/// (true) also gates <see cref="UiElement.HitTest"/>'s early "am I even inside my
/// own bounds" check — which would return null for every popup click before ever
/// reaching <see cref="OnHitTest"/>'s own out-of-bounds union below (the popup
/// occupies <c>ly &lt; 0</c> when it opens upward, or <c>ly &gt;= Height</c> when it
/// opens downward — see <see cref="PopupTop"/>'s doc). UiMenu has no real dat
/// children (<see cref="ConsumesDatChildren"/> is true), so this override changes
/// nothing about child drawing/hit-testing; it exists purely to keep this element's
/// OWN out-of-bounds popup region reachable, pairing with
/// <see cref="ExpandsClipForPopup"/> below for the drawing half of the same escape.
/// </summary>
protected override bool ClipsChildren => false;
/// <summary>
/// CT-GF1: the popup drawn in <see cref="OnDrawOverlay"/> is retail's stand-in for a
/// SEPARATE top-level region (<c>UIElement_Menu::MakePopup</c>) — see
/// <see cref="UiElement.ExpandsClipForPopup"/>'s own doc comment for the full
/// rationale and the divergence register row it cites. Without this, the new
/// default ancestor clip would cut off a popup that legitimately opens outside its
/// owning window (e.g. a short chat window's channel dropdown, which draws its rows
/// ABOVE the button and can extend past the window's own top edge).
/// </summary>
protected override bool ExpandsClipForPopup => true;
protected override void OnDraw(UiRenderContext ctx)
{
var resolve = SpriteResolve;

View file

@ -115,6 +115,21 @@ public sealed class UiRenderContext
_clipStack.RemoveAt(_clipStack.Count - 1);
}
/// <summary>
/// Discard every inherited clip rect for the duration of one overlay draw —
/// the escape hatch <see cref="UiElement.ExpandsClipForPopup"/> uses so a popup
/// drawn inline from its owning widget (see that property's doc comment for the
/// retail-parity rationale) is not wrongly clipped by the ancestor chain the
/// CT-GF1 default clip (<see cref="UiElement.ClipsChildren"/>) now threads through
/// every other element. Shares <see cref="PopClip"/>'s stack, so pair the two
/// exactly like <see cref="PushClip"/>.
/// </summary>
public void PushClipUnbounded()
{
_clipStack.Add(_clip);
_clip = null;
}
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;

View file

@ -1,4 +1,7 @@
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;
using AcDream.Runtime;
@ -611,6 +614,150 @@ public sealed class CharacterTitlesControllerTests
}
}
// ── CT-GF1 ancestor-clip gate repro ─────────────────────────────────
/// <summary>
/// OWNER GATE FINDING (screenshots on file, Campaign CT slice CT-GF1):
/// on the Titles tab, the page's authored divider 0x10000530 (300x9,
/// authored Y=60 in the 575px page, top-edge mode 2 = bottom-anchored at
/// 515px from the page bottom) escapes the window at the CT6-correct
/// 372px mounted default — the page is only ~337px tall there, so the
/// divider's bottom-anchor math computes a NEGATIVE Y and renders ABOVE
/// the window entirely. Retail clips this away
/// (<c>UIRegion::DrawHere @0x0069FA30</c>'s ancestor-clip rect
/// intersection, non-empty gate @0x0069FB8E); acdream drew it floating
/// above the window before this fix. Sibling divider 0x10000534
/// (authored Y=550) has the same shape but lands harmlessly at this
/// size — both share sprite 0x06001420, which is why the assertions
/// below key on Y-RANGE (this divider's own resolved screen position),
/// not texture.
///
/// Mounts the real fixture through the same <see cref="RetailWindowFrame"/>
/// production shape as <c>RetailUiRuntime.MountCharacter</c> (372px
/// default: ContentHeight=362f + the 10px NineSlice inset), switches to
/// the REAL Titles tab via <see cref="CharacterStatController"/>'s own
/// click handler (not a manual <c>Visible</c> poke — the CT3 tab-switch
/// closure this file's own <c>CharacterTabs_UseImportedChromeWithout...</c>
/// sibling test already exercises), and draws through a
/// <see cref="RecordingGpuDevice"/>. Unlike <see cref="BindWithEarnedTitles"/>'s
/// harness (which resolves every sprite to texture 0 for the OTHER
/// Titles tests in this file — sufficient for their geometry/wiring
/// assertions), this test resolves real non-zero textures so
/// <c>UiDatElement.OnDraw</c> actually queues quad geometry to inspect.
/// </summary>
[Fact]
public void TitlesPage_Divider_ClipsAwayAtTheCT6Default_AndAppearsWhenTheWindowGrowsTaller()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null);
CharacterStatController.Bind(
layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8));
var titlesTab = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabTitlesId));
Assert.NotNull(titlesTab.OnClick);
titlesTab.OnClick!(); // the REAL tab-switch path — flips TitlesPage.Visible
UiElement divider = UiElement.FindDescendant(layout.Root, 0x10000530u)!;
Assert.NotNull(divider);
UiElement siblingDivider = UiElement.FindDescendant(layout.Root, 0x10000534u)!;
Assert.NotNull(siblingDivider);
var screen = new UiRoot { Width = 1600f, Height = 1200f };
RetailWindowHandle handle = RetailWindowFrame.Mount(
screen,
layout.Root,
id => (id, 8, 8),
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
Left = 0f,
Top = 0f,
// CT6's own corrected default: the host's content parent is
// 300x362, not 0x2100002E's raw 300x600 authoring canvas.
ContentHeight = 362f,
MinWidth = 310f,
MaxWidth = 310f,
MinHeight = 372f,
MaxHeight = 1000f,
ResizeX = false,
ResizeY = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
Assert.Equal(372f, handle.Height); // the CT6-correct mounted default
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(screen.Width, screen.Height));
var ctx = new UiRenderContext(renderer, new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
// At the 372px default the divider's computed Y must be negative
// (above the window) — the owner's reported Y≈-178 shape.
Vector2 dividerAtDefault = divider.ScreenPosition;
Assert.True(
dividerAtDefault.Y + divider.Height <= 0f,
"expected the Titles divider to compute a Y above the window at the 372px " +
$"default (owner-reported ≈-178); got {dividerAtDefault.Y}");
// Nothing at all may render meaningfully above the window's own top
// edge (Y=0 itself is the window's own top border/frame, not "above
// the window") — the exact shape of the owner's screenshot finding.
AssertNoQuadCoversY(renderer, -10_000f, -1f);
// Grow the window taller. A real frame draws every tick, which is
// what reflows a bottom-anchored child against its parent's CURRENT
// size (UiElement.ApplyAnchor / LayoutPolicy.Apply run only from
// DrawSelfAndChildren) — two passes, matching the CT6 sibling test's
// own raw-edge-LayoutPolicy "policies settle" pattern above.
handle.OuterFrame.Height = 600f;
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
Vector2 dividerGrown = divider.ScreenPosition;
Assert.True(
dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f,
"expected the Titles divider to land inside the grown window at its authored " +
$"spot; got {dividerGrown.Y}");
AssertQuadCoversY(renderer, dividerGrown.Y, dividerGrown.Y + divider.Height);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static void AssertNoQuadCoversY(TextRenderer renderer, float yLo, float yHi)
{
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vy = seg.Verts[i * 8 + 1];
Assert.False(
vy > yLo - 0.01f && vy < yHi + 0.01f,
$"unexpected quad vertex at Y={vy} inside the clipped-away range " +
$"[{yLo},{yHi}] (texture {seg.Texture})");
}
}
}
private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi)
{
bool found = renderer.DebugSpriteSegmentVerts.Any(seg =>
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vy = seg.Verts[i * 8 + 1];
if (vy >= yLo - 0.5f && vy <= yHi + 0.5f) return true;
}
return false;
});
Assert.True(found, $"expected at least one quad in Y range [{yLo},{yHi}]");
}
// ── Lifecycle ───────────────────────────────────────────────────────
[Fact]

View file

@ -603,6 +603,23 @@ public class ChatLayoutConformanceTests
Assert.Equal(390f, handle.Width);
Assert.Equal(100f, handle.Height);
// CT-GF1: a real frame draws every tick, which is what reflows an
// anchored child's Left/Top against its parent's CURRENT size
// (UiElement.ApplyAnchor runs only from DrawSelfAndChildren) — so by
// the time a player's next click lands, the grip is already
// repositioned for the just-shrunk window. This test drives the resize
// directly without an intervening render, so without this draw pass the
// grip's ScreenPosition below stays at its PRE-shrink (now stale, wider)
// anchor and lands outside the shrunk window's own bounds — UiElement's
// new default ancestor clip (ClipsChildren) then refuses the press
// before it ever reaches the grip. Matches a real frame boundary, not a
// workaround for the clip.
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(root.Width, root.Height));
var drawCtx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height));
root.DrawSelfAndChildren(drawCtx);
// Now grow from the shrunken state — this is the reported-broken direction.
var brGripAfterShrink = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
var gs2 = brGripAfterShrink.ScreenPosition;
@ -732,6 +749,82 @@ public class ChatLayoutConformanceTests
$"input ends {input.Left + input.Width} past send start {send.Left}");
}
/// <summary>
/// CT-GF1 regression pin — companion to
/// <see cref="ResizingTheWindow_KeepsTheInputRowInsideIt"/> above, which
/// only proves the input row's Left/Width GEOMETRY stays inside the
/// window: that test's <c>LayoutImporter.Build</c> call resolves every
/// sprite through <see cref="NoTex"/> (texture 0), and
/// <c>UiDatElement.OnDraw</c>'s own <c>tex == 0</c> guard means nothing
/// ever reaches a quad — exactly why the owner's "text input sticks out
/// on resize" report was previously unreproducible in a fixture. This
/// test resolves REAL non-zero textures (same <c>id =&gt; (id, 8, 8)</c>
/// pattern as <c>MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_
/// NotJustResolvesSpriteFile</c> above) and draws the whole mounted
/// window through a <see cref="RecordingGpuDevice"/> at small sizes, then
/// asserts every emitted quad's vertices stay inside the window's own
/// [0,width]x[0,height] rect. The mechanism CT-GF1 ports
/// (<c>UiElement.ClipsChildren</c>'s new client-wide default,
/// retail's <c>UIRegion::DrawHere @0x0069FA30</c> ancestor-clip
/// intersection) is what makes this true now — confirmed a real
/// regression pin (not vacuous) by temporarily reverting the default:
/// the 120x40/80x30 cases fail without the fix (a quad renders ~15-38px
/// past the window's bottom edge, the input row's authored ~72px extent
/// no longer fitting a window shrunk below the ~100px it was designed
/// for) and pass with it; 300x100 is the "still comfortably fits, sanity"
/// control case.
/// </summary>
[Theory]
[InlineData(300f, 100f)]
[InlineData(120f, 40f)]
[InlineData(80f, 30f)]
public void ResizingTheWindowSmall_NoInputRowQuadRendersOutsideTheWindowRect(float width, float height)
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement window = layout.FindElement(0x10000600u)!;
var root = new UiRoot { Width = 800f, Height = 600f };
root.AddChild(window);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(root.Width, root.Height));
var ctx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
window.Width = width;
window.Height = height;
window.ResetAnchorCapture();
// Two frames — same "raw-edge LayoutPolicy needs a settle pass" reasoning
// as the geometry sibling test above.
renderer.Begin(new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
renderer.Begin(new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
float windowLeft = window.ScreenPosition.X;
float windowTop = window.ScreenPosition.Y;
const float Slop = 0.5f;
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vx = seg.Verts[i * 8];
float vy = seg.Verts[i * 8 + 1];
Assert.True(
vx >= windowLeft - Slop && vx <= windowLeft + width + Slop
&& vy >= windowTop - Slop && vy <= windowTop + height + Slop,
$"quad vertex ({vx},{vy}) escapes the {width}x{height} chat window rect " +
$"[{windowLeft},{windowTop}]-[{windowLeft + width},{windowTop + height}] " +
$"(texture {seg.Texture})");
}
}
}
private static void ApplyLayoutPassLocal(UiElement parent)
{
foreach (var child in parent.Children)

View file

@ -274,6 +274,20 @@ public sealed class VendorUiControllerTests
root.AddChild(ItemsPage);
root.AddChild(BuyingPage);
root.AddChild(SellingPage);
// CT-GF1: UiElement.ClipsChildren now defaults to true (retail's
// UIRegion::DrawHere ancestor-clip port) — a page container's
// CHILDREN are unreachable by draw or hit-test once the container's
// own Width/Height clips them away. This hand-built harness never ran
// a real DAT-driven layout pass, so these bare TestElement pages were
// left at their 0x0 default; that was harmless before this default
// flipped (nothing clipped, so a 0x0 "page" still let its children
// draw/hit-test anywhere) but now hides every child of an unsized
// page, matching production's shape (a tab page fills the window
// body below the tab strip) — not a workaround, just giving the
// hand-built fixture the geometry a real mounted page always has.
ItemsPage.Width = root.Width; ItemsPage.Height = root.Height;
BuyingPage.Width = root.Width; BuyingPage.Height = root.Height;
SellingPage.Width = root.Width; SellingPage.Height = root.Height;
ItemsPage.AddChild(ItemList);
ItemsPage.AddChild(ItemScrollbar);
ItemsPage.AddChild(TypeMenu);

View file

@ -0,0 +1,274 @@
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();
}
}