feat(ui): Phase D.2a — VitalsPanel wired into GameWindow + backend pivot

Closes Phase D.2a. Launch with ACDREAM_DEVTOOLS=1 now shows a live
ImGui "Vitals" window whose HP bar reads CombatState.GetHealthPercent
for the local player. Without the env var the branches are dead code,
no ImGui context is created, and behaviour is identical to before.

GameWindow hunks:
  - fields: _imguiBootstrap / _panelHost / _vitalsVm + DevToolsEnabled
  - init (OnLoad): construct bootstrap + host, register VitalsPanel
  - GUID push: _vitalsVm?.SetLocalPlayerGuid(chosen.Id) at live-connect
  - frame begin: _imguiBootstrap.BeginFrame(dt) after GL clear
  - frame end: _panelHost.RenderAll(ctx) + _imguiBootstrap.Render() after debug overlay
  - input gating: skip WASD when ImGui.GetIO().WantCaptureKeyboard

Backend pivot: Hexa.NET.ImGui → ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui.

First-light integration with the Hexa backend crashed 0xC0000005 inside
Hexa.NET.ImGui.Backends.OpenGL3.ImGuiImplOpenGL3.InitNative. Root cause:
Hexa's native OpenGL3 backend resolves GL function pointers via GLFW or
SDL internally; with Silk.NET (which uses neither) the pointers are null
and the native code crashes on first use. The mitigation path was
already planned — the design doc's Risk section called a pivot to
ImGui.NET a "one-morning operation" — and that's exactly what happened.

  - Packages: Hexa.NET.ImGui 2.2.9 + Hexa.NET.ImGui.Backends 1.0.18
    → ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0
  - ImGuiBootstrapper: was static Initialize(gl)+Shutdown() wrapping
    Hexa's OpenGL3 init; now an IDisposable wrapping Silk.NET's
    ImGuiController instance which handles GL backend init + input
    subscription in one go.
  - SilkInputBridge.cs deleted (~190 LOC): ImGuiController subscribes
    IKeyboard / IMouse events itself, we don't need a bespoke bridge.
  - ImGuiPanelRenderer: ImGuiNET.ImGui.* calls instead of
    Hexa.NET.ImGui.ImGui.*. Widget surface unchanged.

Boundary discipline is preserved — no panel imports ImGuiNET; only
ImGuiPanelRenderer does. The D.2b custom toolkit will implement the
same IPanelRenderer contract without touching panel code.

Out of scope (tracked for follow-up):
  - Stam/Mana currently return float? null (VitalsVM). Absolute values
    need LocalPlayerState + PlayerDescription (0x0013) parsing to be
    stored rather than discarded — filed as a post-D.2a issue.
  - Mouse-capture gating (WorldMouseFallThrough-style click-through
    tests) — not needed until we add clickable inventory items.

Roadmap + memory + architecture doc + UI framework plan updated in the
same commit per CLAUDE.md roadmap-discipline rules. 753 tests pass
(550 Core + 192 Core.Net + 11 new UI.Abstractions), 0 build warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 00:43:46 +02:00
parent a7dbce3474
commit 55aaca7a14
13 changed files with 218 additions and 275 deletions

4
.gitignore vendored
View file

@ -23,3 +23,7 @@ references/
# Claude Code session state
.claude/
launch.log
launch-*.log
# ImGui auto-saved window/docking state (per-user, not source)
imgui.ini

View file

@ -25,13 +25,14 @@ The codebase is organized by layer (see architecture doc). Current phase
state lives in memory (`memory/project_*.md`), plans in `docs/plans/`,
research in `docs/research/`.
**UI strategy:** three-layer split — swappable backend (Hexa.NET.ImGui for
Phase D.2a short-term, custom retail-look toolkit for D.2b later) /
stable `AcDream.UI.Abstractions` layer (ViewModels + Commands + `IPanel`
/ `IPanelRenderer`) / unchanged game state. **All plugin-facing UI
targets `AcDream.UI.Abstractions` — never import a backend namespace
from a panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`.
Memory crib: `memory/project_ui_architecture.md`.
**UI strategy:** three-layer split — swappable backend (ImGui.NET +
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a short-term, custom
retail-look toolkit for D.2b later) / stable `AcDream.UI.Abstractions`
layer (ViewModels + Commands + `IPanel` / `IPanelRenderer`) / unchanged
game state. **All plugin-facing UI targets `AcDream.UI.Abstractions`
never import a backend namespace from a panel.** Full design:
`docs/plans/2026-04-24-ui-framework.md`. Memory crib:
`memory/project_ui_architecture.md`.
## How to operate

View file

@ -74,7 +74,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`.
```
┌─────────────────────────────────────────────────────────────┐
│ UI BACKEND (swappable) │
│ Hexa.NET.ImGui (Phase D.2a, short-term) │
│ ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui │
│ (Phase D.2a, short-term) │
│ or custom retail-look toolkit (Phase D.2b, later) │
├─────────────────────────────────────────────────────────────┤
│ AcDream.UI.Abstractions (stable contract) │

View file

@ -46,6 +46,7 @@
| G.1+ | Full sky visuals + weather + dynamic-light shader — SkyDescLoader parses Region 0x13000000 dat keyframes with retail fog fields (start/end/mode); WeatherSystem picks Clear/Overcast/Rain/Snow/Storm deterministically per in-game day with 10s fade; SkyRenderer draws far-plane-1e6 celestial meshes with UV scroll; SceneLightingUbo binds at std140 location=1 with 8 Light slots + fog + lightning flash; terrain.vert + mesh.frag + mesh_instanced.frag + sky.frag all consume the shared UBO; LightingHookSink auto-registers Setup.Lights per entity + flips IsLit on SetLightHook; ParticleRenderer renders rain/snow billboards; F7 cycles day time override, F10 cycles weather; WorldSession surfaces server time via ServerTimeUpdated (ConnectRequest + TimeSync flag) | Tests ✓ |
| H.1 | Chat wire layer — Talk (0x0015) / Tell (0x005D) / ChatChannel (0x0147) outbound, HearSpeech (0x02BB local + 0x02BC ranged) inbound, ChatLog ring buffer with adapters for every chat source | Tests ✓ |
| Glue | GameEventWiring.WireAll — single-call registration mapping parsed GameEvents → Core state classes (ChatLog, CombatState, Spellbook, ItemRepository); GameWindow exposes state classes + wires them to live session | Tests ✓ |
| D.2a | UI scaffold — `AcDream.UI.Abstractions` stable contract (`IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + `VitalsVM` / `VitalsPanel`); `AcDream.UI.ImGui` backend on ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` (pivoted from Hexa.NET.ImGui on 2026-04-25 — Hexa's native OpenGL3 backend resolves GL via GLFW/SDL and crashed 0xC0000005 without them); VitalsPanel wired into GameWindow behind `ACDREAM_DEVTOOLS=1` with `ImGui.WantCaptureKeyboard` WASD suppression. 11 new tests. | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -126,9 +127,11 @@ Plus polish that doesn't get its own phase number:
> [`docs/plans/2026-04-24-ui-framework.md`](2026-04-24-ui-framework.md)
> for the full design. Short version:
>
> 1. **D.2a — Hexa.NET.ImGui as the short-term backend.** Wire up in days,
> 1. **D.2a — ImGui as the short-term backend.** Wire up in days,
> iterate game logic (chat-send, inventory actions, vitals HUD reading
> real state) in weeks. Looks like a debugger; that's fine.
> real state) in weeks. Looks like a debugger; that's fine. *(Shipped
> 2026-04-25 on ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` after a
> day-one pivot away from Hexa.NET.ImGui; see shipped table.)*
> 2. **Stable `AcDream.UI.Abstractions` layer** — ViewModels + Commands +
> `IPanel` / `IPanelRenderer` interfaces. Backend-agnostic. Plugin API
> publishes against this layer and never sees ImGui.
@ -142,7 +145,7 @@ Plus polish that doesn't get its own phase number:
**Sub-pieces:**
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
- **D.2a — Hexa.NET.ImGui scaffold + `AcDream.UI.Abstractions` layer.** NEW pre-piece introduced 2026-04-24. Wires Hexa.NET.ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModels (`VitalsVM` etc.) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP/stam/mana from `IGameState`. This is what gets game-logic iteration moving; looks like a debugger, acceptable.
- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green.
- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`.
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)**

View file

@ -1,13 +1,51 @@
# UI framework plan
**Date:** 2026-04-24
**Status:** design — not yet implemented
**Date:** 2026-04-24 (design), shipped 2026-04-25
**Status:** **Phase D.2a shipped**`AcDream.UI.Abstractions` + ImGui backend
+ `VitalsPanel` gated on `ACDREAM_DEVTOOLS=1`. Backend pivoted from
`Hexa.NET.ImGui` to `ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui` during
first-light integration — see the pivot note below. Phase D.2b (custom
retail-look backend) remains design-only.
**Owner:** lead engineer (erik) + Claude
Captures the UI strategy agreed via discussion on 2026-04-24. Documents
the choices AND the alternatives considered so future sessions can
re-evaluate with the same context.
## 2026-04-25 pivot: Hexa.NET.ImGui → ImGui.NET
The original choice (documented below) was `Hexa.NET.ImGui` +
`Hexa.NET.ImGui.Backends.OpenGL3`. It did not survive first-light
integration:
- First launch with Hexa's backend crashed with `0xC0000005` inside
`Hexa.NET.ImGui.Backends.OpenGL3.ImGuiImplOpenGL3.InitNative`.
- Root cause: Hexa's native OpenGL3 backend does its own GL function
resolution, looking up symbols via GLFW or SDL. Silk.NET uses neither,
so the resolved function pointers were null and the native code
dereferenced them on init.
- Hexa's Silk.NET examples rely on GLFW being co-loaded (its default
on Hexa's own scenes) — not applicable here.
**Mitigation path** was already written into this doc (§"What we give
up": *"switching to ImGui.NET later is a one-morning operation if Hexa
misbehaves"*) and taken:
- Packages swapped → `ImGui.NET 1.91.6.1` + `Silk.NET.OpenGL.Extensions.ImGui 2.23.0`.
- `Silk.NET.OpenGL.Extensions.ImGui.ImGuiController` handles the whole
integration (GL backend init against the Silk.NET GL binding,
keyboard + mouse IO event subscription). No hand-written input
bridge needed.
- `ImGuiBootstrapper` is a ~10-line `IDisposable` wrapping the
`ImGuiController` instance. `ImGuiPanelRenderer` wraps `ImGuiNET.ImGui.*`.
- Boundary discipline preserved — panels never import `ImGuiNET`
directly; they only use `IPanelRenderer`. The backend swap is
invisible above the abstraction layer, as designed.
Sections below from §"Choice: Hexa.NET.ImGui" onward are kept as the
historical design reasoning. They remain useful if we ever re-evaluate
native AOT / upstream-tracking tradeoffs.
## Goal
acdream needs a playable game UI: chat, vitals HUD, inventory, character

View file

@ -1,10 +1,12 @@
# Retail AC Client GUI — Master Synthesis
> **Scope note (2026-04-24):** This document describes retail's Keystone
> UI toolkit — it is the research foundation for **Phase D.2b (custom
> retail-look backend)**, not **Phase D.2a (Hexa.NET.ImGui scaffold)**.
> When reading this for implementation guidance, assume D.2a has shipped
> a working `AcDream.UI.Abstractions` layer (`IPanel`, `IPanelRenderer`,
> **Scope note (2026-04-24, updated 2026-04-25):** This document
> describes retail's Keystone UI toolkit — it is the research foundation
> for **Phase D.2b (custom retail-look backend)**, not Phase D.2a
> (shipped ImGui scaffold, `AcDream.UI.Abstractions` + ImGui.NET +
> `Silk.NET.OpenGL.Extensions.ImGui` + `VitalsPanel`). When reading this
> for implementation guidance, assume D.2a has shipped a working
> `AcDream.UI.Abstractions` layer (`IPanel`, `IPanelRenderer`,
> ViewModels, Commands) and you are building the custom retained-mode
> toolkit that implements the same contracts using dat-sourced fonts /
> sprites / cursors. See `docs/plans/2026-04-24-ui-framework.md` for the

View file

@ -15,10 +15,14 @@
└─────────────────────────────────────────┘
```
- **UI backend** (bottom swap axis): `Hexa.NET.ImGui` for Phase D.2a
- **UI backend** (bottom swap axis): **`ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui`** for Phase D.2a
(short-term, debugger-look, validates game logic fast). Custom
retail-look toolkit for Phase D.2b (long-term, uses dat assets).
ImGui stays **forever** as the `ACDREAM_DEVTOOLS=1` overlay.
(Pivoted from `Hexa.NET.ImGui` on 2026-04-25 — Hexa's native OpenGL3
backend resolves GL via GLFW/SDL internally and crashed `0xC0000005`
against Silk.NET; the Silk.NET extension is purpose-built for this
stack.)
- **ViewModels + Commands** (the stable contract): per-panel data
records (`VitalsVM`, `InventoryVM`, `ChatVM`, …) and action records
(`UseItemCmd`, `SendChatCmd`, `CastSpellCmd`, …). Lives in
@ -32,16 +36,19 @@
- `src/AcDream.UI.Abstractions/``IPanel`, `IPanelHost`,
`IPanelRenderer`, `ICommandBus`, all ViewModels + Commands. Backend-
agnostic.
- `src/AcDream.UI.ImGui/` — Hexa.NET.ImGui-based implementation of
`IPanelRenderer` + ImGui bootstrap. Phase D.2a.
- `src/AcDream.UI.ImGui/``ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui`
implementation of `IPanelRenderer` + ImGui bootstrap. Phase D.2a.
`ImGuiController` (from the Silk.NET extension) handles GL backend +
input event subscription; `ImGuiBootstrapper` is a thin IDisposable
wrapper; `ImGuiPanelRenderer` wraps the widgets `IPanelRenderer` needs.
- `src/AcDream.UI.Retail/` (later) — custom retained-mode toolkit using
dat assets, same `IPanelRenderer` contract. Phase D.2b.
## Hard rules
1. **No panel references a backend namespace.** If a panel imports
`Hexa.NET.ImGui` or a custom-toolkit widget class directly, it's a
bug.
`ImGuiNET` / `Silk.NET.OpenGL.Extensions.ImGui` or a custom-toolkit
widget class directly, it's a bug — extend `IPanelRenderer` instead.
2. **Plugin API targets the abstraction layer only.** Plugins define
`IPanel` instances; they never see which backend draws them.
3. **Features that only ImGui can express → not in `IPanelRenderer`.**

View file

@ -24,6 +24,8 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Rendering\Shaders\*.*">

View file

@ -281,6 +281,14 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
public readonly AcDream.Core.Items.ItemRepository Items = new();
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private static readonly bool DevToolsEnabled =
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
@ -870,6 +878,35 @@ public sealed class GameWindow : IDisposable
}
}
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
if (DevToolsEnabled)
{
try
{
_imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
_panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless.
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat);
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel registered)");
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
_imguiBootstrap?.Dispose();
_imguiBootstrap = null;
_panelHost = null;
_vitalsVm = null;
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -1149,6 +1186,7 @@ public sealed class GameWindow : IDisposable
var chosen = _liveSession.Characters.Characters[0];
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
_vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0);
@ -3502,6 +3540,13 @@ public sealed class GameWindow : IDisposable
var kb = _input.Keyboards[0];
// Phase D.2a — suppress game-side WASD / interaction polling when
// ImGui has keyboard focus (e.g. a text field is active). Without
// this, typing "walk" into a chat field would actually walk.
bool suppressGameInput =
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
if (suppressGameInput) return;
if (_cameraController.IsFlyMode)
{
_cameraController.Fly.Update(
@ -3709,6 +3754,12 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
if (DevToolsEnabled && _imguiBootstrap is not null)
_imguiBootstrap.BeginFrame((float)deltaSeconds);
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
if (_animatedEntities.Count > 0)
@ -4002,6 +4053,20 @@ public sealed class GameWindow : IDisposable
}
}
// 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
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
// today) would need manual protection.
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
{
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
AcDream.UI.Abstractions.NullCommandBus.Instance);
_panelHost.RenderAll(ctx);
_imguiBootstrap.Render();
}
// Update the window title with performance stats every ~0.5s.
_perfAccum += deltaSeconds;
_perfFrameCount++;

View file

@ -7,15 +7,20 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Hexa.NET.ImGui backend — chosen over ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui
for AOT-ready native-lib bundling and closer tracking of upstream cimgui.
See docs/plans/2026-04-24-ui-framework.md §"Choice: Hexa.NET.ImGui". -->
<PackageReference Include="Hexa.NET.ImGui" Version="2.2.9" />
<PackageReference Include="Hexa.NET.ImGui.Backends" Version="1.0.18" />
<!-- SilkInputBridge references Silk.NET.Input types directly so we need
the package even though AcDream.App transitively has it. -->
<!-- Pivoted from Hexa.NET.ImGui to ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui
on 2026-04-25 because Hexa's native OpenGL3 backend does its own GL
function resolution via GLFW / SDL internally; with Silk.NET (no GLFW/SDL)
it crashed in InitNative with 0xC0000005. The Silk.NET extension is
purpose-built for this scenario — it wraps ImGui.NET's backend using
Silk.NET's own GL binding. See docs/plans/2026-04-24-ui-framework.md
"Risk: Hexa.NET.ImGui stops being maintained → mitigation: switch to
ImGui.NET is a one-morning operation." This WAS the one-morning
operation, triggered by a different Hexa issue (GL-loader absence). -->
<PackageReference Include="ImGui.NET" Version="1.91.6.1" />
<PackageReference Include="Silk.NET.OpenGL.Extensions.ImGui" Version="2.23.0" />
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />

View file

@ -1,62 +1,64 @@
using Hexa.NET.ImGui;
using Hexa.NET.ImGui.Backends.OpenGL3;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ImGui;
using Silk.NET.Windowing;
namespace AcDream.UI.ImGui;
/// <summary>
/// One-shot ImGui setup / teardown for the devtools overlay. Called from
/// <c>GameWindow</c> when <c>ACDREAM_DEVTOOLS=1</c>. Hides the cimgui
/// context + OpenGL3 renderer-impl lifecycles behind two static methods
/// so the calling code stays clean.
/// Owns the <c>ImGuiController</c> from <c>Silk.NET.OpenGL.Extensions.ImGui</c>,
/// which handles the whole Silk.NET ↔ ImGui.NET integration:
/// <list type="bullet">
/// <item>Creates the ImGui context + OpenGL3 backend using Silk.NET's GL binding
/// (no GLFW / SDL dependency — unlike Hexa.NET.ImGui, which assumed one).</item>
/// <item>Subscribes to Silk.NET's window + input events to drive IO.</item>
/// <item>Per frame: <c>Update(dt)</c> calls <c>ImGui.NewFrame()</c>; <c>Render()</c>
/// calls <c>ImGui.Render()</c> + uploads draw data via its OpenGL3 backend.</item>
/// </list>
///
/// <para>
/// Intentionally <b>not</b> an <c>IDisposable</c> singleton — the host
/// window owns the one call to <see cref="Shutdown"/> at application
/// exit. Re-initialisation mid-session is not supported.
/// Instance-scoped rather than static so GL-context lifetime is explicit.
/// <c>GameWindow</c> owns the one instance and disposes on shutdown.
/// </para>
///
/// <para>
/// History: tried <c>Hexa.NET.ImGui</c> + <c>Hexa.NET.ImGui.Backends.OpenGL3</c> first
/// per the original plan, but its native OpenGL3 backend resolves GL functions
/// via GLFW / SDL internally and crashed (0xC0000005) in <c>InitNative</c> without
/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25.
/// </para>
/// </summary>
public static class ImGuiBootstrapper
public sealed class ImGuiBootstrapper : IDisposable
{
private static bool _initialized;
private readonly ImGuiController _controller;
public ImGuiBootstrapper(GL gl, IView window, IInputContext input)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(input);
// ImGuiController constructor handles:
// - ImGui.CreateContext()
// - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL)
// - Keyboard + mouse event subscription (bound to Silk.NET IInputContext)
// - Default style = dark
_controller = new ImGuiController(gl, window, input);
}
/// <summary>
/// Create an ImGui context, apply the dark style + enable keyboard
/// navigation, and bootstrap the OpenGL3 renderer backend. The GL
/// context owned by Silk.NET must be current on the calling thread.
/// Begin an ImGui frame. Call BEFORE any <c>ImGui.*</c> widget calls.
/// Internally: consumes buffered input events, calls <c>ImGui.NewFrame()</c>.
/// </summary>
/// <param name="glslVersion">
/// GLSL version directive for the ImGui-internal shader.
/// <c>"#version 330"</c> matches acdream's existing shaders and is
/// the safest default for the OpenGL 4.3 core profile we ship.
/// </param>
public static void Initialize(string glslVersion = "#version 330")
{
if (_initialized) return;
public void BeginFrame(float deltaSeconds) => _controller.Update(deltaSeconds);
Hexa.NET.ImGui.ImGui.CreateContext();
Hexa.NET.ImGui.ImGui.StyleColorsDark();
/// <summary>
/// Finalise the ImGui frame and draw to the framebuffer. Call AFTER all
/// panel draws, within the same frame as <see cref="BeginFrame"/>. The
/// OpenGL3 backend save/restores the GL state it touches (shader, VAO,
/// texture, blend, scissor); state not in its save-list (e.g.
/// <c>GL_FRAMEBUFFER_SRGB</c>) is caller's responsibility.
/// </summary>
public void Render() => _controller.Render();
var io = Hexa.NET.ImGui.ImGui.GetIO();
io.ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard;
// DO NOT enable NavEnableGamepad — we don't wire a gamepad backend.
// DO NOT enable DockingEnable / ViewportsEnable — out of scope for D.2a.
ImGuiImplOpenGL3.Init(glslVersion);
_initialized = true;
}
/// <summary>Tear down the OpenGL3 renderer + destroy the ImGui context.</summary>
public static void Shutdown()
{
if (!_initialized) return;
ImGuiImplOpenGL3.Shutdown();
Hexa.NET.ImGui.ImGui.DestroyContext();
_initialized = false;
}
/// <summary>True after <see cref="Initialize"/> has run successfully.</summary>
public static bool IsInitialized => _initialized;
public void Dispose() => _controller.Dispose();
}

View file

@ -1,31 +1,32 @@
using System.Numerics;
using AcDream.UI.Abstractions;
using ImGuiNET;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelRenderer"/> implemented as thin wrappers around
/// Hexa.NET.ImGui calls. This is the ONLY place where Hexa.NET.ImGui
/// types appear outside of bootstrap / input-bridge plumbing — panels
/// that need a feature must extend the abstraction here, not by importing
/// ImGui in panel files.
/// ImGui.NET calls. This is the ONLY place where ImGuiNET types appear
/// outside of bootstrap plumbing — panels that need a feature must
/// extend the abstraction here, not by importing ImGuiNET in panel
/// files.
/// </summary>
public sealed class ImGuiPanelRenderer : IPanelRenderer
{
/// <inheritdoc />
public bool Begin(string title) => Hexa.NET.ImGui.ImGui.Begin(title);
public bool Begin(string title) => ImGuiNET.ImGui.Begin(title);
/// <inheritdoc />
public void End() => Hexa.NET.ImGui.ImGui.End();
public void End() => ImGuiNET.ImGui.End();
/// <inheritdoc />
public void Text(string text) => Hexa.NET.ImGui.ImGui.TextUnformatted(text);
public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text);
/// <inheritdoc />
public void SameLine() => Hexa.NET.ImGui.ImGui.SameLine();
public void SameLine() => ImGuiNET.ImGui.SameLine();
/// <inheritdoc />
public void Separator() => Hexa.NET.ImGui.ImGui.Separator();
public void Separator() => ImGuiNET.ImGui.Separator();
/// <inheritdoc />
public void ProgressBar(float fraction, float width, string? overlay = null)
@ -36,6 +37,6 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
else if (fraction > 1f) fraction = 1f;
var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font
Hexa.NET.ImGui.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty);
ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty);
}
}

View file

@ -1,188 +0,0 @@
using System.Numerics;
using Hexa.NET.ImGui;
using Silk.NET.Input;
namespace AcDream.UI.ImGui;
/// <summary>
/// Forwards Silk.NET keyboard / mouse events to ImGui's IO. Replaces what
/// you'd get from the stock GLFW or SDL backends in a non-Silk.NET host.
///
/// <para>
/// Event-driven (we subscribe to Silk.NET events); does not poll. Each
/// handler writes directly to <c>ImGui.GetIO()</c> via the <c>AddXxx</c>
/// family of calls. Frame-start book-keeping (display size, delta time,
/// active modifier latch) happens in <see cref="BeginFrame"/>.
/// </para>
///
/// <para>
/// Call <see cref="Dispose"/> at app shutdown to unsubscribe from Silk.NET
/// events.
/// </para>
/// </summary>
public sealed class SilkInputBridge : IDisposable
{
private readonly IInputContext _input;
private readonly IKeyboard? _keyboard;
private readonly IMouse? _mouse;
public SilkInputBridge(IInputContext input)
{
_input = input ?? throw new ArgumentNullException(nameof(input));
_keyboard = input.Keyboards.Count > 0 ? input.Keyboards[0] : null;
_mouse = input.Mice.Count > 0 ? input.Mice[0] : null;
if (_keyboard is not null)
{
_keyboard.KeyDown += OnKeyDown;
_keyboard.KeyUp += OnKeyUp;
_keyboard.KeyChar += OnKeyChar;
}
if (_mouse is not null)
{
_mouse.MouseMove += OnMouseMove;
_mouse.MouseDown += OnMouseDown;
_mouse.MouseUp += OnMouseUp;
_mouse.Scroll += OnScroll;
}
}
/// <summary>
/// Per-frame bookkeeping. Call right before <c>ImGui.NewFrame()</c>.
/// Sets display size (in logical pixels) and delta-time on ImGui's IO.
/// </summary>
public void BeginFrame(Vector2 displaySize, float deltaSeconds)
{
var io = Hexa.NET.ImGui.ImGui.GetIO();
io.DisplaySize = displaySize;
io.DeltaTime = deltaSeconds > 0f ? deltaSeconds : 1f / 60f;
}
// ─── event handlers ──────────────────────────────────────────────
private void OnKeyDown(IKeyboard kb, Key key, int scancode) => AddKey(key, down: true);
private void OnKeyUp (IKeyboard kb, Key key, int scancode) => AddKey(key, down: false);
private void OnKeyChar(IKeyboard kb, char c)
{
// Feeds typed text into any focused ImGui TextField. Safe to call
// even when no TextField has focus — ImGui buffers the character
// and discards it if nothing claims it.
Hexa.NET.ImGui.ImGui.GetIO().AddInputCharacter(c);
}
private void OnMouseMove(IMouse m, Vector2 pos)
{
Hexa.NET.ImGui.ImGui.GetIO().AddMousePosEvent(pos.X, pos.Y);
}
private void OnMouseDown(IMouse m, MouseButton button) => AddMouseButton(button, down: true);
private void OnMouseUp (IMouse m, MouseButton button) => AddMouseButton(button, down: false);
private void OnScroll(IMouse m, ScrollWheel wheel)
{
Hexa.NET.ImGui.ImGui.GetIO().AddMouseWheelEvent(wheel.X, wheel.Y);
}
// ─── helpers ─────────────────────────────────────────────────────
private static void AddKey(Key key, bool down)
{
// Update modifier latches first (ImGui reads these when any AddKeyEvent fires).
var io = Hexa.NET.ImGui.ImGui.GetIO();
if (key is Key.ControlLeft or Key.ControlRight) io.AddKeyEvent(ImGuiKey.ModCtrl, down);
if (key is Key.ShiftLeft or Key.ShiftRight) io.AddKeyEvent(ImGuiKey.ModShift, down);
if (key is Key.AltLeft or Key.AltRight) io.AddKeyEvent(ImGuiKey.ModAlt, down);
if (key is Key.SuperLeft or Key.SuperRight) io.AddKeyEvent(ImGuiKey.ModSuper, down);
if (KeyMap.TryGetValue(key, out var imguiKey))
io.AddKeyEvent(imguiKey, down);
// Unmapped keys are silently ignored — fine for D.2a; panels that
// need exotic keys can extend the map.
}
private static void AddMouseButton(MouseButton button, bool down)
{
int idx = button switch
{
MouseButton.Left => 0,
MouseButton.Right => 1,
MouseButton.Middle => 2,
_ => -1,
};
if (idx < 0) return;
Hexa.NET.ImGui.ImGui.GetIO().AddMouseButtonEvent(idx, down);
}
/// <summary>
/// Silk.NET → ImGui key map. Covers text-input + navigation keys +
/// WASD + function keys. Unlisted keys fall through to no-op.
/// </summary>
private static readonly Dictionary<Key, ImGuiKey> KeyMap = new()
{
// Navigation + control
[Key.Tab] = ImGuiKey.Tab,
[Key.Left] = ImGuiKey.LeftArrow,
[Key.Right] = ImGuiKey.RightArrow,
[Key.Up] = ImGuiKey.UpArrow,
[Key.Down] = ImGuiKey.DownArrow,
[Key.PageUp] = ImGuiKey.PageUp,
[Key.PageDown] = ImGuiKey.PageDown,
[Key.Home] = ImGuiKey.Home,
[Key.End] = ImGuiKey.End,
[Key.Insert] = ImGuiKey.Insert,
[Key.Delete] = ImGuiKey.Delete,
[Key.Backspace] = ImGuiKey.Backspace,
[Key.Space] = ImGuiKey.Space,
[Key.Enter] = ImGuiKey.Enter,
[Key.Escape] = ImGuiKey.Escape,
// Modifiers (also add via the mod-flag path, but these let ImGui
// see them as named keys too).
[Key.ControlLeft] = ImGuiKey.LeftCtrl,
[Key.ControlRight] = ImGuiKey.RightCtrl,
[Key.ShiftLeft] = ImGuiKey.LeftShift,
[Key.ShiftRight] = ImGuiKey.RightShift,
[Key.AltLeft] = ImGuiKey.LeftAlt,
[Key.AltRight] = ImGuiKey.RightAlt,
// Letters (Silk.NET.Key.A..Z map 1:1 to ImGuiKey.A..Z).
[Key.A] = ImGuiKey.A, [Key.B] = ImGuiKey.B, [Key.C] = ImGuiKey.C, [Key.D] = ImGuiKey.D,
[Key.E] = ImGuiKey.E, [Key.F] = ImGuiKey.F, [Key.G] = ImGuiKey.G, [Key.H] = ImGuiKey.H,
[Key.I] = ImGuiKey.I, [Key.J] = ImGuiKey.J, [Key.K] = ImGuiKey.K, [Key.L] = ImGuiKey.L,
[Key.M] = ImGuiKey.M, [Key.N] = ImGuiKey.N, [Key.O] = ImGuiKey.O, [Key.P] = ImGuiKey.P,
[Key.Q] = ImGuiKey.Q, [Key.R] = ImGuiKey.R, [Key.S] = ImGuiKey.S, [Key.T] = ImGuiKey.T,
[Key.U] = ImGuiKey.U, [Key.V] = ImGuiKey.V, [Key.W] = ImGuiKey.W, [Key.X] = ImGuiKey.X,
[Key.Y] = ImGuiKey.Y, [Key.Z] = ImGuiKey.Z,
// Digit row
[Key.Number0] = ImGuiKey.Key0, [Key.Number1] = ImGuiKey.Key1, [Key.Number2] = ImGuiKey.Key2,
[Key.Number3] = ImGuiKey.Key3, [Key.Number4] = ImGuiKey.Key4, [Key.Number5] = ImGuiKey.Key5,
[Key.Number6] = ImGuiKey.Key6, [Key.Number7] = ImGuiKey.Key7, [Key.Number8] = ImGuiKey.Key8,
[Key.Number9] = ImGuiKey.Key9,
// Function keys
[Key.F1] = ImGuiKey.F1, [Key.F2] = ImGuiKey.F2, [Key.F3] = ImGuiKey.F3, [Key.F4] = ImGuiKey.F4,
[Key.F5] = ImGuiKey.F5, [Key.F6] = ImGuiKey.F6, [Key.F7] = ImGuiKey.F7, [Key.F8] = ImGuiKey.F8,
[Key.F9] = ImGuiKey.F9, [Key.F10] = ImGuiKey.F10, [Key.F11] = ImGuiKey.F11, [Key.F12] = ImGuiKey.F12,
};
public void Dispose()
{
if (_keyboard is not null)
{
_keyboard.KeyDown -= OnKeyDown;
_keyboard.KeyUp -= OnKeyUp;
_keyboard.KeyChar -= OnKeyChar;
}
if (_mouse is not null)
{
_mouse.MouseMove -= OnMouseMove;
_mouse.MouseDown -= OnMouseDown;
_mouse.MouseUp -= OnMouseUp;
_mouse.Scroll -= OnScroll;
}
}
}