acdream/src/AcDream.App/UI/Layout/ItemListCellTemplate.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

206 lines
8.9 KiB
C#

using System.Collections.Generic;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail
/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 /
/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &amp;id) -> GetByEnum(0x10000038,5,0x23) [the
/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned
/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background.
/// </summary>
public static class ItemListCellTemplate
{
/// <summary>The shared UIItem cell-template catalog. Hardcoded: retail resolves it via
/// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal);
/// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032)
/// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface).</summary>
public const uint CatalogLayoutId = 0x21000037u;
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
/// <summary>
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
/// the <see cref="UiItemSlot"/> default).
/// </summary>
public static uint ResolveEmptySprite(IDatReaderWriter dats, uint listLayoutId, uint listElementId)
{
var listLd = dats.Get<LayoutDesc>(listLayoutId);
if (listLd is null) return 0;
var listElem = FindDesc(listLd, listElementId);
if (listElem is null) return 0;
uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e
if (protoId == 0) return 0;
return ResolvePrototypeEmptySprite(dats, protoId);
}
/// <summary>
/// Resolve from an already inherited <see cref="ElementInfo"/> tree. Use this
/// overload when the ItemList is contributed by a cross-layout BaseElement:
/// the raw outer LayoutDesc does not physically contain that descendant, but
/// <see cref="LayoutImporter.ImportInfos(DatCollection,uint)"/> has applied the
/// same inheritance that retail uses before <c>InternalCreateItem</c> queries
/// attribute <c>0x1000000E</c>.
/// </summary>
public static uint ResolveEmptySprite(
IDatReaderWriter dats,
ElementInfo resolvedRoot,
uint listElementId)
{
ElementInfo? list = FindInfo(resolvedRoot, listElementId);
if (list is null
|| !list.TryGetEffectiveProperty(CellTemplateAttr, out UiPropertyValue property)
|| property.Kind is not (UiPropertyKind.Enum or UiPropertyKind.DataId)
|| property.UnsignedValue is 0 or > uint.MaxValue)
return 0;
return ResolvePrototypeEmptySprite(dats, (uint)property.UnsignedValue);
}
/// <summary>
/// Resolves <c>ItemSlot_Empty</c> directly from an authored UIItem prototype in
/// <see cref="CatalogLayoutId"/>. Paperdoll lists select distinct prototypes for their
/// jewelry, weapon, clothing, and armor locations even though the lists share one base.
/// </summary>
public static uint ResolvePrototypeEmptySprite(IDatReaderWriter dats, uint prototypeElementId)
{
if (prototypeElementId == 0) return 0;
var catalog = dats.Get<LayoutDesc>(CatalogLayoutId);
if (catalog is null) return 0;
var proto = FindDesc(catalog, prototypeElementId);
if (proto is null) return 0;
// The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH
// BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind
// an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw
// child search alone returns nothing for containers. We deliberately do NOT use the
// prototype's DirectState child overlay: on the container prototype that child is the
// open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the
// selected container (a deferred container-selection feature) — never as empty-cell art.
// (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.)
return FindIconEmpty(catalog, proto, new HashSet<uint>());
}
// ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ──
// Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern,
// but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection.
private static uint ReadCellTemplateId(ElementDesc elem)
{
uint id = ReadIdFromState(elem.StateDesc);
if (id != 0) return id;
foreach (var s in elem.States)
{
id = ReadIdFromState(s.Value);
if (id != 0) return id;
}
return 0;
}
private static uint ReadIdFromState(StateDesc? sd)
{
if (sd?.Properties is null) return 0;
return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0;
}
private static uint ReadId(BaseProperty raw)
{
if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]);
if (raw is DataIdBaseProperty did) return did.Value;
if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat
return 0;
}
// ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ──
// Find the icon child's empty media within `element`'s subtree, following BaseElement edges
// within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries
// 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited
// base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers.
private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet<uint> baseSeen)
{
if (element.ElementId == IconChildId)
{
uint e = IconEmptyState(element);
if (e != 0) return e;
}
foreach (var kv in element.Children)
{
uint e = FindIconEmpty(catalog, kv.Value, baseSeen);
if (e != 0) return e;
}
if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement))
{
var baseDesc = FindDesc(catalog, element.BaseElement);
if (baseDesc is not null)
{
uint e = FindIconEmpty(catalog, baseDesc, baseSeen);
if (e != 0) return e;
}
}
return 0;
}
private static uint IconEmptyState(ElementDesc icon)
{
foreach (var s in icon.States)
if (s.Key.ToString() == ItemSlotEmpty)
{
uint f = StateImage(s.Value);
if (f != 0) return f;
}
return DirectStateImage(icon); // some icons carry empty media on the DirectState
}
private static uint DirectStateImage(ElementDesc d)
=> d.StateDesc is null ? 0u : StateImage(d.StateDesc);
private static uint StateImage(StateDesc sd)
{
foreach (var m in sd.Media)
if (m is MediaDescImage img && img.File != 0) return img.File;
return 0;
}
// ── depth-first element search by id (LayoutImporter.FindDesc is private there) ──
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementInfo? FindInfo(ElementInfo element, uint id)
{
if (element.Id == id) return element;
foreach (ElementInfo child in element.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
}