fix(ui): retained-UI rect outlines composite in painter order, not above every window

Owner report: with the MossTank plugin window BEHIND the inventory window,
the plugin's "Force Buff"/"Cancel Force Buff" button border outlines drew on
top of the inventory paperdoll. Only outlines leaked; fills did not.

Root cause: TextRenderer composited three buckets per layer — submission-
ordered sprite segments, then ALL untextured DrawRect quads (_rectBuf), then
debug text (Flush/DrawLayer). UiRenderContext.DrawRect forwarded into that
separate rect bucket, which always flushed AFTER every sprite segment
regardless of submission order. UiRenderContext.DrawRectOutline is four
DrawRect calls, so every BorderColor outline in the retained UI (UiPanel,
UiMarkupList) composited above every window's sprite content drawn after it,
instead of only the windows actually painted before it.

Fix: UiRenderContext.DrawRect now forwards to DrawFill — the same untextured
SPRITE-bucket segment DrawFill already used for panel backgrounds — so
DrawRectOutline inherits real painter/submission order. Audited the only
other DrawRect caller (UiMeter's bg-then-bar fill, which already relied on
same-call submission order and is unaffected) and the only other
DrawRectOutline callers (UiPanel, UiMarkupList, both routed through the same
fixed chokepoint). TextRenderer.DrawRect/DrawRectOutline and the _rectBuf
bucket are left in place (not deleted) with updated doc comments noting no
caller in src/ outside TextRenderer itself reaches them anymore; a future
non-retained-UI caller (e.g. a debug overlay) could still want that
"always on top of sprites" behavior. Added TextRenderer.DebugRectVertexCount
(test-only) and a failing-first regression test
(UiRectOutlinePainterOrderTests) that builds a real UiPanel border behind a
later-added opaque sprite and asserts submission order.

No retail-divergence register row: this is a renderer ordering bug, not a
documented retail behavior deviation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 16:24:34 +02:00
parent 47eb2d575b
commit 761a7519f1
3 changed files with 199 additions and 30 deletions

View file

@ -0,0 +1,112 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Owner-reported symptom: with a plugin window BEHIND the inventory window,
/// the plugin button's BORDER outline drew on top of the inventory's paperdoll
/// even though the inventory window was added (and so composited) LATER. Root
/// cause — <see cref="TextRenderer"/> used to composite in three buckets per
/// layer: submission-ordered sprite segments, THEN every untextured
/// <see cref="TextRenderer.DrawRect"/> quad (regardless of when it was
/// submitted), THEN debug text. <see cref="UiRenderContext.DrawRect"/> (and so
/// <see cref="UiRenderContext.DrawRectOutline"/>, which every
/// <c>BorderColor</c> outline in the retained UI — <see cref="UiPanel"/>,
/// <see cref="UiMarkupList"/> — goes through) forwarded into that rect
/// bucket, so every outline composited above every window's sprite content
/// drawn after it, no matter the actual paint order.
///
/// <para>
/// This pins the mechanism directly against the real <see cref="UiPanel"/>
/// draw path: a back panel with a visible border, then a front sibling
/// (added AFTER — later paint order) that draws an opaque sprite over the
/// same screen rect. The back panel's border must composite UNDER the front
/// sprite, exactly like <see cref="UiRenderContext.DrawFill"/> already does
/// for panel backgrounds.
/// </para>
/// </summary>
public sealed class UiRectOutlinePainterOrderTests
{
private sealed class TestElement : UiElement { }
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (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 (renderer, ctx);
}
[Fact]
public void BackPanelBorder_ComposesUnderAFrontSpriteAddedLater()
{
var root = new TestElement { Width = 200f, Height = 200f };
// Back window: added FIRST, drawn first (lower paint order). Its
// border is the ONLY thing it draws — background left transparent so
// any leaked geometry in the assertions below can only be the border.
var backPanel = new UiPanel
{
Left = 0f, Top = 0f, Width = 100f, Height = 60f,
BackgroundColor = default,
BorderColor = new Vector4(1f, 1f, 1f, 1f),
BorderThickness = 2f,
};
// Front window: added AFTER (drawn later / higher paint order) and
// covers the SAME screen rect with an opaque sprite — the inventory
// window's paperdoll, standing in for MossTank's button border.
const uint frontTexture = 55u;
var frontSprite = new UiSolidSpriteFill
{
Left = 0f, Top = 0f, Width = 100f, Height = 60f,
SpriteId = frontTexture,
SpriteResolve = id => (id, 8, 8),
};
root.AddChild(backPanel);
root.AddChild(frontSprite);
var (renderer, ctx) = MakeContext(200f, 200f);
root.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
int frontIndex = -1;
for (int i = 0; i < segs.Count; i++)
{
if (segs[i].Texture == frontTexture) { frontIndex = i; break; }
}
Assert.True(frontIndex >= 0, "the front sprite must be recorded in the sprite bucket");
int outlineIndex = -1;
for (int i = 0; i < segs.Count; i++)
{
if (segs[i].Texture == UiTextureTableHandle.None) { outlineIndex = i; break; }
}
Assert.True(
outlineIndex >= 0,
"the back panel's border must route through the painter-order sprite bucket " +
"(an untextured segment, UiTextureTableHandle.None), not the separate rect bucket");
Assert.True(
outlineIndex < frontIndex,
"the back panel's border segment must be submitted BEFORE the front sprite's " +
"segment so it composites underneath it, matching the actual paint order");
// No outline geometry may land in TextRenderer's separate untextured
// rect bucket at all: that bucket always flushes AFTER every sprite
// segment regardless of submission order, which is exactly the bug —
// an outline drawn there would win against every window painted after it.
Assert.Equal(0, renderer.DebugRectVertexCount);
}
}