diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs
index c66882f4..4f147686 100644
--- a/src/AcDream.App/Rendering/TextRenderer.cs
+++ b/src/AcDream.App/Rendering/TextRenderer.cs
@@ -42,8 +42,10 @@ namespace AcDream.App.Rendering;
/// Vulkan backend gets from the pass description for free.
///
/// Uses per-bucket ring allocations flushed in up to three draw calls per
-/// layer, to avoid a per-vertex "use texture" flag. Rects are drawn first so
-/// text sits on top of background panels.
+/// layer, to avoid a per-vertex "use texture" flag. The untextured-rect bucket
+/// ('s _rectBuf) composites AFTER every sprite
+/// segment regardless of submission order — see that method's doc comment for
+/// why the retained UI (AcDream.App.UI) no longer feeds it.
///
public sealed class TextRenderer : IDisposable
{
@@ -155,6 +157,16 @@ public sealed class TextRenderer : IDisposable
internal (int VertexCount, float Alpha) DebugTextBuffer
=> (_textVerts, _textBuf.Count > 0 ? _textBuf[7] : 0f);
+ ///
+ /// Test-only: vertex count queued in the NORMAL-layer untextured rect bucket
+ /// ('s _rectBuf — see that method's doc comment for why
+ /// the retained UI no longer feeds it). Used to prove a fix stays fixed: nothing
+ /// should land here for retained-UI content, since this bucket always flushes AFTER
+ /// every sprite segment regardless of submission order (see ).
+ /// AcDream.App.Tests-only via InternalsVisibleTo.
+ ///
+ internal int DebugRectVertexCount => _rectVerts;
+
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
@@ -250,7 +262,29 @@ public sealed class TextRenderer : IDisposable
OverlayMode = false;
}
- /// Draw a filled rectangle in screen pixel space.
+ ///
+ /// Draw a filled rectangle into the SEPARATE untextured-rect bucket
+ /// (_rectBuf/_overlayRectBuf), which always
+ /// composites AFTER every sprite segment in the layer, REGARDLESS of submission
+ /// order — the opposite of painter order.
+ ///
+ ///
+ /// The retained UI (AcDream.App.UI) does not call this. It used to,
+ /// through — every
+ /// BorderColor outline (,
+ /// , via
+ /// ) landed here and
+ /// so always composited above every OTHER window's sprite content, no matter which
+ /// window actually drew later. Owner report: with the MossTank plugin window
+ /// BEHIND the inventory window, the plugin buttons' border outlines drew on top
+ /// of the inventory paperdoll. UiRenderContext.DrawRect now forwards to
+ /// instead (the same untextured SPRITE-bucket segment,
+ /// painter/submission order), so this bucket is currently unreached by any
+ /// caller in src/ outside this class. Kept — not deleted — for a future
+ /// non-retained-UI caller (e.g. a debug overlay) that genuinely wants "always on
+ /// top of every sprite" compositing; do not route new retained-UI code through it.
+ ///
+ ///
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
@@ -258,10 +292,11 @@ public sealed class TextRenderer : IDisposable
}
/// Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
- /// when active), so it composites in painter order with sprites + dat-font text. Use
- /// this — not — for a panel BACKGROUND that text draws on top of:
- /// DrawRect's bucket always flushes after all sprites, so a rect background would cover
- /// the text instead.
+ /// when active), so it composites in painter/submission order with sprites, outline
+ /// segments, and dat-font text. Every retained-UI fill and outline
+ /// (/DrawFill/
+ /// DrawRectOutline) routes through here, not — see that
+ /// method's doc comment for why.
///
/// Slice V6d: this used to route through a 1×1 white texture, relying on
/// white × colour = colour. The shader now has an untextured branch that produces
@@ -271,7 +306,10 @@ public sealed class TextRenderer : IDisposable
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(UiTextureTableHandle.None, x, y, w, h, 0f, 0f, 1f, 1f, color);
- /// Draw a 1-pixel-thick outline rect.
+ /// Draw a 1-pixel-thick outline rect through the (unreached-by-retained-UI)
+ /// rect bucket — see 's doc comment. Retained-UI outlines go
+ /// through instead, which
+ /// composites in painter order via .
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
// top, bottom, left, right
@@ -502,21 +540,28 @@ public sealed class TextRenderer : IDisposable
});
encoder.BindPipeline(_pipeline);
- // LAYERED compositing for the UI (background → fill → text):
- // 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
- // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
- // 3. Text glyphs — on top
- // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
- // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
- // composite on top, in that order. The OVERLAY layer repeats all three
- // AFTER the normal layer, so open popups beat even the rect backgrounds.
+ // THREE buckets per layer, drawn in this fixed order:
+ // 1. RGBA dat sprites AND untextured fills/outlines — window chrome,
+ // panel backgrounds, vital bars, and every retained-UI BorderColor
+ // outline (DrawFill/DrawRect route here — see DrawFill's doc comment).
+ // Draws in SUBMISSION (painter) order via _spriteSegs, so painter
+ // z-order is preserved for sprite-on-sprite, fill-on-sprite, AND
+ // outline-on-sprite compositing alike.
+ // 2. The SEPARATE untextured-rect bucket (_rectBuf) — always composites
+ // AFTER every bucket-1 segment regardless of submission order; see
+ // DrawRect's doc comment for why the retained UI no longer feeds it.
+ // 3. Text glyphs (debug bitmap-font text; retained-UI dat-font text is
+ // also a bucket-1 sprite segment, not this bucket) — on top.
+ // The OVERLAY layer repeats all three AFTER the normal layer, so open
+ // popups beat even the normal layer's bucket-1 content.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font, frame, encoder);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font, frame, encoder);
}
- /// Draw one compositing layer: sprites (submission order, one call per
- /// texture) → untextured rects → debug-font text. Shared by the normal and overlay
- /// layers; GL state + shader are set up by .
+ /// Draw one compositing layer: sprites + fills + outlines, all in submission
+ /// (painter) order, one call per texture-table slot → the separate untextured-rect
+ /// bucket (unused by the retained UI) → debug-font text. Shared by the normal and
+ /// overlay layers; GL state + shader are set up by .
private void DrawLayer(
List spriteSegs, int segUsed,
List rectBuf, int rectVerts,
diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs
index a91ee7cb..15b008ff 100644
--- a/src/AcDream.App/UI/UiRenderContext.cs
+++ b/src/AcDream.App/UI/UiRenderContext.cs
@@ -153,18 +153,27 @@ public sealed class UiRenderContext
// ── Pass-through draw helpers (add current translate) ──────────────
- public void DrawRect(float x, float y, float w, float h, Vector4 color)
- {
- x += _current.X;
- y += _current.Y;
- if (!ClipRect(ref x, ref y, ref w, ref h)) return;
- TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color));
- }
+ ///
+ /// Solid-colour fill/outline-segment drawn in the SPRITE bucket (painter/submission
+ /// order with every other sprite and dat-font glyph). Root-cause fix (owner report,
+ /// "MossTank border draws over the inventory paperdoll"): this used to forward into
+ /// 's separate untextured-rect bucket, which
+ /// always composited AFTER every sprite segment in
+ /// the layer, regardless of submission order — so a
+ /// border (every BorderColor outline in the retained UI: ,
+ /// ) always won against whatever window happened to draw
+ /// after it, instead of only the windows actually painted before it. Routing through
+ /// the same untextured segment uses makes painter order the
+ /// ONLY thing that decides who's on top, exactly like sprites and text.
+ /// See 's own doc comment for what (if anything)
+ /// still uses that bucket.
+ ///
+ public void DrawRect(float x, float y, float w, float h, Vector4 color) => DrawFill(x, y, w, h, color);
- /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
- /// a panel BACKGROUND that text draws on top of. composites after
- /// all sprites and would cover the text — use this for backgrounds, that for foreground
- /// fills (carets, vital bars).
+ /// Solid-colour fill drawn in the SPRITE bucket (painter/submission order with
+ /// every other sprite, outline segment, and dat-font glyph) — see 's
+ /// doc comment; the two are now the same operation under different names (background vs.
+ /// foreground callers), both routing through .
public void DrawFill(float x, float y, float w, float h, Vector4 color)
{
x += _current.X;
@@ -173,6 +182,9 @@ public sealed class UiRenderContext
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
}
+ /// Four-quad border, each side drawn through — and so,
+ /// since that fix, in painter/submission order with everything else rather than always
+ /// on top of every window drawn after it. See 's doc comment.
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
if (thickness <= 0f || w <= 0f || h <= 0f) return;
diff --git a/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs b/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs
new file mode 100644
index 00000000..1b1586cd
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs
@@ -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;
+
+///
+/// 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 — used to composite in three buckets per
+/// layer: submission-ordered sprite segments, THEN every untextured
+/// quad (regardless of when it was
+/// submitted), THEN debug text. (and so
+/// , which every
+/// BorderColor outline in the retained UI — ,
+/// — 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.
+///
+///
+/// This pins the mechanism directly against the real
+/// 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 already does
+/// for panel backgrounds.
+///
+///
+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);
+ }
+}