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:
commit
c83fd02642
94 changed files with 16216 additions and 199 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue