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>
This commit is contained in:
Erik 2026-07-27 18:27:52 +02:00
parent ceec3bc440
commit 9aaf97e785
334 changed files with 3841 additions and 3661 deletions

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using AcDream.App.UI;
@ -18,7 +18,7 @@ namespace AcDream.App.UI.Layout;
///
/// <para>
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
/// walks two layers down to extract them: the two Type-3 container children
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
/// on top = higher), then within each container the image children that carry
@ -28,44 +28,44 @@ namespace AcDream.App.UI.Layout;
///
/// <para>
/// The expand-detail overlay present in the front container carries ONLY named
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
/// automatically.
/// </para>
/// </summary>
internal static class DatWidgetFactory
public static class DatWidgetFactory
{
/// <summary>
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
/// </summary>
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
/// <param name="resolve">RenderSurface id (GL tex handle, pixel width, pixel height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
/// May be null pre-load the meter falls back to the debug bitmap font.</param>
/// <param name="fontResolve">Optional font resolver: FontDid <see cref="UiDatFont"/>
/// (or null when the font can't be loaded). When non-null, any element whose
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
/// <paramref name="datFont"/> for every element).</param>
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
/// <returns>The widget for this element. Never null every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info,
Func<uint, (GpuTextureSlot, int, int)> resolve, UiDatFont? datFont,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
// editable fields — retail draws those as inert media-bearing Fields, which our
// editable fields retail draws those as inert media-bearing Fields, which our
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
// affordance a UiField would add). The one true editable field, the chat input
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
// stays on the generic fallback here; register it as UiField only when a window
// actually carries a factory-built editable Type-3 field (and UiField grows a
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
// still ships — it just isn't wired into the factory switch yet.
// still ships it just isn't wired into the factory switch yet.
// Resolve this element's own dat font if a resolver is provided and the element
// has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or
// when the resolver returns null (font missing from dats).
@ -86,11 +86,11 @@ internal static class DatWidgetFactory
// gmUIElement_*Indicator custom button classes
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
0xD => new UiViewport(), // UIElement_Viewport 3-D mini-scene blit leaf
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000031u => new UiItemList(resolve), // UIElement_ItemList toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(
info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
@ -105,7 +105,7 @@ internal static class DatWidgetFactory
e.Width = info.Width;
e.Height = info.Height;
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK e.g. the
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
@ -134,7 +134,7 @@ internal static class DatWidgetFactory
/// </summary>
private static UiScrollbar BuildScrollbar(
ElementInfo info,
Func<uint, (GpuTextureSlot tex, int w, int h)> resolve)
Func<uint, (uint tex, int w, int h)> resolve)
{
var bar = new UiScrollbar
{
@ -302,39 +302,39 @@ internal static class DatWidgetFactory
return stateName == "Normal" ? DefaultImage(info) : 0u;
}
// ── Meter ────────────────────────────────────────────────────────────────
// ── Meter ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
/// child/grandchild elements (format doc §11). Two shapes are handled:
/// child/grandchild elements (format doc §11). Two shapes are handled:
///
/// <para>
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
/// <b>3-slice shape</b> (vitals meters 2 Type-3 containers, each with 3 image grandchildren):
/// <code>
/// meter (Type 7)
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
/// │ ├── center image (DirectState "" → File = back-tile sprite)
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
/// │ ├── left-cap image (→ front-left sprite)
/// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
/// │ ├── center image (DirectState "" → File = back-tile sprite)
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
/// │ ├── left-cap image (→ front-left sprite)
/// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
/// </code>
/// </para>
///
/// <para>
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 1 Type-3
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
/// <code>
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
/// meter (Type 7) [DirectState "" back-track sprite, e.g. 0x0600193E]
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
/// </code>
/// </para>
///
@ -345,7 +345,7 @@ internal static class DatWidgetFactory
/// </para>
/// </summary>
private static UiMeter BuildMeter(ElementInfo info,
Func<uint, (GpuTextureSlot, int, int)> resolve, UiDatFont? datFont)
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var m = new UiMeter
{
@ -383,18 +383,18 @@ internal static class DatWidgetFactory
// Single-image shape used by the toolbar selected-object meters
// (health 0x100001A1, mana 0x100001A2).
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
// info.StateMedia) not on any grandchild image. e.g. health back = 0x0600193E.
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
// The fill child has NO image grandchildren, so SliceIds would return all-zero
// read the container's StateMedia directly instead.
//
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
// which retail renders as "tile at native width to fill the full element geometry"
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
// sprite's NATIVE width (capL = min(nativeW, 140)) leaving a right-side gap and
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
// midW = full bar width, so the back tiles across all 140px and the front clips to
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
@ -435,7 +435,7 @@ internal static class DatWidgetFactory
}
else
{
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape — bar may render as solid-color fallback.");
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape bar may render as solid-color fallback.");
}
return m;
@ -482,11 +482,11 @@ internal static class DatWidgetFactory
return (left, tile, right);
}
// ── Text ─────────────────────────────────────────────────────────────────
// ── Text ─────────────────────────────────────────────────────────────────
/// <summary>Type-12 UIElement_Text: an editable field or colored-line text view,
/// selected from the canonical property bag. The element's
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.
@ -497,15 +497,15 @@ internal static class DatWidgetFactory
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
/// defaults — the build-time value is just the starting point, not a lock.
/// defaults the build-time value is just the starting point, not a lock.
/// </para>
/// </summary>
/// <param name="elementFont">The font to seed on the widget. When a font resolver was
/// provided and the element's FontDid resolved successfully, this is that element-specific
/// font; otherwise it is the shared global fallback. Controllers that call
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
/// still override this — the build-time value is just the starting point.</param>
private static UiElement BuildText(ElementInfo info, Func<uint, (GpuTextureSlot, int, int)> resolve,
/// still override this the build-time value is just the starting point.</param>
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
@ -547,7 +547,7 @@ internal static class DatWidgetFactory
// Apply horizontal + vertical justification from the dat at build time.
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
// afterward will override these — this is only the dat-driven default.
// afterward will override these this is only the dat-driven default.
bool centered = info.HJustify == HJustify.Center;
bool rightAligned = info.HJustify == HJustify.Right;
var vJustify = info.VJustify switch
@ -580,7 +580,7 @@ internal static class DatWidgetFactory
// Font color from dat property 0x1B (ColorBaseProperty).
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
// Controllers that supply explicit per-line colors via LinesProvider still win — this is only
// Controllers that supply explicit per-line colors via LinesProvider still win this is only
// the build-time default.
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
@ -593,7 +593,7 @@ internal static class DatWidgetFactory
private static UiButton BuildButton(
ElementInfo info,
Func<uint, (GpuTextureSlot, int, int)> resolve,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve)
@ -663,7 +663,7 @@ internal static class DatWidgetFactory
/// </summary>
private static UiButton BuildCheckbox(
ElementInfo info,
Func<uint, (GpuTextureSlot, int, int)> resolve,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve)