acdream/src/AcDream.App/UI/UiItemList.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

447 lines
18 KiB
C#

using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>Cell order for a multi-column <see cref="UiItemList"/>.</summary>
public enum UiItemListFlow
{
/// <summary>Retail listbox bit 0 set: index advances across columns, then down rows.</summary>
RowMajor,
/// <summary>Retail listbox bit 0 clear: index advances down rows, then across columns.</summary>
ColumnMajor,
}
/// <summary>
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
/// LayoutImporter must NOT build dat children. The toolbar uses single-cell
/// instances (one slot); the inventory phase will grow this to an N-cell grid.
/// </summary>
public sealed class UiItemList : UiElement
{
private readonly List<UiItemSlot> _cells = new();
private int _layoutDeferralDepth;
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
public UiScrollable Scroll { get; }
public UiItemList(
Func<uint, (uint tex, int w, int h)>? spriteResolve = null,
UiScrollable? scroll = null)
{
Scroll = scroll ?? new UiScrollable();
SpriteResolve = spriteResolve;
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });
}
public override bool ConsumesDatChildren => true;
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
private IReadOnlyList<uint>? _cooldownSprites;
public IReadOnlyList<uint>? CooldownSprites
{
get => _cooldownSprites;
set
{
_cooldownSprites = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownSprites = value;
}
}
private Func<uint, int>? _cooldownStepProvider;
public Func<uint, int>? CooldownStepProvider
{
get => _cooldownStepProvider;
set
{
_cooldownStepProvider = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownStepProvider = value;
}
}
/// <summary>
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
/// physical-item drops continue through <see cref="DragHandler"/>.
/// Coordinates are local to this list.
/// </summary>
public Action<object, int, int>? CatalogDropped { get; set; }
/// <summary>
/// Physical-item left-button-down command supplied by the owning panel.
/// Return <see langword="true"/> when a target mode consumed the press so
/// the slot suppresses drag promotion and the completed Click/DoubleClick
/// gesture.
///
/// Retail references:
/// <c>UIElement_ListBox::MouseDown @ 0x0046E3A0</c> selects the item under
/// the pointer during MouseDown, before optional drag selection;
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c> handles
/// target mode before ordinary physical-item selection.
/// </summary>
public Func<uint, bool>? PrimaryItemPressed { get; set; }
/// <summary>
/// Appraisal command supplied by the owning panel controller for physical
/// item cells. Retail <c>UIElement_ItemList::ListenToElementMessage
/// @ 0x004E4D50</c> handles its right-click branch at <c>0x004E4ECA</c>:
/// select the occupied UIItem's itemID, then call
/// <c>ClientUISystem::ExamineObject</c>.
/// </summary>
public Action<uint>? ExamineItemRequested { get; set; }
/// <summary>
/// Non-weenie catalog-entry left-button-down command supplied by the owning
/// panel. Spell favorites use this distinct path so a spell id is never
/// published as the globally selected object GUID.
///
/// Retail <c>gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0</c>
/// handles UIItem message parameter 7 (left press) by calling
/// <c>SpellCastSubMenu::SetSelected</c>.
/// </summary>
public Action<uint>? PrimaryCatalogEntryPressed { get; set; }
/// <summary>
/// Local examination command for non-weenie catalog entries. Retail
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c>
/// routes right-clicked <c>spellID</c> values to
/// <c>ClientUISystem::ExamineSpell</c> without changing the selected object.
/// </summary>
public Action<uint>? ExamineCatalogEntryRequested { get; set; }
private uint _cellEmptySprite;
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
/// AND future cell.</summary>
public uint CellEmptySprite
{
get => _cellEmptySprite;
set
{
_cellEmptySprite = value;
if (value != 0)
foreach (var c in _cells) c.EmptySprite = value;
}
}
/// <summary>The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
public IItemListDragHandler? DragHandler { get; private set; }
/// <summary>Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
/// runs, so use <see cref="GetItem"/> there instead.</summary>
/// <exception cref="InvalidOperationException">the list has no cells (e.g. after Flush).</exception>
public UiItemSlot Cell => _cells.Count > 0
? _cells[0]
: throw new InvalidOperationException("UiItemList has no cells; call AddItem first or use GetItem(index).");
public int GetNumUIItems() => _cells.Count;
/// <summary>Returns this cell's current retained-list position, matching
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.</summary>
public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell);
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
// on every child after OnDraw, which would otherwise reset the scroll offset to each
// cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells
// the sole authority over cell rects.
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
if (_layoutDeferralDepth == 0)
LayoutCells();
}
/// <summary>
/// Batch a procedural list rebuild without repeatedly shrinking the scroll
/// extents as cells are re-added. The retained pixel offset is clamped once
/// against the completed list when the outermost scope ends.
/// </summary>
public IDisposable DeferLayout()
{
_layoutDeferralDepth++;
return new LayoutDeferral(this);
}
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
public int Columns { get; set; } = 1;
/// <summary>
/// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of
/// m_bitField: set = row-major, clear = column-major. Default preserves the
/// existing toolbar/grid behavior; panel controllers select retail-specific flow.
/// </summary>
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
/// to the whole list (the toolbar single-slot legacy). Set &gt;0 (with CellHeight) for a grid.</summary>
public float CellWidth { get; set; }
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
public float CellHeight { get; set; }
/// <summary>
/// Lay cells left-to-right in one row. Retail favorite spell lists use the
/// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so
/// the number of visible cells is determined by width rather than the nine
/// shortcut-number overlays.
/// </summary>
public bool SingleRow { get; set; }
/// <summary>
/// The single row uses the shared pixel scroll model as a horizontal offset.
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
/// horizontal ItemList + Scrollbar consumer.
/// </summary>
public bool HorizontalScroll { get; set; }
/// <summary>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
/// </summary>
public bool FillVisibleEmptySlots { get; set; }
/// <summary>Creates an empty cell for <see cref="FillVisibleEmptySlots"/>.
/// Null uses a plain physical UiItemSlot.</summary>
public Func<UiItemSlot>? EmptySlotFactory { get; set; }
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
/// <paramref name="columns"/> columns.</summary>
public static int RowCount(int cellCount, int columns)
{
int cols = columns < 1 ? 1 : columns;
return (cellCount + cols - 1) / cols;
}
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
/// <summary>
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
/// row-major when bit 0 is set, column-major when it is clear.
/// </summary>
internal static (float x, float y) CellOffset(
int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH)
{
int cols = columns < 1 ? 1 : columns;
if (flow == UiItemListFlow.ColumnMajor)
{
int rows = Math.Max(1, RowCount(cellCount, cols));
int col = index / rows, row = index % rows;
return (col * cellW, row * cellH);
}
return CellOffset(index, cols, cellW, cellH);
}
/// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
/// cell to the list; grid (CellWidth&gt;0) tiles cells by <see cref="Flow"/>, offset by the scroll position
/// with pixel viewport clipping (partially visible edge rows are cropped).</summary>
internal void LayoutCells()
{
if (CellWidth <= 0f)
{
// Fill mode (the toolbar single cell): size the one cell to the list.
if (_cells.Count > 0)
{
var c = _cells[0];
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
}
return;
}
UpdateEmptySlots();
int cols = SingleRow
? Math.Max(1, _cells.Count)
: Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
if (SingleRow && HorizontalScroll)
{
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
Scroll.LineHeight = cellW;
Scroll.ContentHeight = _cells.Count * cellW;
Scroll.ViewHeight = (int)MathF.Floor(Width);
Scroll.SetScrollY(Scroll.ScrollY);
float scrollX = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
float left = i * CellWidth - scrollX;
UiItemSlot cell = _cells[i];
cell.Left = left;
cell.Top = 0f;
cell.Width = CellWidth;
cell.Height = CellHeight;
cell.Visible = left < Width && left + CellWidth > 0f;
}
return;
}
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max.
Scroll.LineHeight = cellH > 0 ? cellH : 1;
Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
Scroll.ViewHeight = (int)MathF.Floor(Height);
Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max
float scrollY = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight);
float top = baseY - scrollY;
var cell = _cells[i];
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
// Retail listboxes retain intersecting edge rows at arbitrary pixel offsets.
// The UiElement child-clip traversal crops their visible portion.
cell.Visible = top < Height && top + CellHeight > 0f;
}
}
/// <summary>
/// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when
/// the viewport shrinks removes only empty cells from the tail. Occupied cells
/// are never discarded merely because they extend beyond the viewport.
/// </summary>
private void UpdateEmptySlots()
{
if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f)
return;
int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth));
while (_cells.Count < visibleCellCount)
{
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
}
while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot)
{
UiItemSlot cell = _cells[^1];
_cells.RemoveAt(_cells.Count - 1);
RemoveChild(cell);
}
}
protected override bool ClipsChildren => CellWidth > 0f;
public void Flush()
{
foreach (var c in _cells) RemoveChild(c);
_cells.Clear();
}
public override bool OnEvent(in UiEvent e)
{
if (CatalogDropped is not null && e.Payload is not null)
{
if (e.Type is UiEventType.DragEnter or UiEventType.DragOver)
return true;
if (e.Type == UiEventType.DropReleased)
{
CatalogDropped(e.Payload, e.Data1, e.Data2);
return true;
}
}
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
{
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
Scroll.ScrollByLines(-e.Data0);
return true;
}
return base.OnEvent(e);
}
/// <summary>
/// Port of the listbox expose-item behavior called by
/// <c>gmSpellbookUI::SetSelected @ 0x0048B540</c>.
/// </summary>
public void ScrollItemIntoView(int index)
{
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
if (SingleRow && HorizontalScroll)
{
float left = index * CellWidth;
float right = left + CellWidth;
if (left < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(left));
else if (right > Scroll.ScrollY + Width)
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
return;
}
int columns = Math.Max(1, Columns);
int row = Flow == UiItemListFlow.ColumnMajor
? index % Math.Max(1, RowCount(_cells.Count, columns))
: index / columns;
float top = row * CellHeight;
float bottom = top + CellHeight;
if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top));
else if (bottom > Scroll.ScrollY + Height)
Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height));
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets Width/Height AFTER construction, so re-layout each frame:
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
LayoutCells();
}
private void EndLayoutDeferral()
{
if (_layoutDeferralDepth <= 0)
return;
_layoutDeferralDepth--;
if (_layoutDeferralDepth == 0)
LayoutCells();
}
private sealed class LayoutDeferral : IDisposable
{
private UiItemList? _owner;
public LayoutDeferral(UiItemList owner) => _owner = owner;
public void Dispose()
{
UiItemList? owner = _owner;
_owner = null;
owner?.EndLayoutDeferral();
}
}
}