From 761a7519f11fd0a4e2ea65477be740100cbff586 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 16:24:34 +0200 Subject: [PATCH] fix(ui): retained-UI rect outlines composite in painter order, not above every window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Rendering/TextRenderer.cs | 83 ++++++++++--- src/AcDream.App/UI/UiRenderContext.cs | 34 ++++-- .../UI/UiRectOutlinePainterOrderTests.cs | 112 ++++++++++++++++++ 3 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs 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); + } +}