fix(ui): D.2b-B — inventory panels render over the backdrop (#145 continuation) + captions

Visual verification surfaced two render bugs (controller LOGIC was already correct):

1. BACKDROP WASH-OUT (the big one — #145 continuation). The mounted backpack/
   3D-items panels inherited their sub-window root's ZLevel 1000 via the merge's
   zero-wins-base rule. The #145 ZOrder fold (ReadOrder − ZLevel·10000) turned 1000
   into ZOrder ≈ −10,000,000 — sinking the panels BEHIND the frame's Alphablend
   backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpainted the panels'
   captions/burden-meter/cells (the paperdoll root is ZLevel 0 so it escaped, which
   is why the previous session thought #145 was done). Fix: the sub-window mount now
   keeps each slot's OWN frame ZLevel, so panels sit in front of the backdrop.
   Root-caused via a one-shot sprite-segment-order dump (backdrop was painting after
   the panel content) + a live ZLevel probe.

2. CAPTIONS. The caption elements resolve to UiText; driving a nested child UiText
   didn't paint. AttachCaption now drives the host UiText directly.

Locked by InventoryFrameImportProbe (real-dat smoke: asserts each mounted panel's
ZOrder > the backdrop's). Visually confirmed by the user: dark backdrop behind,
Burden 17% + vertical bar + Contents-of-Backpack + full item grid all visible.
Build + App(532)/Core(1526) tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 17:58:44 +02:00
parent 1ccf07b705
commit 417b1375fd
3 changed files with 108 additions and 3 deletions

View file

@ -82,9 +82,9 @@ public sealed class InventoryController
_burdenMeter.Fill = () => _burdenFill;
}
// Captions: attach a centered UiText child carrying the known string. "Contents of
// Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/
// SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.)
// Captions: drive each host UiText directly with the known string (the caption
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont);
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
@ -168,6 +168,30 @@ public sealed class InventoryController
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
{
if (host is null) return;
// The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack",
// 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live).
// Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas
// a nested child UiText did not paint. Set it to a static centered single-line label.
if (host is UiText t)
{
t.Centered = true;
t.DatFont = datFont;
t.ClickThrough = true;
t.AcceptsFocus = false;
t.IsEditControl = false;
t.CapturesPointerDrag = false;
t.LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
};
return;
}
// Fallback (non-UiText host): attach a centered label child.
var label = new UiText
{
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,

View file

@ -242,8 +242,21 @@ public static class LayoutImporter
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
// ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder ZLevel·10000)
// turns 1000 into ZOrder ≈ 10,000,000 — sinking the whole panel BEHIND the frame's
// Alphablend backdrop (ZLevel 100 → ≈ 1,000,000). The backdrop then overpaints the
// panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
}
return result;
}

View file

@ -0,0 +1,68 @@
using System;
using System.IO;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window
/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN
/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the
/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold
/// (ReadOrder ZLevel·10000) sank them to ≈ 10,000,000, BEHIND the frame's Alphablend
/// backdrop (ZLevel 100 → ≈ 1,000,000), and the backdrop washed out the panels' captions,
/// burden meter, and item cells. Skips when the live dat directory is absent (CI).
/// </summary>
public class InventoryFrameImportProbe
{
private const uint Frame = 0x21000023u;
private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100)
private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot
private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot
private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot
private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it)
private const uint ContentsGrid = 0x100001C6u; // 3D-items content
private static string? DatDir()
{
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(d) ? d : null;
}
[Fact]
public void Mounted_panels_sit_in_front_of_the_backdrop()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip (this is a smoke test)
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
Assert.NotNull(layout);
var backdrop = layout!.FindElement(Backdrop);
Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame
// The sub-window mount brings each panel's content into the by-id index.
Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter
Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid
// The fix: every mounted panel slot must draw IN FRONT of the backdrop
// (higher ZOrder = painted later = on top), so the backdrop sits behind the content.
foreach (var (id, name) in new[]
{
(BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"),
})
{
var panel = layout.FindElement(id);
Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree");
Assert.True(panel!.ZOrder > backdrop!.ZOrder,
$"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " +
"(else the Alphablend backdrop overpaints/washes out the panel content)");
}
}
}