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();