merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch

Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 09:29:53 +02:00
commit c83fd02642
94 changed files with 16216 additions and 199 deletions

View file

@ -612,6 +612,10 @@ public sealed class GameWindow : IDisposable
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
@ -862,12 +866,14 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames;
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
}
@ -972,8 +978,10 @@ public sealed class GameWindow : IDisposable
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
_mouseSource = new AcDream.App.Input.SilkMouseSource(
firstMouse,
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false),
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|| (_uiHost?.Root.WantsKeyboard ?? false));
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
@ -1054,7 +1062,8 @@ public sealed class GameWindow : IDisposable
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
// surviving direct-mouse handler. Suppresses RMB orbit /
// FlyCamera look while ImGui has the mouse focus.
if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false))
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
@ -1744,6 +1753,175 @@ public sealed class GameWindow : IDisposable
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
// Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
// UiHost retained-mode tree (dormant until now) + a first vitals panel.
// Render-only: UiHost input is NOT yet bridged to the InputDispatcher
// (next sub-phase), so the close button + window drag are inert. Coexists
// with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
// pointer is over a widget — no double-handling.
foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
return (t, w, h);
}
// Phase D.2b — optional retail stylesheet. controls.ini lives under
// the AC install (ACDREAM_AC_DIR); absent → source-verified fallback.
var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
// Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000,
// Latin-1, 16px, outline atlas). Passed into the importer so the meter
// number overlay renders through the dat-font two-pass blit; falls back to
// the debug font only if it fails to load. Under _datLock like other reads.
AcDream.App.UI.UiDatFont? vitalsDatFont;
lock (_datLock)
vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
Console.WriteLine(vitalsDatFont is not null
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
// Phase D.2b — the vitals window is data-driven from the dat LayoutDesc
// (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml
// markup path was retired after the importer proved pixel-identical at the
// 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels.
AcDream.App.UI.Layout.ImportedLayout? imported;
lock (_datLock)
imported = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont);
if (imported is not null)
{
AcDream.App.UI.Layout.VitalsController.Bind(imported,
healthPct: () => _vitalsVm!.HealthPercent,
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
// Top-level retail window: user-positioned (Anchors.None so the per-frame
// anchor pass doesn't reset it), movable, and horizontally resizable like
// retail. On a width change the dat edge-anchors reflow the pieces
// (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges +
// the three bars stretch, corners stay 5px, the right edge/corners track
// the right side. Vertical resize is off (the layout has no vertical stretch).
var vitalsRoot = imported.Root;
vitalsRoot.Left = 10; vitalsRoot.Top = 30;
vitalsRoot.ClickThrough = false;
vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
vitalsRoot.Draggable = true;
vitalsRoot.Resizable = true;
vitalsRoot.ResizeX = true;
vitalsRoot.ResizeY = false;
vitalsRoot.MinWidth = 40f;
_uiHost.Root.AddChild(vitalsRoot);
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
}
else
{
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
AcDream.App.UI.Layout.ElementInfo? chatRootInfo;
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
lock (_datLock)
{
chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos(
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId);
chatLayout = chatRootInfo is null ? null
: AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont);
}
if (chatRootInfo is not null && chatLayout is not null)
{
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
chatRootInfo, chatLayout, retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null)
{
// Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the
// keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard
// is set by WireKeyboard above — it is non-null here.
chatController.Transcript.Keyboard = _uiHost.Keyboard;
chatController.Input.Keyboard = _uiHost.Keyboard;
// Wrap the dat content in the universal 8-piece beveled window chrome —
// the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
// layout only carries flat background sprites, so without this the window
// has no retail-style border (the user asked for the vitals border). The
// nine-slice IS the movable/resizable window; the dat content fills its
// interior, inset by the border. The gmMainChatUI content is authored 490
// wide (its transcript/input panels) — KEEP that width + the dat-authored
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
// fills) capture correct margins on first layout; resizing the frame reflows
// them correctly from there.
const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border;
var chatRoot = chatController.Root;
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = 10, Top = 440,
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
MinWidth = 200f, MinHeight = 90f,
// Retail chat is translucent — fade the window's backgrounds/chrome
// (text stays opaque). Configurable opacity is a later step; 0.75 reads
// as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0)
Opacity = 0.75f,
};
chatRoot.Left = chatBorder; chatRoot.Top = chatBorder;
chatRoot.Width = contentW; chatRoot.Height = contentH;
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
chatRoot.Draggable = false; chatRoot.Resizable = false;
chatFrame.AddChild(chatRoot);
_uiHost.Root.AddChild(chatFrame);
// Tab / Enter enters "write mode" by focusing this input (retail's chat
// activation); a focused input suppresses character movement (see the
// WantsKeyboard gate in the movement poll).
_uiHost.Root.DefaultTextInput = chatController.Input;
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
}
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
}
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
// Drain plugin-registered markup panels (buffered before the GL
// window opened) into the same UiRoot tree. A faulty plugin markup
// file is isolated — logged + skipped, never crashes the client.
if (_uiRegistry is not null)
{
foreach (var p in _uiRegistry.Drain())
{
try
{
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
pluginXml, p.Binding, ResolveChrome, controls);
_uiHost.Root.AddChild(pluginPanel);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
}
catch (Exception ex)
{
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
}
}
}
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// always construct.
@ -7099,6 +7277,11 @@ public sealed class GameWindow : IDisposable
// this guard adds defense-in-depth for the per-frame IsActionHeld
// movement poll below (typing "walk" into a chat field shouldn't
// walk).
// ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a
// debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block
// below still runs so AUTORUN keeps driving the character while you type. Held WASD
// is silenced at the source instead — InputDispatcher.IsActionHeld returns false
// while WantCaptureKeyboard (which includes a focused chat input) is set.
bool suppressGameInput =
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
if (suppressGameInput) return;
@ -8476,6 +8659,16 @@ public sealed class GameWindow : IDisposable
SkipWorldGeometry: ;
}
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
// blend/depth state and restores. Drawn before ImGui so the devtools
// overlay composites on top during development.
if (_options.RetailUi && _uiHost is not null)
{
_uiHost.Tick(deltaSeconds);
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
// so ImGui composites on top. ImGuiController save/restores the
// GL state it touches (blend, scissor, VAO, shader, texture); any
@ -12496,6 +12689,7 @@ public sealed class GameWindow : IDisposable
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_uiHost?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();
_dats?.Dispose();

View file

@ -7,10 +7,13 @@ uniform sampler2D uTex;
uniform int uUseTexture;
void main() {
if (uUseTexture != 0) {
if (uUseTexture == 1) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else if (uUseTexture == 2) {
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
FragColor = texture(uTex, vUv) * vColor;
} else {
FragColor = vColor;
}

View file

@ -25,14 +25,39 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
// SAME texture batch into one segment; a texture change starts a new segment.
// Drawing segments in submission order preserves painter z-order for
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
private readonly List<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
public bool OverlayMode { get; set; }
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
@ -56,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
_whiteTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -64,17 +103,32 @@ public sealed unsafe class TextRenderer : IDisposable
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0;
_rectVerts = 0;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
_rectVerts += 6;
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
}
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
@ -119,16 +173,47 @@ public sealed unsafe class TextRenderer : IDisposable
if (gw > 0 && gh > 0)
{
AppendQuad(_textBuf,
gx, gy, gw, gh,
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
}
cursorX += g.Advance;
}
}
/// <summary>
/// Draw a textured sprite quad in screen pixel space with an explicit
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
/// </summary>
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
SpriteSeg seg = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
}
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
{
if (used > 0 && segs[used - 1].Texture == texture)
return segs[used - 1];
if (used < segs.Count)
{
var s = segs[used++];
s.Texture = texture;
s.Verts.Clear();
return s;
}
var ns = new SpriteSeg { Texture = texture };
segs.Add(ns);
used++;
return ns;
}
private static void AppendQuad(List<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
@ -159,7 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
if (_textVerts == 0 && _rectVerts == 0) return;
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
@ -171,36 +258,85 @@ public sealed unsafe class TextRenderer : IDisposable
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Untextured rects first — they form panel backgrounds.
if (_rectVerts > 0)
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// composite on top, in that order. The OVERLAY layer repeats all three
// AFTER the normal layer, so open popups beat even the rect backgrounds.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
// Restore GL state.
_gl.DepthMask(true);
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
_gl.BindVertexArray(0);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font)
{
// 1. RGBA dat sprites — one draw call per distinct GL texture.
if (segUsed > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(_rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
_shader.SetInt("uUseTexture", 2);
_gl.ActiveTexture(TextureUnit.Texture0);
_shader.SetInt("uTex", 0);
for (int i = 0; i < segUsed; i++)
{
var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
}
}
// Textured text glyphs.
if (_textVerts > 0 && font is not null)
// 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
}
// 3. Textured debug-font text glyphs on top.
if (textVerts > 0 && font is not null)
{
_shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
UploadBuffer(_textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
}
// Restore GL state.
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
private void UploadBuffer(List<float> buf)
@ -223,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
_shader.Dispose();

View file

@ -14,6 +14,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
/// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
@ -30,6 +31,12 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle;
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, uint> _handlesByRenderSurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
@ -80,6 +87,65 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h;
}
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
public uint GetOrUpload(uint surfaceId, out int width, out int height)
{
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing)
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
{
width = sz.w; height = sz.h;
return existing;
}
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
return h;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// for world-geometry materials. This is the correct path for retail UI
/// chrome + font glyph sheets, which reference RenderSurface directly.
/// Palette is null for now (a paletted INDEX16/P8 UI sprite would return
/// Magenta — wire a UI palette when one is actually encountered). Returns a
/// 1x1 magenta handle on miss.
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
{
width = sz.w; height = sz.h;
return existing;
}
DecodedTexture decoded;
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
}
else
{
decoded = DecodedTexture.Magenta;
}
uint h = UploadRgba8(decoded, nearest);
_handlesByRenderSurfaceId[renderSurfaceId] = h;
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
return h;
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
@ -476,7 +542,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return composed;
}
private uint UploadRgba8(DecodedTexture decoded)
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex);
@ -493,8 +559,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
PixelType.UnsignedByte,
p);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);

View file

@ -2044,7 +2044,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-37) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)