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.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
@ -10,7 +10,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior the acdream
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
///
/// <para>
@ -24,20 +24,20 @@ namespace AcDream.App.UI.Layout;
/// and bound in place.
/// </para>
/// </summary>
internal sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
{
public const uint LayoutId = 0x21000006u;
private bool _disposed;
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu;
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px dropped; nine-slice grips replace it)
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TranscriptId = 0x10000011u; // Type-12 prototype skipped by factory
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 UiField
private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu;
@ -48,7 +48,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
private const uint MenuItemSelected = 0x0600124Du; // active channel row
// ── Public surface ─────────────────────────────────────────────────────
// ── Public surface ─────────────────────────────────────────────────────
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
public UiElement Root { get; private set; } = null!;
@ -71,7 +71,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
public RetailWindowHandle? WindowHandle { get; private set; }
public bool IsMaximized => _maximized;
// ── Private state ──────────────────────────────────────────────────────
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
@ -86,7 +86,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
private BitmapFont? _cachedTranscriptDebugFont;
internal int TranscriptLayoutBuildCount { get; private set; }
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
@ -133,7 +133,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
private bool _maximized;
private UiButton? _maxMinButton;
// ── Factory ────────────────────────────────────────────────────────────
// ── Factory ────────────────────────────────────────────────────────────
/// <summary>
/// Bind an imported chat layout to live behavior.
@ -156,7 +156,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
/// <param name="resolve">Dat RenderSurface id (GL tex handle, px width, px height).
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
public static ChatWindowController? Bind(
ElementInfo rootInfo,
@ -165,7 +165,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
Func<ICommandBus> busProvider,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (GpuTextureSlot tex, int w, int h)> resolve)
Func<uint, (uint tex, int w, int h)> resolve)
{
// Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
@ -177,14 +177,14 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(input={input is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) " +
$"chat window will not be interactive.");
return null;
}
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
// window a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
// so using layout.Root would render the strays overlapping the real window (the
@ -210,9 +210,9 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
transcriptPanel.Top = 0f;
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
// ── Transcript ───────────────────────────────────────────────────
// ── Transcript ───────────────────────────────────────────────────
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
// Find it in the widget tree and bind the live providers — no remove/add needed.
// Find it in the widget tree and bind the live providers no remove/add needed.
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
@ -228,7 +228,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
// ── Input ────────────────────────────────────────────────────────
// ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the
// imported property/state bags. The controller supplies runtime services only.
c.Input = input;
@ -238,9 +238,9 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── Scrollbar — bind the factory-built Type-11 track element ────────
// ── Scrollbar — bind the factory-built Type-11 track element ────────
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
// directly. Find it, bind it in place — no remove/add needed.
// directly. Find it, bind it in place no remove/add needed.
var track = layout.FindElement(TrackId);
if (track is UiScrollbar bar)
{
@ -253,7 +253,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
c.Scrollbar = bar;
}
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
if (layout.FindElement(MenuId) is UiMenu menu)
{
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
@ -268,7 +268,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
// The widget reports the pick; the controller owns Selected. Only a talk-channel
// payload updates the active channel + highlight — the null-payload specials are
// payload updates the active channel + highlight the null-payload specials are
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
menu.OnSelect = p =>
{
@ -277,18 +277,18 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
c.Menu = menu;
}
// ── Send button — Enter-alternate submit trigger ──────────────────
// ── Send button — Enter-alternate submit trigger ──────────────────
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
// The Send sprite is a blank gold button — retail draws the caption as text.
// The Send sprite is a blank gold button retail draws the caption as text.
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
// ── Size the channel button to its label + reflow the input field ─
// ── Size the channel button to its label + reflow the input field ─
// Retail's talk-focus button autosizes to the selected channel name; the input
// field then fills the gap from the button's right edge to the Send button. The
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
@ -310,12 +310,12 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
ReflowInputRow();
}
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
{
// The dat puts max/min and the scrollbar up-button at the SAME X (both
// right-anchored), so at content width they overlap. Retail shows max/min
// just LEFT of the scrollbar column — shift it one button-width left.
// just LEFT of the scrollbar column shift it one button-width left.
if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.ResetAnchorCapture();
@ -326,7 +326,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
return c;
}
// ── Max/min implementation ─────────────────────────────────────────────
// ── Max/min implementation ─────────────────────────────────────────────
/// <summary>
/// Attach the typed outer-frame handle after the controller's imported content
@ -419,7 +419,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
return null;
}
// ── Helpers ────────────────────────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
@ -449,7 +449,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
}
// Word-wrap each message to the transcript's current pixel width (ports retail
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
// GlyphList::Recalculate @0x473800 break at word boundaries when the line would
// exceed wrapWidth). The cache key re-evaluates it after window resize.
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
@ -486,8 +486,8 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
/// hyphen), packed onto the current line first so a long unbroken token (e.g. a URL
/// or "wwwww") wraps instead of overflowing, and a "You say," prefix stays on the same
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
/// </summary>
@ -510,7 +510,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
yield return line.ToString(); // word fits alone push to a new line
line.Clear();
line.Append(word);
continue;
@ -532,7 +532,7 @@ internal sealed class ChatWindowController : IRetainedWindowStateController, IRe
}
/// <summary>
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
/// Per-<see cref="ChatKind"/> text color the EXACT retail RGBA values read from a
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).