fix #381: Options-panel footer needs an opaque backing field

Root cause: a live-DAT probe found retail authors NO backing element
behind the Character/Chat/Config tabs' Apply/Reset/Defaults footer —
each page root has exactly five children (the row ListBox, its
scrollbar, and the three buttons) with zero direct-state media on the
root itself. Scrolled row content therefore bled through visibly
between/behind the three buttons; the bleed-through is a rendering gap
in our own composition, not a missing import.

Fix: new minimal widget UiSolidSpriteFill tiles
RetailChromeSprites.CenterFill (the SAME panel-background sprite the
Options window's own chrome already draws behind everything, not an
invented color) across the footer strip's rect, derived from the three
buttons' own resolved Top/Height and z-ordered strictly behind every
other child so it can never intercept input or occlude the buttons.
Register row AP-205 records the synthesis. Regressed by
OptionsPanelControllerTests.
Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults,
which pins exactly one backing field per page, sized from the live
button rects, z-ordered behind every sibling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:01:43 +02:00
parent 2a248c0d48
commit a31fd631ad
7 changed files with 234 additions and 17 deletions

View file

@ -169,9 +169,18 @@ public sealed class OptionsPanelController : IRetainedPanelController
/// import in this codebase (<see cref="CharacterController"/>,
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
/// </summary>
/// <param name="resolveSprite">#381: RenderSurface id → (GL tex handle,
/// pixel width, pixel height) — wired onto each page's synthesized
/// footer backing field (<see cref="UiSolidSpriteFill"/>). Null leaves
/// the field present but drawing nothing (matching every other
/// null-safe sprite resolver in this codebase) — the fixture/
/// conformance callers that never exercise the visual layer.</param>
/// <returns>Null if <paramref name="layout"/>'s root did not build as a
/// <see cref="UiTabPanel"/> (a missing/malformed LayoutDesc).</returns>
public static OptionsPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
public static OptionsPanelController? Bind(
ImportedLayout layout,
Callbacks callbacks,
Func<uint, (uint tex, int w, int h)>? resolveSprite = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(callbacks);
@ -223,7 +232,16 @@ public sealed class OptionsPanelController : IRetainedPanelController
UiButton? apply = BindPageButton(pageRoot, ApplyButtonId, page.Apply);
UiButton? reset = BindPageButton(pageRoot, ResetButtonId, page.Reset);
BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
UiButton? defaults = BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
// #381 (2026-08-11, gate 4): the opaque backing field behind the
// footer strip — see UiSolidSpriteFill's own doc for the
// live-DAT probe proving retail authors no such element (the
// page root has exactly 5 children, zero direct-state media).
// Sized from the THREE BUTTONS' OWN resolved rects (not a
// hardcoded literal) so it tracks whatever the authored layout
// actually placed them at.
AddFooterBacking(pageRoot, apply, reset, defaults, resolveSprite);
// MUST-FIX 2 (OP4 review-fix round, 2026-08-11): retail
// PlayerOptionPage::OnOptionChanged @0x004F27D0 — Apply/Reset
@ -264,6 +282,53 @@ public sealed class OptionsPanelController : IRetainedPanelController
return null;
}
/// <summary>Draws behind everything else already added to
/// <paramref name="pageRoot"/> — see <see cref="UiSolidSpriteFill"/>'s
/// own doc for why this element exists at all (AP-205, synthesized,
/// no authored counterpart).</summary>
private const int FooterBackingZOrder = int.MinValue / 2;
/// <summary>
/// #381: inserts the opaque, borderless backing field behind
/// Apply/Reset/Defaults, deriving its Y/height from whichever of the
/// three physical buttons resolved (all three share the SAME authored
/// Y=564/Height=32, research doc §3.1 — any one is sufficient). Skipped
/// (not an error — logged) only when NONE resolved; a page missing all
/// three already logged its own per-button warnings above.
/// </summary>
private static void AddFooterBacking(
UiElement pageRoot,
UiButton? apply,
UiButton? reset,
UiButton? defaults,
Func<uint, (uint tex, int w, int h)>? resolveSprite)
{
UiButton? first = apply ?? reset ?? defaults;
if (first is null)
{
Console.WriteLine(
$"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8} has no "
+ "resolved Apply/Reset/Defaults buttons — footer backing field skipped.");
return;
}
// Span the FULL authored page width (not just the button union) —
// retail's own three buttons don't reach the page's right edge, but
// the reported bleed-through is content scrolling behind the WHOLE
// footer strip, not just the button rects themselves.
var backing = new UiSolidSpriteFill
{
SpriteId = RetailChromeSprites.CenterFill,
SpriteResolve = resolveSprite,
Left = 0f,
Top = first.Top,
Width = pageRoot.Width,
Height = first.Height,
ZOrder = FooterBackingZOrder,
};
pageRoot.AddChild(backing);
}
/// <summary>
/// Activates the tab-switching behavior (idempotent — safe even if
/// already active). Must run AFTER <see cref="Bind"/> so this

View file

@ -2062,7 +2062,7 @@ public sealed class RetailUiRuntime : IDisposable
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks);
Layout.OptionsPanelController.Bind(layout, callbacks, _bindings.Assets.ResolveSprite);
if (controller is null)
{
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");

View file

@ -0,0 +1,63 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// #381 (Campaign OP gate 4, 2026-08-11): a minimal, purely-synthesized
/// leaf that tiles ONE opaque sprite across its own rect — nothing more.
/// Used for the Apply/Reset/Defaults footer's opaque backing field: a
/// live-DAT probe found the Character/Chat/Config page roots author
/// EXACTLY five children each (the row ListBox, its scrollbar, and the
/// three physical buttons) with zero direct-state media on the root itself
/// — retail authors NO backdrop element behind the footer strip at all, so
/// scrolled row content bled through between/behind the three buttons.
///
/// <para>
/// This is a genuine synthesis, not a retail port — register row AP-205
/// records it. The fill sprite is <see cref="RetailChromeSprites.CenterFill"/>,
/// the SAME authored panel-background media the whole Options window's own
/// <see cref="UiNineSlicePanel"/> chrome already tiles behind everything —
/// not an invented color, the panel's own background family reused for
/// visual consistency with the rest of the window.
/// </para>
///
/// <para>
/// <see cref="UiNineSlicePanel"/> itself was not reused here — its
/// constructor forces <c>Draggable</c>/<c>Resizable</c>/<c>Anchors.None</c>
/// (top-level-window machinery this footer strip must never have) and
/// draws a full 8-piece bevel + resize grip this borderless field
/// explicitly does not want (the issue calls for "no border").
/// </para>
/// </summary>
public sealed class UiSolidSpriteFill : UiElement
{
public uint SpriteId { get; init; }
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
public UiSolidSpriteFill()
{
// A backing field must never intercept pointer events meant for
// whatever's stacked in front of/behind it (the buttons draw ON
// TOP via their own later ReadOrder; nothing should route to this
// leaf at all).
ClickThrough = true;
}
protected override void OnDraw(UiRenderContext ctx)
{
var resolve = SpriteResolve;
if (resolve is null || SpriteId == 0u) return;
var (tex, tw, th) = resolve(SpriteId);
if (tex == 0 || tw == 0 || th == 0) return;
// Force OPAQUE — an occluding backing field must read solid even
// when it sits inside a window whose own chrome/content is
// translucent (same "force opaque" pattern UiMenu's popup uses).
ctx.PushAlphaAbsolute(1f);
try
{
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, Width / tw, Height / th, Vector4.One);
}
finally { ctx.PopAlpha(); }
}
}