diff --git a/.gitignore b/.gitignore index 1731f2eb..904fdf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AcDream.slnx b/AcDream.slnx index e7fd39ac..1cf8f243 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -6,6 +6,8 @@ + + @@ -14,5 +16,6 @@ + diff --git a/CLAUDE.md b/CLAUDE.md index 84c1d809..96449ed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 38d52ec9..5f2c01a9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -135,6 +135,29 @@ Copy this block when adding a new issue: --- +## #5 — VitalsPanel stamina/mana bars always null (absolute values not stored) + +**Status:** OPEN +**Severity:** LOW (cosmetic — HP bar already works; stam/mana would be a nice-to-have) +**Filed:** 2026-04-25 +**Component:** ui / net / player-state + +**Description:** Phase D.2a shipped `VitalsVM` with `StaminaPercent` / `ManaPercent` returning `float?` null. `VitalsPanel` already renders an HP progress bar from `CombatState.GetHealthPercent(localGuid)` because per-entity health is tracked from combat notifications. Stamina and mana are absolute values and only arrive in `PlayerDescription (0x0013)` — which we currently parse then discard. Result: the Vitals window shows HP only. + +**Root cause / status:** We need a `LocalPlayerState` Core class (analogous to `CombatState` but scoped to the local player) that retains parsed `PlayerDescription` fields — at minimum: `CurrentStamina` + `MaxStamina` + `CurrentMana` + `MaxMana`. `AppraiseInfoParser.CreatureProfile` already has the shape for these values; we just don't persist them. + +**Files:** +- `src/AcDream.Core.Net/Parsers/PlayerDescriptionParser.cs` — parses then discards (verify path) +- `src/AcDream.Core.Net/Parsers/AppraiseInfoParser.cs` — has `CreatureProfile` with absolute values +- `src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs` — `StaminaPercent` / `ManaPercent` would divide `LocalPlayerState.Current*` by `Max*` +- `src/AcDream.App/Rendering/GameWindow.cs` — construct `LocalPlayerState`, hand to `VitalsVM`, wire into event dispatch + +**Research:** none needed — wire-level field positions are already decoded in `PlayerDescriptionParser`. + +**Acceptance:** With `ACDREAM_DEVTOOLS=1`, the Vitals window shows three progress bars (HP / Stamina / Mana) that update when the server sends `PlayerDescription` or any delta event (`UpdateHealth`, `UpdateStamina`, `UpdateMana`). + +--- + # Recently closed *(none yet — move DONE items here with closed-date + commit SHA)* diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index e5f7f856..80536a17 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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) │ diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 15595904..aabe5685 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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.)** diff --git a/docs/plans/2026-04-24-ui-framework.md b/docs/plans/2026-04-24-ui-framework.md index 85d8a41c..e6fce264 100644 --- a/docs/plans/2026-04-24-ui-framework.md +++ b/docs/plans/2026-04-24-ui-framework.md @@ -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 diff --git a/docs/research/retail-ui/00-master-synthesis.md b/docs/research/retail-ui/00-master-synthesis.md index 7882a9cb..0dbbf771 100644 --- a/docs/research/retail-ui/00-master-synthesis.md +++ b/docs/research/retail-ui/00-master-synthesis.md @@ -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 diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 2d6240de..bf5e4ad6 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -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`.** diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 277ae99c..6fb3af04 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -24,6 +24,8 @@ + + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cbd4b27f..ab4479f3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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++; diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj new file mode 100644 index 00000000..0fc070c7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + + + + + diff --git a/src/AcDream.UI.Abstractions/ICommandBus.cs b/src/AcDream.UI.Abstractions/ICommandBus.cs new file mode 100644 index 00000000..71742969 --- /dev/null +++ b/src/AcDream.UI.Abstractions/ICommandBus.cs @@ -0,0 +1,24 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Publishes user-intent commands from panels to the systems that handle +/// them (WorldSession, ChatService, Inventory, ...). Panels never touch +/// those systems directly — they a record +/// and the bus dispatches. +/// +/// +/// D.2a scaffolding: is the default wire-up +/// — commands are accepted but dropped. Real routing lands alongside +/// chat and inventory (Sprint 2 of the UI plan) when we actually need +/// commands flowing server-ward. +/// +/// +public interface ICommandBus +{ + /// + /// Publish a command record. The bus routes by runtime type via + /// registered handlers. Never blocks; handlers run on the publish + /// thread today (render thread for panel-triggered commands). + /// + void Publish(T command) where T : notnull; +} diff --git a/src/AcDream.UI.Abstractions/IPanel.cs b/src/AcDream.UI.Abstractions/IPanel.cs new file mode 100644 index 00000000..5a4a37f7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanel.cs @@ -0,0 +1,37 @@ +namespace AcDream.UI.Abstractions; + +/// +/// A UI panel — chat window, inventory, vitals HUD, character sheet, etc. +/// Panels are backend-agnostic: they only call into +/// primitives, never reach through to a specific UI library (Hexa.NET.ImGui +/// in Phase D.2a, a custom retail-look toolkit in Phase D.2b). +/// +/// +/// Hard rule: no using Hexa.NET.ImGui inside a panel file. If a +/// widget needs a feature the abstraction doesn't expose, extend +/// ; do not import the backend. See +/// docs/plans/2026-04-24-ui-framework.md. +/// +/// +public interface IPanel +{ + /// Stable, globally-unique identifier. Convention: acdream.{name}. + string Id { get; } + + /// Human-readable window title shown in the chrome of the panel. + string Title { get; } + + /// + /// Whether the panel is currently visible. Backends read this per frame; + /// panels may mutate it in response to their own close-button handling. + /// + bool IsVisible { get; set; } + + /// + /// Draw the panel for one frame. Called by + /// on the render thread once ImGui's (or the future custom backend's) + /// frame has begun. Panels issue drawing calls through + /// and publish user-intent actions through .. + /// + void Render(PanelContext ctx, IPanelRenderer renderer); +} diff --git a/src/AcDream.UI.Abstractions/IPanelHost.cs b/src/AcDream.UI.Abstractions/IPanelHost.cs new file mode 100644 index 00000000..bbd685f7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanelHost.cs @@ -0,0 +1,36 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Owns the set of live s and drives per-frame draw +/// dispatch. The backend (Hexa.NET.ImGui in D.2a, custom in D.2b) implements +/// this; GameWindow creates one at startup and registers panels. +/// +/// +/// Does not call ImGui.NewFrame / ImGui.Render — those +/// belong to the caller so GL-state ownership is unambiguous. Caller pattern: +/// +/// +/// +/// // per frame, render thread +/// inputBridge.BeginFrame(size, dt); +/// ImGui.NewFrame(); +/// panelHost.RenderAll(ctx); +/// ImGui.Render(); +/// ImGuiImplOpenGL3.RenderDrawData(ImGui.GetDrawData()); +/// +/// +public interface IPanelHost +{ + /// Register a panel for per-frame rendering. Idempotent by . + void Register(IPanel panel); + + /// Remove the panel with the matching id. No-op if not present. + void Unregister(string panelId); + + /// + /// Iterate every visible panel and call . Call + /// order within a frame is the registration order; panels with + /// set to false are skipped entirely. + /// + void RenderAll(PanelContext ctx); +} diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs new file mode 100644 index 00000000..93e25847 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -0,0 +1,43 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Drawing primitives exposed to panels. The only API panels use to +/// emit pixels. The ImGui backend maps these straight onto ImGui calls; the +/// later custom retail-look backend will map the same primitives onto its +/// own retained-mode toolkit using retail dat-sourced fonts / sprites. +/// +/// +/// Keep this interface small and retail-friendly. If a widget requires a +/// feature the custom backend couldn't express with dat assets, don't add +/// it — find a different widget shape that both backends can satisfy. +/// +/// +public interface IPanelRenderer +{ + /// + /// Begin a top-level window. Matches retail's root UiPanel + + /// ImGui's Begin. Returns false if the window is collapsed + /// — the caller must still call to balance. + /// + bool Begin(string title); + + /// Close the most recent . + void End(); + + /// Draw a single line of text. No formatting / markdown. + void Text(string text); + + /// Keep the next widget on the same line as the previous one. + void SameLine(); + + /// Horizontal rule separator. + void Separator(); + + /// + /// A filled progress bar. + /// is clamped by the backend to [0, 1]. + /// is the pixel width of the full bar. + /// is optional text (e.g. "54%") rendered on top. + /// + void ProgressBar(float fraction, float width, string? overlay = null); +} diff --git a/src/AcDream.UI.Abstractions/NullCommandBus.cs b/src/AcDream.UI.Abstractions/NullCommandBus.cs new file mode 100644 index 00000000..2c111ea8 --- /dev/null +++ b/src/AcDream.UI.Abstractions/NullCommandBus.cs @@ -0,0 +1,21 @@ +namespace AcDream.UI.Abstractions; + +/// +/// No-op . Accepts any published command and +/// discards it. Used as the default in D.2a until chat / inventory panels +/// need real command routing. +/// +public sealed class NullCommandBus : ICommandBus +{ + /// Shared singleton — the bus is stateless. + public static readonly NullCommandBus Instance = new(); + + private NullCommandBus() { } + + /// + public void Publish(T command) where T : notnull + { + // Intentionally empty. Panel-emitted commands in D.2a are + // read-only diagnostics; nothing routes server-ward yet. + } +} diff --git a/src/AcDream.UI.Abstractions/PanelContext.cs b/src/AcDream.UI.Abstractions/PanelContext.cs new file mode 100644 index 00000000..37caba1d --- /dev/null +++ b/src/AcDream.UI.Abstractions/PanelContext.cs @@ -0,0 +1,15 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Per-frame context passed to each call. +/// Struct + record for zero-allocation per frame. Add fields here as new +/// capabilities become panel-facing — e.g. a future IGameState +/// handle once we need richer data than individual ViewModels can carry. +/// +/// +/// Carried by value; cheap. Passed per-render; do not cache across frames. +/// +/// +public readonly record struct PanelContext( + float DeltaSeconds, + ICommandBus Commands); diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs new file mode 100644 index 00000000..5040c7ea --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs @@ -0,0 +1,69 @@ +namespace AcDream.UI.Abstractions.Panels.Vitals; + +/// +/// First real UI panel — shows the local player's vitals as progress bars. +/// Backend-agnostic; renders exclusively through +/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom +/// retail-look toolkit (D.2b). +/// +/// +/// D.2a shows only HP (percent). / +/// return null until a +/// LocalPlayerState is wired (follow-up issue). When they start +/// returning non-null, this panel picks them up automatically. +/// +/// +public sealed class VitalsPanel : IPanel +{ + private const float BarWidth = 200f; + + private readonly VitalsVM _vm; + + public VitalsPanel(VitalsVM vm) + { + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); + } + + /// + public string Id => "acdream.vitals"; + + /// + public string Title => "Vitals"; + + /// + public bool IsVisible { get; set; } = true; + + /// + public void Render(PanelContext ctx, IPanelRenderer renderer) + { + if (!renderer.Begin(Title)) + { + renderer.End(); + return; + } + + // HP — always available from CombatState. + float hp = _vm.HealthPercent; + renderer.Text("HP"); + renderer.SameLine(); + renderer.ProgressBar(hp, BarWidth, overlay: $"{hp * 100f:F0}%"); + + // Stamina — show only when the VM has a real value. + if (_vm.StaminaPercent is float stam) + { + renderer.Text("Stam"); + renderer.SameLine(); + renderer.ProgressBar(stam, BarWidth, overlay: $"{stam * 100f:F0}%"); + } + + // Mana — show only when the VM has a real value. + if (_vm.ManaPercent is float mana) + { + renderer.Text("Mana"); + renderer.SameLine(); + renderer.ProgressBar(mana, BarWidth, overlay: $"{mana * 100f:F0}%"); + } + + renderer.End(); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs new file mode 100644 index 00000000..6d512f29 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -0,0 +1,75 @@ +using AcDream.Core.Combat; + +namespace AcDream.UI.Abstractions.Panels.Vitals; + +/// +/// ViewModel for the vitals HUD panel. Reads live health percentage for the +/// local player from (which is fed by the server's +/// UpdateHealth (0x01C0) GameEvent). +/// +/// +/// D.2a scope limits: +/// +/// +/// +/// HP comes from and is percent-only +/// (0..1). Absolute current/max HP is not wired yet. +/// Stamina / Mana are always null — those values live in +/// AppraiseInfoParser.CreatureProfile (parsed from +/// PlayerDescription (0x0013)) but the parsed record is +/// currently discarded. Wiring a LocalPlayerState cache is +/// a separate follow-up; see docs/ISSUES.md. +/// +/// +/// +/// GUID timing: the local player's server GUID isn't known at +/// OnLoad (pre-login). Construct with +/// left as 0; GameWindow calls the setter when the live session +/// receives its guid at EnterWorld. Before the GUID is set, +/// returns 1.0 (via CombatState's safe +/// default for unknown guids) — the bar reads "full", which is harmless. +/// +/// +public sealed class VitalsVM +{ + private readonly CombatState _combat; + private uint _localPlayerGuid; + + /// + /// Build a VitalsVM bound to a instance. The + /// GUID starts at 0; call once the + /// live session assigns it. + /// + public VitalsVM(CombatState combat) + { + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _localPlayerGuid = 0; + } + + /// + /// Push the authoritative local-player GUID from WorldSession. + /// One-way setter — only GameWindow should call it, exactly once + /// per live session. + /// + public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; + + /// + /// Current health percent (0..1) for the local player. Returns 1.0 + /// before login or if the server has never sent an UpdateHealth for + /// this GUID. + /// + public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid); + + /// + /// Stamina percent (0..1) or null when absolute values aren't wired. + /// D.2a always returns null; to be populated by a future + /// LocalPlayerState that caches PlayerDescription (0x0013). + /// + public float? StaminaPercent => null; + + /// + /// Mana percent (0..1) or null when absolute values aren't wired. + /// Same status as . + /// + public float? ManaPercent => null; +} diff --git a/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj new file mode 100644 index 00000000..66f5bbad --- /dev/null +++ b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + latest + true + + + + + + + + + + + + + diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs new file mode 100644 index 00000000..bbcc3a81 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -0,0 +1,64 @@ +using Silk.NET.Input; +using Silk.NET.OpenGL; +using Silk.NET.OpenGL.Extensions.ImGui; +using Silk.NET.Windowing; + +namespace AcDream.UI.ImGui; + +/// +/// Owns the ImGuiController from Silk.NET.OpenGL.Extensions.ImGui, +/// which handles the whole Silk.NET ↔ ImGui.NET integration: +/// +/// Creates the ImGui context + OpenGL3 backend using Silk.NET's GL binding +/// (no GLFW / SDL dependency — unlike Hexa.NET.ImGui, which assumed one). +/// Subscribes to Silk.NET's window + input events to drive IO. +/// Per frame: Update(dt) calls ImGui.NewFrame(); Render() +/// calls ImGui.Render() + uploads draw data via its OpenGL3 backend. +/// +/// +/// +/// Instance-scoped rather than static so GL-context lifetime is explicit. +/// GameWindow owns the one instance and disposes on shutdown. +/// +/// +/// +/// History: tried Hexa.NET.ImGui + Hexa.NET.ImGui.Backends.OpenGL3 first +/// per the original plan, but its native OpenGL3 backend resolves GL functions +/// via GLFW / SDL internally and crashed (0xC0000005) in InitNative without +/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25. +/// +/// +public sealed class ImGuiBootstrapper : IDisposable +{ + 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); + } + + /// + /// Begin an ImGui frame. Call BEFORE any ImGui.* widget calls. + /// Internally: consumes buffered input events, calls ImGui.NewFrame(). + /// + public void BeginFrame(float deltaSeconds) => _controller.Update(deltaSeconds); + + /// + /// Finalise the ImGui frame and draw to the framebuffer. Call AFTER all + /// panel draws, within the same frame as . The + /// OpenGL3 backend save/restores the GL state it touches (shader, VAO, + /// texture, blend, scissor); state not in its save-list (e.g. + /// GL_FRAMEBUFFER_SRGB) is caller's responsibility. + /// + public void Render() => _controller.Render(); + + public void Dispose() => _controller.Dispose(); +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelHost.cs b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs new file mode 100644 index 00000000..d9a3a4c1 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs @@ -0,0 +1,46 @@ +using AcDream.UI.Abstractions; + +namespace AcDream.UI.ImGui; + +/// +/// implementation for the ImGui backend. Owns the +/// registered panel set; iterates + draws every frame when the caller is +/// inside an ImGui frame (between ImGui.NewFrame and +/// ImGui.Render). +/// +/// +/// This class does not call ImGui.NewFrame / ImGui.Render +/// itself. Those belong to the caller (GameWindow) so GL-state +/// ownership is explicit and the render-loop integration point is obvious. +/// +/// +public sealed class ImGuiPanelHost : IPanelHost +{ + private readonly Dictionary _panels = new(); + private readonly ImGuiPanelRenderer _renderer = new(); + + /// + public void Register(IPanel panel) + { + ArgumentNullException.ThrowIfNull(panel); + _panels[panel.Id] = panel; // idempotent by Id + } + + /// + public void Unregister(string panelId) => _panels.Remove(panelId); + + /// + public void RenderAll(PanelContext ctx) + { + // Order-independent — ImGui windows stack in the order they're drawn + // for focus purposes but we have <=1 panel in D.2a. + foreach (var panel in _panels.Values) + { + if (!panel.IsVisible) continue; + panel.Render(ctx, _renderer); + } + } + + /// Current registered count (for diagnostics). + public int Count => _panels.Count; +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs new file mode 100644 index 00000000..d1fde2f9 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -0,0 +1,42 @@ +using System.Numerics; +using AcDream.UI.Abstractions; +using ImGuiNET; + +namespace AcDream.UI.ImGui; + +/// +/// implemented as thin wrappers around +/// 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. +/// +public sealed class ImGuiPanelRenderer : IPanelRenderer +{ + /// + public bool Begin(string title) => ImGuiNET.ImGui.Begin(title); + + /// + public void End() => ImGuiNET.ImGui.End(); + + /// + public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text); + + /// + public void SameLine() => ImGuiNET.ImGui.SameLine(); + + /// + public void Separator() => ImGuiNET.ImGui.Separator(); + + /// + public void ProgressBar(float fraction, float width, string? overlay = null) + { + // Clamp defensively; ImGui clamps internally but the abstraction + // contract promises to handle out-of-range values. + if (fraction < 0f) fraction = 0f; + else if (fraction > 1f) fraction = 1f; + + var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font + ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj new file mode 100644 index 00000000..f99c6e22 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs new file mode 100644 index 00000000..6f34d5ea --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs @@ -0,0 +1,22 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class NullCommandBusTests +{ + private sealed record FakeCmd(int Value); + + [Fact] + public void Publish_DoesNotThrow_OnAnyRecordType() + { + var bus = NullCommandBus.Instance; + + bus.Publish(new FakeCmd(42)); + bus.Publish("a string command"); + bus.Publish(12345); + } + + [Fact] + public void Instance_IsSingleton() + { + Assert.Same(NullCommandBus.Instance, NullCommandBus.Instance); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs b/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs new file mode 100644 index 00000000..2a665c93 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs @@ -0,0 +1,24 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class PanelContextTests +{ + [Fact] + public void Fields_RoundTripThroughConstructor() + { + var ctx = new PanelContext(DeltaSeconds: 0.016f, Commands: NullCommandBus.Instance); + + Assert.Equal(0.016f, ctx.DeltaSeconds); + Assert.Same(NullCommandBus.Instance, ctx.Commands); + } + + [Fact] + public void RecordEquality_ByValue() + { + var a = new PanelContext(1f / 60f, NullCommandBus.Instance); + var b = new PanelContext(1f / 60f, NullCommandBus.Instance); + + // Record-struct equality is value-based on DeltaSeconds + reference-based + // on Commands (since ICommandBus is a reference type, same instance → equal). + Assert.Equal(a, b); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs new file mode 100644 index 00000000..3abf2c35 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs @@ -0,0 +1,80 @@ +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Vitals; + +namespace AcDream.UI.Abstractions.Tests; + +public sealed class VitalsVMTests +{ + [Fact] + public void HealthPercent_ReturnsCombatStateValue_AfterUpdateHealth() + { + var combat = new CombatState(); + uint guid = 0x5000_0042u; + combat.OnUpdateHealth(guid, 0.42f); + + var vm = new VitalsVM(combat); + vm.SetLocalPlayerGuid(guid); + + Assert.Equal(0.42f, vm.HealthPercent, precision: 3); + } + + [Fact] + public void HealthPercent_ReturnsOne_WhenGuidUnknown() + { + var combat = new CombatState(); + var vm = new VitalsVM(combat); + + // No SetLocalPlayerGuid call — defaults to 0 which CombatState has never seen. + Assert.Equal(1f, vm.HealthPercent); + } + + [Fact] + public void HealthPercent_ReturnsOne_WhenGuidSetButNeverUpdated() + { + var combat = new CombatState(); + var vm = new VitalsVM(combat); + vm.SetLocalPlayerGuid(0xDEAD_BEEFu); + + Assert.Equal(1f, vm.HealthPercent); + } + + [Fact] + public void StaminaPercent_IsNull_ForD2aScope() + { + // D.2a explicitly defers Stamina until LocalPlayerState + PlayerDescription + // wiring. When that arrives VitalsVM.StaminaPercent becomes non-null and + // VitalsPanel starts drawing the Stam bar automatically. + var vm = new VitalsVM(new CombatState()); + Assert.Null(vm.StaminaPercent); + } + + [Fact] + public void ManaPercent_IsNull_ForD2aScope() + { + var vm = new VitalsVM(new CombatState()); + Assert.Null(vm.ManaPercent); + } + + [Fact] + public void SetLocalPlayerGuid_ReroutesHealthLookup_WithoutStaleCache() + { + // Simulate the realistic GameWindow flow: VM is constructed pre-login + // with GUID=0, then SetLocalPlayerGuid is called at EnterWorld. + var combat = new CombatState(); + uint playerGuid = 0x5003_E219u; + combat.OnUpdateHealth(playerGuid, 0.75f); + + var vm = new VitalsVM(combat); + // Before SetLocalPlayerGuid — reads GUID=0 → returns safe 1.0. + Assert.Equal(1f, vm.HealthPercent); + + vm.SetLocalPlayerGuid(playerGuid); + Assert.Equal(0.75f, vm.HealthPercent, precision: 3); + } + + [Fact] + public void Constructor_ThrowsOnNullCombat() + { + Assert.Throws(() => new VitalsVM(null!)); + } +}