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:
parent
2a248c0d48
commit
a31fd631ad
7 changed files with 234 additions and 17 deletions
|
|
@ -38,16 +38,35 @@ button starts in, what media that state resolves) — before fixing.
|
||||||
|
|
||||||
## #381 — Options-panel footer (Apply/Reset/Defaults) needs an opaque backing field; list content shows through between the buttons
|
## #381 — Options-panel footer (Apply/Reset/Defaults) needs an opaque backing field; list content shows through between the buttons
|
||||||
|
|
||||||
**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user report).
|
**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's
|
||||||
The Apply/Reset/Defaults row draws its three buttons directly over the
|
re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report).
|
||||||
page content, so scrolled rows/text remain visible between and behind
|
|
||||||
them. Retail's tabs give that footer strip its own solid rectangular
|
**ROOT CAUSE — confirmed a genuine acdream synthesis, retail authors no
|
||||||
field (no border) that occludes the content underneath. Fix: an opaque
|
backing element either.** A live-DAT probe dumped the Character/Chat/
|
||||||
borderless backing panel behind the footer button row on every page that
|
Config page roots' (`0x100001F9`/`0x100001FF`/`0x1000050A`) full
|
||||||
has the three buttons (Character/Chat/Config, and the equivalent strip on
|
top-level child inventory: each has EXACTLY five children — the row
|
||||||
Configure Keyboard if its authored layout shows the same bleed-through).
|
ListBox, its scrollbar, and the three physical buttons — with ZERO
|
||||||
Check first whether the authored LayoutDescs carry such a field element
|
direct-state media on the page root itself. Retail's own footer strip has
|
||||||
we fail to draw (preferred: draw the authored one) before synthesizing.
|
no authored backdrop; the bleed-through was a rendering gap, not a
|
||||||
|
missing import. **Fix:** a 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, spanning the full page width),
|
||||||
|
z-ordered strictly behind every other child so it can never occlude the
|
||||||
|
buttons. Register row AP-205 records the synthesis (Configure Keyboard
|
||||||
|
was NOT touched — its own footer strip was not probed and is out of this
|
||||||
|
fix's scope; file a follow-up if it shows the same bleed-through).
|
||||||
|
Regressed by
|
||||||
|
`tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs`
|
||||||
|
(`Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults`
|
||||||
|
— pins exactly one backing field per page, sized from the live button
|
||||||
|
rects, z-ordered behind every sibling).
|
||||||
|
|
||||||
|
**Re-gate (§OP4/OP5/OP6, "the list does not clip/overlap the Apply/
|
||||||
|
Reset/Defaults buttons" steps): scrolled content should no longer be
|
||||||
|
visible through or around the three footer buttons on any of the three
|
||||||
|
tabs.**
|
||||||
|
|
||||||
## #380 — Chat tab: the two opacity sliders are missing their retail row captions
|
## #380 — Chat tab: the two opacity sliders are missing their retail row captions
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -205,7 +205,12 @@ TrySetOption` seam every other Options-panel consumer already uses.
|
||||||
FAILURE shape, not a crash, but still worth flagging which row).
|
FAILURE shape, not a crash, but still worth flagging which row).
|
||||||
2. **Scroll the list** via the scrollbar. All 50+6 rows are reachable;
|
2. **Scroll the list** via the scrollbar. All 50+6 rows are reachable;
|
||||||
the list does not clip or overlap the Apply/Reset/Defaults buttons at
|
the list does not clip or overlap the Apply/Reset/Defaults buttons at
|
||||||
the bottom.
|
the bottom. **Gate-4 re-test note (#381):** the footer strip behind the
|
||||||
|
three buttons previously had NO backing — scrolled row text/content was
|
||||||
|
visible bleeding through between and behind Apply/Reset/Defaults. The
|
||||||
|
footer now has an opaque backing field; report if any content is still
|
||||||
|
visible through or around the three buttons (this applies identically
|
||||||
|
on the Chat and Config tabs' own footer strips, OP5/OP6 below).
|
||||||
3. **Confirm the checkboxes reflect your character's actual state** —
|
3. **Confirm the checkboxes reflect your character's actual state** —
|
||||||
e.g. if you have `IgnoreAllegianceRequests` on from a prior session,
|
e.g. if you have `IgnoreAllegianceRequests` on from a prior session,
|
||||||
its row should show checked on open (seeded from the live server bit
|
its row should show checked on open (seeded from the live server bit
|
||||||
|
|
@ -411,7 +416,9 @@ chat windows already read when deciding which lines to show.
|
||||||
cleanly at the panel's bottom edge — scrolling reveals the rest
|
cleanly at the panel's bottom edge — scrolling reveals the rest
|
||||||
smoothly. A block that disappears ENTIRELY while its header stays (the
|
smoothly. A block that disappears ENTIRELY while its header stays (the
|
||||||
pre-fix void your gate-3 screenshot review caught) is a regression —
|
pre-fix void your gate-3 screenshot review caught) is a regression —
|
||||||
report it.
|
report it. **Gate-4 re-test note (#381):** see the Character-tab (OP4)
|
||||||
|
step 2 note — the SAME opaque-footer-backing fix applies to this tab's
|
||||||
|
own Apply/Reset/Defaults strip.
|
||||||
3. **Look closely at a row whose mask covers MULTIPLE underlying message
|
3. **Look closely at a row whose mask covers MULTIPLE underlying message
|
||||||
types** (Gameplay, Combat, Allegiance, or Fellowship — the composite-mask
|
types** (Gameplay, Combat, Allegiance, or Fellowship — the composite-mask
|
||||||
rows per the research doc) versus a single-bit row (e.g. "Error" or
|
rows per the research doc) versus a single-bit row (e.g. "Error" or
|
||||||
|
|
@ -590,7 +597,8 @@ should show no caption.
|
||||||
invented English when a string fails to resolve).
|
invented English when a string fails to resolve).
|
||||||
2. **Scroll the list** via the scrollbar. All 27 rows are reachable; the
|
2. **Scroll the list** via the scrollbar. All 27 rows are reachable; the
|
||||||
list does not clip or overlap the Apply/Reset/Defaults buttons at the
|
list does not clip or overlap the Apply/Reset/Defaults buttons at the
|
||||||
bottom.
|
bottom. **Gate-4 re-test note (#381):** see the Character-tab (OP4)
|
||||||
|
step 2 note above — the SAME opaque-footer-backing fix applies here.
|
||||||
|
|
||||||
### Live rows — audio
|
### Live rows — audio
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,18 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
||||||
/// import in this codebase (<see cref="CharacterController"/>,
|
/// import in this codebase (<see cref="CharacterController"/>,
|
||||||
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
|
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
|
||||||
/// </summary>
|
/// </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
|
/// <returns>Null if <paramref name="layout"/>'s root did not build as a
|
||||||
/// <see cref="UiTabPanel"/> (a missing/malformed LayoutDesc).</returns>
|
/// <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(layout);
|
||||||
ArgumentNullException.ThrowIfNull(callbacks);
|
ArgumentNullException.ThrowIfNull(callbacks);
|
||||||
|
|
@ -223,7 +232,16 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
||||||
|
|
||||||
UiButton? apply = BindPageButton(pageRoot, ApplyButtonId, page.Apply);
|
UiButton? apply = BindPageButton(pageRoot, ApplyButtonId, page.Apply);
|
||||||
UiButton? reset = BindPageButton(pageRoot, ResetButtonId, page.Reset);
|
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
|
// MUST-FIX 2 (OP4 review-fix round, 2026-08-11): retail
|
||||||
// PlayerOptionPage::OnOptionChanged @0x004F27D0 — Apply/Reset
|
// PlayerOptionPage::OnOptionChanged @0x004F27D0 — Apply/Reset
|
||||||
|
|
@ -264,6 +282,53 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// Activates the tab-switching behavior (idempotent — safe even if
|
/// Activates the tab-switching behavior (idempotent — safe even if
|
||||||
/// already active). Must run AFTER <see cref="Bind"/> so this
|
/// already active). Must run AFTER <see cref="Bind"/> so this
|
||||||
|
|
|
||||||
|
|
@ -2062,7 +2062,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
|
OpenConfigureKeyboard: () => ToggleWindow(WindowNames.KeyboardConfig));
|
||||||
|
|
||||||
Layout.OptionsPanelController? controller =
|
Layout.OptionsPanelController? controller =
|
||||||
Layout.OptionsPanelController.Bind(layout, callbacks);
|
Layout.OptionsPanelController.Bind(layout, callbacks, _bindings.Assets.ResolveSprite);
|
||||||
if (controller is null)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");
|
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");
|
||||||
|
|
|
||||||
63
src/AcDream.App/UI/UiSolidSpriteFill.cs
Normal file
63
src/AcDream.App/UI/UiSolidSpriteFill.cs
Normal 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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,67 @@ public sealed class OptionsPanelControllerTests
|
||||||
Assert.Empty(controller.ConfigPage.Rows);
|
Assert.Empty(controller.ConfigPage.Rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Every <see cref="UiSolidSpriteFill"/> under a subtree, in
|
||||||
|
/// build (document) order.</summary>
|
||||||
|
private static List<UiSolidSpriteFill> CollectFooterBackings(UiElement root)
|
||||||
|
{
|
||||||
|
var found = new List<UiSolidSpriteFill>();
|
||||||
|
Walk(root, found);
|
||||||
|
return found;
|
||||||
|
|
||||||
|
static void Walk(UiElement node, List<UiSolidSpriteFill> acc)
|
||||||
|
{
|
||||||
|
if (node is UiSolidSpriteFill fill) acc.Add(fill);
|
||||||
|
foreach (UiElement child in node.Children) Walk(child, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #381 regression (2026-08-11, gate 4): before this fix, the
|
||||||
|
/// Character/Chat/Config pages drew their Apply/Reset/Defaults buttons
|
||||||
|
/// directly over the ListBox content with nothing behind them — a live-
|
||||||
|
/// DAT probe found retail authors no backing element either (page root
|
||||||
|
/// has exactly 5 children, zero direct-state media), so this is a
|
||||||
|
/// genuine acdream synthesis (register row AP-205), not a missing
|
||||||
|
/// import. Pins that exactly one opaque field is synthesized per page,
|
||||||
|
/// sized/positioned from the buttons' OWN resolved rects, and z-ordered
|
||||||
|
/// strictly behind everything else on that page (so it can never
|
||||||
|
/// occlude the buttons it backs).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||||
|
var calls = new List<string>();
|
||||||
|
|
||||||
|
OptionsPanelController? controller = OptionsPanelController.Bind(
|
||||||
|
layout, MakeCallbacks(calls), resolveSprite: _ => (1u, 8, 8));
|
||||||
|
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
// Page slot ids — the SAME literals ConfigOptionsPageController's
|
||||||
|
// own PageSlotElementId/ChatOptionsPageController's own
|
||||||
|
// PageSlotElementId cite, and this file's own TabSwitch tests
|
||||||
|
// already use for Character (0x10000211).
|
||||||
|
foreach (uint pageSlotId in new[] { 0x10000211u, 0x1000050Cu, 0x10000213u })
|
||||||
|
{
|
||||||
|
UiElement pageRoot = UiElement.FindDescendant(controller!.TabPanel, pageSlotId)!;
|
||||||
|
Assert.NotNull(pageRoot);
|
||||||
|
List<UiSolidSpriteFill> backings = CollectFooterBackings(pageRoot);
|
||||||
|
UiSolidSpriteFill backing = Assert.Single(backings);
|
||||||
|
|
||||||
|
Assert.Equal(RetailChromeSprites.CenterFill, backing.SpriteId);
|
||||||
|
Assert.NotNull(backing.SpriteResolve);
|
||||||
|
Assert.True(backing.ClickThrough);
|
||||||
|
Assert.Equal(pageRoot.Width, backing.Width);
|
||||||
|
|
||||||
|
// Drawn BEHIND every sibling already on the page (buttons,
|
||||||
|
// ListBox, scrollbar) — lowest ZOrder wins the painter's-algorithm
|
||||||
|
// back-to-front sort (UiElement.cs's own convention).
|
||||||
|
Assert.All(pageRoot.Children.Where(c => !ReferenceEquals(c, backing)),
|
||||||
|
sibling => Assert.True(backing.ZOrder < sibling.ZOrder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ActivateTabs_SelectsGameplayAsDefault_ButNeverFlushesIt()
|
public void ActivateTabs_SelectsGameplayAsDefault_ButNeverFlushesIt()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue