feat(render): Campaign V slice V11 commit 1 - delete ImGui, Studio, and the DevTools frontend

The ImGui developer-tools stack (AcDream.UI.ImGui), UI Studio
(src/AcDream.App/Studio), and the DevToolsFramePresenter/
SettingsDevToolsCompositionPhase ImGui composition machinery are removed.
Vulkan never composed a DevTools frontend (DevToolsEnabled already forced
false whenever the backend was Vulkan); this commit makes that permanent by
deleting the only implementation rather than leaving a dead branch behind.

What moved: Studio/SampleData.cs is a live production dependency
(InteractionRetainedUiComposition's character-sheet fallback, plus three
UI.Layout test files) - git mv'd to src/AcDream.App/UI/Layout/SampleData.cs,
namespace AcDream.App.UI.Layout, and trimmed to the SampleCharacter API that
is actually still called (BuildObjectTable/AddItem/AddEquipped/the item-guid
and icon constants had zero callers left once the Studio fixture provider
that used them was deleted).

What survives as backend-neutral seams, per the tests that still exercise
them: IDevToolsFrameLifecycle (moved into RenderFramePreparationController.cs,
now always bound to null), IFramebufferDevToolsTarget/FramebufferDevToolsBinding
in FramebufferResizeController.cs (its concrete DevToolsFramebufferTarget
adapter is deleted), and IDevToolsGameplayCommands in
GameplayInputCommandController.cs (DevToolsGameplayCommands becomes a
documented no-op instead of forwarding to the deleted presenter). A follow-up
re-homes Settings/Debug onto the retained UI through IPanelRenderer; until
then keybind remapping falls back to editing keybinds.json.

DevToolsEnabled is now `private const bool DevToolsEnabled = false`.
RuntimeOptions.DevTools is unchanged and still reaches VulkanGraphicsContext
for the optional debug-utils extensions; Program.cs now logs one line when
ACDREAM_DEVTOOLS=1 explaining that the ImGui UI is gone and the flag is
Vulkan-only now.

Removed: AcDream.UI.ImGui (project + ImGui.NET/Silk.NET.OpenGL.Extensions.ImGui
package refs), src/AcDream.App/Studio (minus SampleData.cs),
DevToolsFramePresenter.cs and everything only it constructed
(ISettingsDevToolsCompositionFactory, RetailSettingsDevToolsCompositionFactory,
DevToolsCompositionOwner, IGameWindowSettingsDevToolsPublication,
SettingsDevToolsOptionalDependencies, the "developer tools" shutdown-ledger
stage and its DevTools-typed fields on IngressShutdownRoots/
RenderShutdownRoots), the ui-studio Program.cs verb, and the cimgui native
manifest entries in GraphicalHostPlatformServices. GameWindow.cs's DevTools
composition branch, its _vitalsVm/_debugVm/_devToolsComposition/
_devToolsFramePresenter/_devToolsCommandBus fields, and every settingsDevTools
.DevTools?.* access across FrameRootComposition.cs/SessionPlayerComposition.cs
are gone with it.

Build green; complete Release solution suite 8,830 / 5 skips (App Tests
4,097/3 skips run standalone - one #250-family zero-allocation test flakes
under the full parallel `dotnet test AcDream.slnx` run, a pre-existing,
documented class unrelated to this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 23:56:04 +02:00
parent db4426d5ef
commit 844cf092a1
48 changed files with 227 additions and 6201 deletions

View file

@ -52,7 +52,6 @@
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.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

@ -433,16 +433,17 @@ internal sealed class FrameRootCompositionPhase
// Campaign V slice V6j: no debug-line renderer on the Vulkan arm.
// DrawAndPublish flushes it INSIDE the world phase and that
// renderer opens its own pass, which the frame's one backbuffer
// pass forbids. The collision-wireframe toggle is DevTools-only
// and DevTools is not composed there, so nothing is lost —
// composing it would throw on the first wireframe frame rather
// than silently misdraw (plan §5.5.14 item 7).
// pass forbids. The collision-wireframe toggle was DevTools-only
// and the ImGui DevTools frontend is gone as of Campaign V slice
// V11, so nothing is lost — composing it would throw on the
// first wireframe frame rather than silently misdraw (plan
// §5.5.14 item 7).
gl is not null ? foundation.DebugLines : null,
d.PhysicsEngine,
d.PlayerMode,
d.PlayerController,
d.DebugVmRenderFacts,
settings.DevTools is not null);
debugVmConsumerActive: false);
var worldScenePasses = new WorldScenePassExecutor(
worldPassSurface,
worldFrameGlState,
@ -570,11 +571,15 @@ internal sealed class FrameRootCompositionPhase
live.PaperdollPresenter,
live.CreatureAppraisalPresenter),
retainedGameplayUi,
settings.DevTools?.Presenter,
// The ImGui developer-tools frontend was removed at Campaign V
// slice V11; this optional hook is unbound until a follow-up
// re-homes it onto the retained UI (IDevToolsFrameLifecycle stays
// as the seam).
devTools: null,
privateScreenshot);
var framePreparation = new RenderFramePreparationController(
renderFrameResources,
settings.DevTools?.Presenter,
devTools: null,
renderWeatherFrame);
IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null
@ -602,8 +607,7 @@ internal sealed class FrameRootCompositionPhase
privatePresentation,
live.FrameDiagnostics,
postDiagnostics,
(IRenderFrameFailureRecovery?)settings.DevTools?.Presenter
?? NullRenderFrameFailureRecovery.Instance);
NullRenderFrameFailureRecovery.Instance);
Fault(FrameRootCompositionPoint.RenderRootCreated);
var liveFrameCoordinator = new RetailLiveFrameCoordinator(

View file

@ -426,7 +426,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Character.LocalPlayer,
playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter,
fallbackSheet: SampleData.SampleCharacter,
canSendRaise: () => late.GameRuntime.IsInWorld,
sendRaiseAttribute: (statId, cost) =>
late.GameRuntime.Advance(

View file

@ -740,15 +740,6 @@ internal sealed class SessionPlayerCompositionPhase
d.MovementDiagnostics,
d.Character.MovementSkills,
d.ViewportAspect);
if (d.SettingsDevTools.DevTools is { } devTools)
{
bindings.Adopt(
"developer player mode",
devTools.LateBindings.PlayerModeCommands.BindOwned(playerMode));
bindings.Adopt(
"developer command bus",
devTools.CommandBus.BindOwned(liveSessionSource));
}
var playerModeAutoEntry = new PlayerModeAutoEntry(
new LivePlayerModeAutoEntryContext(
liveSessionSource,
@ -832,8 +823,7 @@ internal sealed class SessionPlayerCompositionPhase
static value => value.Dispose());
AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals =
d.SettingsDevTools.DevTools?.Vitals
?? interaction.RetainedUi?.Vitals;
interaction.RetainedUi?.Vitals;
var sessionRuntimeFactory = new LiveSessionRuntimeFactory(
new LiveSessionPlayerRuntime(
d.PlayerIdentity,
@ -893,9 +883,10 @@ internal sealed class SessionPlayerCompositionPhase
d.Options.LivePass ?? string.Empty));
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
Action<string>? debugToast = d.SettingsDevTools.DevTools is { } devOwner
? message => devOwner.Debug.AddToast(message)
: null;
// The ImGui developer-tools debug toast sink was removed at Campaign V
// slice V11 along with the rest of the ImGui frontend; there is no
// replacement toast surface yet.
Action<string>? debugToast = null;
var combatModeOperations = new LiveCombatModeOperations(
new LiveSessionCombatModeAuthority(sessionHost),
new LocalPlayerCombatEquipmentSource(
@ -954,8 +945,7 @@ internal sealed class SessionPlayerCompositionPhase
var commands = new GameplayInputCommandController(
new RetainedGameplayWindowCommands(
interaction.RetainedUi?.Runtime),
new DevToolsGameplayCommands(
d.SettingsDevTools.DevTools?.Presenter),
new DevToolsGameplayCommands(),
runtimeDiagnostics,
new PlayerModeGameplayCommands(
d.PlayerMode,

View file

@ -1,336 +1,27 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Player;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Debug;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using AcDream.UI.ImGui;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
/// <summary>
/// Resolved display/audio/quality settings for the session. The optional
/// ImGui developer-tools frontend that used to compose here (VitalsPanel,
/// ChatPanel, DebugPanel, SettingsPanel via <c>AcDream.UI.ImGui</c>) was
/// removed at Campaign V slice V11 along with the OpenGL backend it
/// required — see docs/plans/2026-07-27-vulkan-campaign.md. A follow-up
/// re-homes the Settings and Debug panels onto the retained UI through a new
/// <c>IPanelRenderer</c> implementation; until then keybind remapping falls
/// back to editing keybinds.json.
/// </summary>
internal sealed record SettingsDevToolsResult(
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality,
DevToolsCompositionOwner? DevTools);
internal sealed record SettingsDevToolsOptionalDependencies(
IDevToolsRuntimeFacts Facts,
IRuntimeKeyBindingTarget? KeyBindingTarget,
DeferredCanonicalWorldEntityCountSource WorldEntities,
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
DeferredDevToolsPlayerModeCommands PlayerModeCommands);
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality);
internal sealed record SettingsDevToolsDependencies(
IView Window,
RuntimeSettingsController Settings,
IRuntimeSettingsStartupTarget StartupTarget,
HostQuiescenceGate HostQuiescence,
GameRuntime Runtime,
SettingsDevToolsOptionalDependencies? DevTools,
RuntimeDiagnosticCommandSlot DiagnosticCommands,
CombatFeedbackSlot CombatFeedback,
KeyBindings KeyBindings,
FrameProfiler FrameProfiler,
FramebufferResizeController FramebufferResize,
Action<string> Log)
{
public RuntimeCommunicationState Communication =>
Runtime.CommunicationOwner;
public CombatState Combat => Runtime.ActionOwner.Combat;
public LocalPlayerState LocalPlayer => Runtime.CharacterOwner.LocalPlayer;
}
internal interface IGameWindowSettingsDevToolsPublication
{
void PublishDevTools(DevToolsCompositionOwner value);
}
internal interface ISettingsDevToolsCompositionFactory
{
bool IsSupported(IInputContext input);
IDevToolsInputContext CreateInputContext(
IInputContext input,
HostQuiescenceGate quiescence);
IImGuiBootstrapper CreateBootstrap(
GL gl,
IView window,
IInputContext input);
ImGuiPanelHost CreatePanelHost();
IDevToolsFrameBackend CreateBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels);
}
internal sealed class RetailSettingsDevToolsCompositionFactory
: ISettingsDevToolsCompositionFactory
{
public bool IsSupported(IInputContext input) =>
input.Keyboards.Count > 0
&& input.Mice.Count > 0
&& input.Mice[0].ScrollWheels.Count > 0;
public IDevToolsInputContext CreateInputContext(
IInputContext input,
HostQuiescenceGate quiescence) =>
new QuiescentInputContext(input, quiescence);
public IImGuiBootstrapper CreateBootstrap(
GL gl,
IView window,
IInputContext input) =>
new ImGuiBootstrapper(gl, window, input);
public ImGuiPanelHost CreatePanelHost() => new();
public IDevToolsFrameBackend CreateBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels) =>
new ImGuiDevToolsFrameBackend(bootstrap, panels);
}
internal enum SettingsDevToolsCompositionPoint
{
SettingsApplied,
InputContextCreated,
BootstrapCreated,
InputActivated,
PanelHostCreated,
VitalsRegistered,
ChatRegistered,
DebugViewModelCreated,
DiagnosticActionsBound,
CombatFeedbackBound,
DebugRegistered,
SettingsViewModelBound,
SettingsRegistered,
BackendCreated,
PresenterCreated,
FramebufferBound,
InitialLayoutApplied,
DevToolsPublished,
}
IRuntimeSettingsStartupTarget StartupTarget);
/// <summary>
/// Complete optional developer frontend lifetime. Construction rollback and
/// normal shutdown share the same named, retryable release operations.
/// </summary>
internal sealed class DevToolsCompositionOwner : IDisposable
{
private readonly IDevToolsInputContext _input;
private readonly IDevToolsFrameBackend _backend;
private readonly ChatVM _chat;
private readonly DebugVM _debug;
private readonly RuntimeSettingsViewModelBinding? _settings;
private readonly CombatFeedbackSlot _combatFeedback;
private readonly FramebufferDevToolsBinding _framebuffer;
private readonly ResourceShutdownTransaction _frontendShutdown;
private bool _ownsInput;
private bool _ownsBackend;
private bool _ownsChat;
private bool _ownsDebug;
private bool _ownsSettings;
private bool _ownsCombatBinding;
private bool _ownsFramebuffer;
public DevToolsCompositionOwner(
IDevToolsInputContext input,
IDevToolsFrameBackend backend,
DevToolsFramePresenter presenter,
DevToolsCommandBusSource commandBus,
VitalsVM vitals,
ChatVM chat,
DebugVM debug,
SettingsDevToolsOptionalDependencies lateBindings,
RuntimeSettingsViewModelBinding? settings,
CombatFeedbackSlot combatFeedback,
FramebufferDevToolsBinding framebuffer)
{
_input = input ?? throw new ArgumentNullException(nameof(input));
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
Presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
CommandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus));
Vitals = vitals ?? throw new ArgumentNullException(nameof(vitals));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_debug = debug ?? throw new ArgumentNullException(nameof(debug));
LateBindings = lateBindings
?? throw new ArgumentNullException(nameof(lateBindings));
_settings = settings;
_combatFeedback = combatFeedback ?? throw new ArgumentNullException(nameof(combatFeedback));
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
_frontendShutdown = new ResourceShutdownTransaction(
new ResourceShutdownStage("developer framebuffer binding",
[
new("framebuffer resize binding", ReleaseFramebuffer),
]),
new ResourceShutdownStage("developer backend",
[
new("ImGui backend", ReleaseBackend),
]),
new ResourceShutdownStage("developer view models",
[
new("settings view model binding", ReleaseSettings),
new("combat feedback binding", ReleaseCombatBinding),
new("debug combat subscriptions", ReleaseDebug),
new("chat transcript subscription", ReleaseChat),
]));
}
public DevToolsFramePresenter Presenter { get; }
public DevToolsCommandBusSource CommandBus { get; }
public VitalsVM Vitals { get; }
public DebugVM Debug => _debug;
public SettingsDevToolsOptionalDependencies LateBindings { get; }
public bool IsDisposalComplete =>
_frontendShutdown.IsComplete && !_ownsInput;
internal bool IsFrontendDisposalComplete => _frontendShutdown.IsComplete;
public void AdoptInput() => _ownsInput = true;
public void AdoptBackend() => _ownsBackend = true;
public void AdoptChat() => _ownsChat = true;
public void AdoptDebug() => _ownsDebug = true;
public void AdoptSettings() => _ownsSettings = _settings is not null;
public void AdoptCombatBinding() => _ownsCombatBinding = true;
public void AdoptFramebuffer() => _ownsFramebuffer = true;
public void CompleteOwnership()
{
if (!_ownsInput
|| !_ownsBackend
|| !_ownsChat
|| !_ownsDebug
|| !_ownsCombatBinding
|| !_ownsFramebuffer
|| (_settings is not null && !_ownsSettings))
{
throw new InvalidOperationException(
"Developer composition ownership is incomplete.");
}
}
public void DeactivateInput()
{
CommandBus.Deactivate();
LateBindings.PlayerModeCommands.Deactivate();
LateBindings.FrameDiagnostics.Deactivate();
LateBindings.WorldEntities.Deactivate();
if (_ownsInput)
_input.Deactivate();
}
public void DetachInput()
{
if (!_ownsInput)
return;
_input.Dispose();
if (!_input.IsDisposalComplete)
throw new InvalidOperationException(
"Developer input callback cleanup remains incomplete.");
_ownsInput = false;
}
public void DisposeFrontend() => _frontendShutdown.CompleteOrThrow();
public void Dispose()
{
DeactivateInput();
DisposeFrontend();
DetachInput();
}
private void ReleaseFramebuffer()
{
if (!_ownsFramebuffer)
return;
_framebuffer.Dispose();
_ownsFramebuffer = false;
}
private void ReleaseCombatBinding()
{
if (!_ownsCombatBinding)
return;
_combatFeedback.Unbind(_debug);
_ownsCombatBinding = false;
}
private void ReleaseSettings()
{
if (!_ownsSettings)
return;
_settings!.Dispose();
_ownsSettings = false;
}
private void ReleaseDebug()
{
if (!_ownsDebug)
return;
_debug.Dispose();
_ownsDebug = false;
}
private void ReleaseChat()
{
if (!_ownsChat)
return;
_chat.Dispose();
_ownsChat = false;
}
private void ReleaseBackend()
{
if (!_ownsBackend)
return;
_backend.Dispose();
_ownsBackend = false;
}
}
internal sealed class CombatFeedbackBinding : IDisposable
{
private readonly CombatFeedbackSlot _owner;
private readonly DebugVM _viewModel;
private bool _disposed;
public CombatFeedbackBinding(CombatFeedbackSlot owner, DebugVM viewModel)
{
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
_owner.Bind(_viewModel);
}
public void Dispose()
{
if (_disposed)
return;
_owner.Unbind(_viewModel);
_disposed = true;
}
}
/// <summary>
/// Production Phase 3. Settings are required; the optional developer frontend
/// may be abandoned only after every locally acquired edge has been removed.
/// Production Phase 3: applies the resolved startup display/audio settings.
/// </summary>
internal sealed class SettingsDevToolsCompositionPhase :
ISettingsDevToolsCompositionPhase<
@ -340,20 +31,10 @@ internal sealed class SettingsDevToolsCompositionPhase :
SettingsDevToolsResult>
{
private readonly SettingsDevToolsDependencies _dependencies;
private readonly IGameWindowSettingsDevToolsPublication _publication;
private readonly ISettingsDevToolsCompositionFactory _factory;
private readonly Action<SettingsDevToolsCompositionPoint>? _faultInjection;
public SettingsDevToolsCompositionPhase(
SettingsDevToolsDependencies dependencies,
IGameWindowSettingsDevToolsPublication publication,
ISettingsDevToolsCompositionFactory? factory = null,
Action<SettingsDevToolsCompositionPoint>? faultInjection = null)
public SettingsDevToolsCompositionPhase(SettingsDevToolsDependencies dependencies)
{
_dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies));
_publication = publication ?? throw new ArgumentNullException(nameof(publication));
_factory = factory ?? new RetailSettingsDevToolsCompositionFactory();
_faultInjection = faultInjection;
}
public SettingsDevToolsResult Compose(
@ -366,282 +47,6 @@ internal sealed class SettingsDevToolsCompositionPhase :
ArgumentNullException.ThrowIfNull(content);
_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);
Fault(SettingsDevToolsCompositionPoint.SettingsApplied);
DevToolsCompositionOwner? devTools = _dependencies.DevTools is { } optional
? ComposeOptionalDevTools(platform, host, optional)
: null;
return new SettingsDevToolsResult(
_dependencies.Settings.ResolvedQuality,
devTools);
return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality);
}
private DevToolsCompositionOwner? ComposeOptionalDevTools(
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform,
HostInputCameraResult host,
SettingsDevToolsOptionalDependencies optional)
{
var scope = new CompositionAcquisitionScope();
bool published = false;
try
{
if (!_factory.IsSupported(platform.Input))
{
_dependencies.Log(
"devtools: keyboard, mouse, or scroll input unavailable — devtools disabled");
return null;
}
var inputLease = scope.Acquire(
"developer input context",
() => _factory.CreateInputContext(
platform.Input,
_dependencies.HostQuiescence),
static value => value.Dispose());
IDevToolsInputContext input = inputLease.Resource;
Fault(SettingsDevToolsCompositionPoint.InputContextCreated);
var bootstrapLease = scope.Acquire(
"ImGui bootstrap",
// ImGui is a GL-only frontend and is not ported to Vulkan — the
// campaign deletes it at slice V11 — so a Vulkan host composes
// no DevTools at all and never reaches here.
() => _factory.CreateBootstrap(
platform.Graphics.RequireGl("developer UI"),
_dependencies.Window,
input),
static value => value.Dispose());
IImGuiBootstrapper bootstrap = bootstrapLease.Resource;
Fault(SettingsDevToolsCompositionPoint.BootstrapCreated);
input.Activate();
Fault(SettingsDevToolsCompositionPoint.InputActivated);
ImGuiPanelHost panelHost = _factory.CreatePanelHost();
Fault(SettingsDevToolsCompositionPoint.PanelHostCreated);
var vitals = new VitalsVM(
_dependencies.Combat,
_dependencies.LocalPlayer);
var vitalsPanel = new VitalsPanel(vitals);
panelHost.Register(vitalsPanel);
Fault(SettingsDevToolsCompositionPoint.VitalsRegistered);
var chatLease = scope.Acquire(
"developer chat view model",
() => new ChatVM(
_dependencies.Communication.Chat,
commandTargets:
_dependencies.Communication.CommandTargets)
{
FpsProvider = () => optional.Facts.Fps,
PositionProvider = () => optional.Facts.PlayerPosition,
},
static value => value.Dispose());
ChatVM chatVm = chatLease.Resource;
var chatPanel = new ChatPanel(chatVm);
panelHost.Register(chatPanel);
Fault(SettingsDevToolsCompositionPoint.ChatRegistered);
var debugLease = scope.Acquire(
"developer debug view model",
() => CreateDebugViewModel(optional.Facts),
static value => value.Dispose());
DebugVM debugVm = debugLease.Resource;
Fault(SettingsDevToolsCompositionPoint.DebugViewModelCreated);
debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;
debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;
debugVm.ToggleCollisionWires =
_dependencies.DiagnosticCommands.ToggleCollisionWireframes;
debugVm.ToggleFlyMode = optional.PlayerModeCommands.ToggleFlyOrChase;
Fault(SettingsDevToolsCompositionPoint.DiagnosticActionsBound);
var combatBindingLease = scope.Acquire(
"combat feedback binding",
() => new CombatFeedbackBinding(
_dependencies.CombatFeedback,
debugVm),
static value => value.Dispose());
Fault(SettingsDevToolsCompositionPoint.CombatFeedbackBound);
var debugPanel = new DebugPanel(debugVm);
panelHost.Register(debugPanel);
Fault(SettingsDevToolsCompositionPoint.DebugRegistered);
RuntimeSettingsViewModelBinding? settingsBinding = null;
SettingsPanel? settingsPanel = null;
CompositionAcquisitionScope.CompositionAcquisitionLease<RuntimeSettingsViewModelBinding>?
settingsLease = null;
if (host.InputDispatcher is not null
&& optional.KeyBindingTarget is not null)
{
settingsLease = scope.Acquire(
"settings view model binding",
() => _dependencies.Settings.CreateViewModelBinding(
_dependencies.KeyBindings,
host.InputDispatcher,
optional.KeyBindingTarget.Apply),
static value => value.Dispose());
settingsBinding = settingsLease.Resource;
Fault(SettingsDevToolsCompositionPoint.SettingsViewModelBound);
settingsPanel = new SettingsPanel(settingsBinding.ViewModel);
panelHost.Register(settingsPanel);
Fault(SettingsDevToolsCompositionPoint.SettingsRegistered);
}
var backendLease = scope.Acquire(
"developer frame backend",
() => _factory.CreateBackend(bootstrap, panelHost),
static value => value.Dispose());
IDevToolsFrameBackend backend = backendLease.Resource;
bootstrapLease.Transfer();
Fault(SettingsDevToolsCompositionPoint.BackendCreated);
var cameraMenu = new DevToolsCameraMenuOperations(
host.CameraController,
optional.PlayerModeCommands);
var commandBus = new DevToolsCommandBusSource();
var presenter = new DevToolsFramePresenter(
backend,
cameraMenu,
commandBus,
_dependencies.FrameProfiler,
new DevToolsPanelSet(
vitalsPanel,
chatPanel,
debugPanel,
debugVm,
settingsPanel));
Fault(SettingsDevToolsCompositionPoint.PresenterCreated);
var framebufferLease = scope.Acquire(
"developer framebuffer binding",
() => new FramebufferDevToolsBinding(
_dependencies.FramebufferResize,
new DevToolsFramebufferTarget(presenter)),
static value => value.Dispose());
Fault(SettingsDevToolsCompositionPoint.FramebufferBound);
presenter.ResetLayout(
_dependencies.Window.Size.X,
_dependencies.Window.Size.Y,
DevToolsPanelLayoutCondition.FirstUseEver);
Fault(SettingsDevToolsCompositionPoint.InitialLayoutApplied);
var ownerLease = scope.Acquire(
"developer composition owner",
() => new DevToolsCompositionOwner(
input,
backend,
presenter,
commandBus,
vitals,
chatVm,
debugVm,
optional,
settingsBinding,
_dependencies.CombatFeedback,
framebufferLease.Resource),
static value => value.Dispose());
DevToolsCompositionOwner owner = ownerLease.Resource;
inputLease.Transfer();
owner.AdoptInput();
backendLease.Transfer();
owner.AdoptBackend();
chatLease.Transfer();
owner.AdoptChat();
debugLease.Transfer();
owner.AdoptDebug();
combatBindingLease.Transfer();
owner.AdoptCombatBinding();
if (settingsLease is not null)
{
settingsLease.Transfer();
owner.AdoptSettings();
}
framebufferLease.Transfer();
owner.AdoptFramebuffer();
owner.CompleteOwnership();
owner = ownerLease.Publish(
_publication.PublishDevTools);
published = true;
Fault(SettingsDevToolsCompositionPoint.DevToolsPublished);
scope.Complete();
_dependencies.Log(
"devtools: ImGui panel host ready " +
"(VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
return owner;
}
catch (Exception failure)
{
if (published)
throw;
try
{
scope.RollbackAndThrow(failure);
}
catch (Exception rolledBack) when (!HasIncompleteCleanup(rolledBack))
{
_dependencies.Log(
$"devtools: ImGui init failed: {rolledBack.Message} — devtools disabled");
return null;
}
throw new System.Diagnostics.UnreachableException();
}
}
private DebugVM CreateDebugViewModel(IDevToolsRuntimeFacts facts)
{
ArgumentNullException.ThrowIfNull(facts);
return new DebugVM(
() => facts.PlayerPosition,
() => facts.PlayerHeadingDegrees,
() => facts.PlayerCellId,
() => facts.PlayerOnGround,
() => facts.InPlayerMode,
() => facts.InFlyMode,
() => facts.VerticalVelocity,
() => facts.EntityCount,
() => facts.AnimatedCount,
() => facts.VisibleLandblocks,
() => facts.TotalLandblocks,
() => facts.ShadowObjectCount,
() => facts.NearestObjectDistance,
() => facts.NearestObjectLabel,
() => facts.Colliding,
() => facts.CollisionWireframesVisible,
() => facts.StreamingRadius,
() => facts.MouseSensitivity,
() => facts.ChaseDistance,
() => facts.RmbOrbitHeld,
() => facts.HourName,
() => facts.DayFraction,
() => facts.Weather,
() => facts.ActiveLights,
() => facts.RegisteredLights,
() => facts.ParticleCount,
() => facts.Fps,
() => facts.FrameMilliseconds,
_dependencies.Combat);
}
private static bool HasIncompleteCleanup(Exception failure)
{
if (failure is ImGuiBootstrapperConstructionException)
return true;
if (failure is IRetryableResourceCleanup cleanup
&& !cleanup.IsCleanupComplete)
{
return true;
}
if (failure is AggregateException aggregate)
return aggregate.InnerExceptions.Any(HasIncompleteCleanup);
return failure.InnerException is { } inner && HasIncompleteCleanup(inner);
}
private void Fault(SettingsDevToolsCompositionPoint point) =>
_faultInjection?.Invoke(point);
}

View file

@ -30,16 +30,26 @@ internal interface IDevToolsGameplayCommands
void ToggleSettingsPanel();
}
internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter)
: IDevToolsGameplayCommands
/// <summary>
/// The ImGui developer-tools frontend these commands used to forward to was
/// removed at Campaign V slice V11. A follow-up re-homes the Settings and
/// Debug panels onto the retained UI through a new <c>IPanelRenderer</c>
/// implementation; until then these are no-ops so the frozen gameplay-action
/// priority graph keeps a single, always-valid handoff target.
/// </summary>
internal sealed class DevToolsGameplayCommands : IDevToolsGameplayCommands
{
private readonly DevToolsFramePresenter? _presenter = presenter;
public void ToggleDebugPanel()
{
}
public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel();
public void FocusChatInput()
{
}
public void FocusChatInput() => _presenter?.FocusChatInput();
public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel();
public void ToggleSettingsPanel()
{
}
}
internal interface IPlayerModeGameplayCommands

View file

@ -9,17 +9,21 @@ internal interface IInputCaptureSource
bool DevToolsWantCaptureKeyboard { get; }
}
/// <summary>
/// The ImGui developer-tools frontend this used to poll for focus capture was
/// removed at Campaign V slice V11; there is no developer UI to steal focus
/// from world/retained-UI input anymore, so this always reports false.
/// </summary>
internal sealed class DevToolsInputCaptureSource
{
private readonly bool _enabled;
public DevToolsInputCaptureSource(bool enabled)
{
_ = enabled;
}
public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled;
public bool WantCaptureMouse => false;
public bool WantCaptureMouse =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
public bool WantCaptureKeyboard =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
public bool WantCaptureKeyboard => false;
}
internal sealed class RetainedUiInputCaptureSlot

View file

@ -94,13 +94,11 @@ internal sealed record GraphicalHostPlatformServices(
GraphicalHostOperatingSystem.Windows =>
[
new("window/input", "glfw3.dll"),
new("developer UI", "cimgui.dll"),
new("audio", "soft_oal.dll"),
],
GraphicalHostOperatingSystem.Linux =>
[
new("window/input", "libglfw.so.3"),
new("developer UI", "libcimgui.so"),
new("audio", "libopenal.so"),
],
_ => throw new ArgumentOutOfRangeException(

View file

@ -13,24 +13,6 @@ ApplicationPathSet applicationPaths = graphicalPlatform.Paths;
IReadOnlyList<string> migratedConfigurationFiles =
GraphicalLegacyConfigurationMigrator.Migrate(applicationPaths);
if (args.Length >= 1 && args[0] == "ui-studio")
{
var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]);
using var sw = new AcDream.App.Studio.StudioWindow(
so,
applicationPaths);
try
{
sw.Run();
return 0;
}
catch (NotSupportedException error)
{
Console.Error.WriteLine(error.Message);
return 4;
}
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
@ -62,6 +44,14 @@ if (string.IsNullOrWhiteSpace(datDir))
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
if (runtimeOptions.DevTools)
{
Log.Information(
"ACDREAM_DEVTOOLS=1: the ImGui developer UI was removed at Campaign V " +
"slice V11 along with the OpenGL backend it required; this flag now " +
"only selects the optional Vulkan validation/debug-utils extensions.");
}
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();

View file

@ -1,491 +0,0 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Debug;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using AcDream.UI.ImGui;
using ImGuiNET;
namespace AcDream.App.Rendering;
internal enum DevToolsPanelLayoutCondition
{
FirstUseEver,
Always,
}
internal enum DevToolsPanelKind
{
Vitals,
Chat,
Debug,
Settings,
}
internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
{
void BeginFrame(float deltaSeconds);
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
}
internal interface IDevToolsFrameBackend : IDisposable
{
void BeginFrame(float deltaSeconds);
void AbortFrame();
bool BeginMainMenuBar();
void EndMainMenuBar();
bool BeginMenu(string label);
void EndMenu();
bool MenuItem(string label, string? shortcut = null, bool selected = false);
void Separator();
void RenderPanels(PanelContext context);
void RenderDrawData();
void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition);
}
internal interface IDevToolsCameraMenuOperations
{
bool IsFlyMode { get; }
bool CollideCamera { get; set; }
void ToggleFlyOrChase();
}
internal interface IDevToolsCommandBusSource
{
ICommandBus Current { get; }
}
internal interface IDevToolsPanelSet
{
bool Contains(DevToolsPanelKind kind);
string? GetTitle(DevToolsPanelKind kind);
bool IsVisible(DevToolsPanelKind kind);
void Toggle(DevToolsPanelKind kind);
void FocusChatInput();
void AddDebugToast(string message);
}
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
{
private readonly IImGuiBootstrapper _bootstrap;
private readonly ImGuiPanelHost _panels;
private bool _disposed;
public ImGuiDevToolsFrameBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels)
{
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
_panels = panels ?? throw new ArgumentNullException(nameof(panels));
}
public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds);
public void AbortFrame() => _bootstrap.AbortFrame();
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label);
public void EndMenu() => ImGuiNET.ImGui.EndMenu();
public bool MenuItem(string label, string? shortcut = null, bool selected = false) =>
ImGuiNET.ImGui.MenuItem(label, shortcut ?? string.Empty, selected);
public void Separator() => ImGuiNET.ImGui.Separator();
public void RenderPanels(PanelContext context) => _panels.RenderAll(context);
public void RenderDrawData() => _bootstrap.Render();
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition)
{
ImGuiCond imguiCondition = condition switch
{
DevToolsPanelLayoutCondition.FirstUseEver => ImGuiCond.FirstUseEver,
DevToolsPanelLayoutCondition.Always => ImGuiCond.Always,
_ => throw new ArgumentOutOfRangeException(nameof(condition)),
};
ImGuiNET.ImGui.SetWindowPos(title, position, imguiCondition);
ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition);
}
public void Dispose()
{
if (_disposed)
return;
_bootstrap.Dispose();
_disposed = true;
}
}
/// <summary>Mutable late-composition seam for player-mode menu operations.</summary>
internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
{
private readonly CameraController _camera;
private readonly IDevToolsPlayerModeCommands _playerMode;
public DevToolsCameraMenuOperations(
CameraController camera,
IDevToolsPlayerModeCommands playerMode)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
}
public bool IsFlyMode => _camera.IsFlyMode;
public bool CollideCamera
{
get => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
}
public void ToggleFlyOrChase() => _playerMode.ToggleFlyOrChase();
}
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
{
private ILiveUiSessionTarget? _session;
private bool _deactivated;
public ICommandBus Current =>
!_deactivated && _session is { } session
? session.Commands
: NullCommandBus.Instance;
public void Bind(ILiveUiSessionTarget session)
{
ArgumentNullException.ThrowIfNull(session);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_session is not null && !ReferenceEquals(_session, session))
throw new InvalidOperationException(
"Developer command-bus authority is already bound.");
_session = session;
}
public IDisposable BindOwned(ILiveUiSessionTarget session)
{
ArgumentNullException.ThrowIfNull(session);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_session is not null)
{
throw new InvalidOperationException(
"Developer command-bus authority is already bound.");
}
_session = session;
return new Binding(this, session);
}
public void Unbind(ILiveUiSessionTarget session)
{
ArgumentNullException.ThrowIfNull(session);
if (ReferenceEquals(_session, session))
_session = null;
}
public void Deactivate()
{
_deactivated = true;
_session = null;
}
private sealed class Binding : IDisposable
{
private DevToolsCommandBusSource? _owner;
private readonly ILiveUiSessionTarget _expected;
public Binding(
DevToolsCommandBusSource owner,
ILiveUiSessionTarget expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
/// <summary>Typed panel operations used by menu and input presentation.</summary>
internal sealed class DevToolsPanelSet : IDevToolsPanelSet
{
private readonly VitalsPanel _vitals;
private readonly ChatPanel _chat;
private readonly DebugPanel _debug;
private readonly DebugVM _debugViewModel;
private readonly SettingsPanel? _settings;
public DevToolsPanelSet(
VitalsPanel vitals,
ChatPanel chat,
DebugPanel debug,
DebugVM debugViewModel,
SettingsPanel? settings)
{
_vitals = vitals ?? throw new ArgumentNullException(nameof(vitals));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_debug = debug ?? throw new ArgumentNullException(nameof(debug));
_debugViewModel = debugViewModel
?? throw new ArgumentNullException(nameof(debugViewModel));
_settings = settings;
}
public bool Contains(DevToolsPanelKind kind) =>
kind != DevToolsPanelKind.Settings || _settings is not null;
public string? GetTitle(DevToolsPanelKind kind) => GetPanel(kind)?.Title;
public bool IsVisible(DevToolsPanelKind kind) => GetPanel(kind)?.IsVisible == true;
public void Toggle(DevToolsPanelKind kind)
{
if (GetPanel(kind) is { } panel)
panel.IsVisible = !panel.IsVisible;
}
public void FocusChatInput() => _chat.FocusInput();
public void AddDebugToast(string message) => _debugViewModel.AddToast(message);
private IPanel? GetPanel(DevToolsPanelKind kind) => kind switch
{
DevToolsPanelKind.Vitals => _vitals,
DevToolsPanelKind.Chat => _chat,
DevToolsPanelKind.Debug => _debug,
DevToolsPanelKind.Settings => _settings,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
}
/// <summary>
/// Owns the optional ImGui developer frame, menu policy, panel actions, and
/// reusable default layout. Retained gameplay UI remains a separate earlier
/// presentation phase.
/// </summary>
internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle
{
private readonly IDevToolsFrameBackend _backend;
private readonly IDevToolsCameraMenuOperations _camera;
private readonly IDevToolsCommandBusSource _commands;
private readonly IDevToolsPanelSet _panels;
private readonly AcDream.App.Diagnostics.FrameProfiler _profiler;
private bool _frameOpen;
public DevToolsFramePresenter(
IDevToolsFrameBackend backend,
IDevToolsCameraMenuOperations camera,
IDevToolsCommandBusSource commands,
AcDream.App.Diagnostics.FrameProfiler profiler,
IDevToolsPanelSet panels)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_panels = panels ?? throw new ArgumentNullException(nameof(panels));
}
public void BeginFrame(float deltaSeconds)
{
if (_frameOpen)
{
throw new InvalidOperationException(
"The previous developer-tools frame must render or abort before a new frame begins.");
}
_frameOpen = true;
_backend.BeginFrame(deltaSeconds);
}
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
{
if (!_frameOpen)
{
throw new InvalidOperationException(
"BeginFrame must open the developer-tools frame before Render.");
}
var context = new PanelContext((float)deltaSeconds, _commands.Current);
DrawMenu(viewportWidth, viewportHeight);
_backend.RenderPanels(context);
using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui);
_backend.RenderDrawData();
_frameOpen = false;
}
public void AbortFrame()
{
if (!_frameOpen)
return;
try
{
_backend.AbortFrame();
}
finally
{
_frameOpen = false;
}
}
public void ToggleDebugPanel()
{
_panels.Toggle(DevToolsPanelKind.Debug);
_panels.AddDebugToast(
$"Debug panel {(_panels.IsVisible(DevToolsPanelKind.Debug) ? "ON" : "OFF")}");
}
public void FocusChatInput() => _panels.FocusChatInput();
public void ToggleSettingsPanel()
{
_panels.Toggle(DevToolsPanelKind.Settings);
}
public void ResetLayout(
int viewportWidth,
int viewportHeight,
DevToolsPanelLayoutCondition condition)
{
float width = Math.Max(viewportWidth, 480);
float height = Math.Max(viewportHeight, 320);
SetLayout(
DevToolsPanelKind.Vitals,
new Vector2(10f, 30f),
new Vector2(220f, 110f),
condition);
SetLayout(
DevToolsPanelKind.Chat,
new Vector2(10f, height - 320f),
new Vector2(450f, 300f),
condition);
SetLayout(
DevToolsPanelKind.Debug,
new Vector2(width - 380f, 30f),
new Vector2(370f, 520f),
condition);
if (_panels.Contains(DevToolsPanelKind.Settings))
{
SetLayout(
DevToolsPanelKind.Settings,
new Vector2((width - 700f) * 0.5f, (height - 500f) * 0.5f),
new Vector2(700f, 500f),
condition);
}
}
private void DrawMenu(int viewportWidth, int viewportHeight)
{
if (!_backend.BeginMainMenuBar())
return;
try
{
if (_backend.BeginMenu("View"))
{
try
{
if (_panels.Contains(DevToolsPanelKind.Settings)
&& _backend.MenuItem("Settings", "F11"))
{
_panels.Toggle(DevToolsPanelKind.Settings);
}
if (_backend.MenuItem("Vitals"))
_panels.Toggle(DevToolsPanelKind.Vitals);
if (_backend.MenuItem("Chat"))
_panels.Toggle(DevToolsPanelKind.Chat);
if (_backend.MenuItem("Debug", "Ctrl+F1"))
_panels.Toggle(DevToolsPanelKind.Debug);
_backend.Separator();
if (_backend.MenuItem("Reset window layout"))
ResetLayout(
viewportWidth,
viewportHeight,
DevToolsPanelLayoutCondition.Always);
}
finally
{
_backend.EndMenu();
}
}
if (_backend.BeginMenu("Camera"))
{
try
{
string flyLabel = _camera.IsFlyMode
? "Exit Free-Fly Mode"
: "Enter Free-Fly Mode";
if (_backend.MenuItem(flyLabel, "Ctrl+Shift+F"))
_camera.ToggleFlyOrChase();
bool collide = _camera.CollideCamera;
if (_backend.MenuItem(
"Collide Camera (spring arm)",
selected: collide))
{
_camera.CollideCamera = !collide;
}
}
finally
{
_backend.EndMenu();
}
}
}
finally
{
_backend.EndMainMenuBar();
}
}
private void SetLayout(
DevToolsPanelKind panel,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition)
{
string? title = _panels.GetTitle(panel);
if (!string.IsNullOrEmpty(title))
_backend.SetWindowLayout(title, position, size, condition);
}
}

View file

@ -37,19 +37,6 @@ internal interface IFramebufferDevToolsTarget
void ResetLayout(int width, int height);
}
internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter)
: IFramebufferDevToolsTarget
{
private readonly DevToolsFramePresenter _presenter = presenter
?? throw new ArgumentNullException(nameof(presenter));
public void ResetLayout(int width, int height) =>
_presenter.ResetLayout(
width,
height,
DevToolsPanelLayoutCondition.Always);
}
/// <summary>Expected-owner lease for the optional Phase-3 resize edge.</summary>
internal sealed class FramebufferDevToolsBinding : IDisposable
{
@ -124,8 +111,9 @@ internal sealed class FramebufferResizeController
return;
// Frozen order: GL viewport, shared aspect publication, camera, then
// optional ImGui layout reset. Late binding never replays an earlier
// resize transition.
// the optional developer-tools layout reset (unbound since Campaign V
// slice V11 removed the ImGui frontend). Late binding never replays an
// earlier resize transition.
_viewport?.ResizeViewport(width, height);
_viewportAspect.Update(width, height);
_camera?.SetAspect(width / (float)height);

View file

@ -26,7 +26,6 @@ public sealed class GameWindow :
IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>,
IGameWindowHostInputCameraPublication,
IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication,
IGameWindowWorldRenderPublication,
IGameWindowInteractionRetainedUiPublication,
IGameWindowLivePresentationPublication,
@ -91,11 +90,11 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.WorldSceneDebugState
_worldSceneDebugState = new();
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
// TextRenderer + BitmapFont fields stay alive because they're shared
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in favor of
// the ImGui-backed DebugPanel, which Campaign V slice V11 removed in turn.
// The TextRenderer + BitmapFont fields stay alive because they're shared
// with UiHost and reserved for the future world-space HUD (D.6 —
// damage floaters, name plates) where ImGui can't reach into the 3D
// damage floaters, name plates) where ImGui couldn't reach into the 3D
// scene. They are no longer used for any debug overlay.
private TextRenderer? _textRenderer;
private BitmapFont? _debugFont;
@ -379,12 +378,9 @@ public sealed class GameWindow :
public AcDream.Core.Player.LocalPlayerState LocalPlayer =>
_runtimeCharacter.LocalPlayer;
// 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.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private DevToolsCompositionOwner? _devToolsComposition;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2a — ImGui devtools UI overlay. Removed at Campaign V slice V11;
// see docs/plans/2026-04-24-ui-framework.md for the staged UI strategy and
// docs/plans/2026-07-27-vulkan-campaign.md for the removal.
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
@ -418,19 +414,13 @@ public sealed class GameWindow :
_creatureAppraisalFramePresenter;
// 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. The devtools presenter owns
// its panel; the VM remains here because runtime feedback producers bind
// directly to it during composition.
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
// DevToolsEnabled reads through typed RuntimeOptions.
//
// Campaign V slice V6h: the developer frontend is ImGui, which is not ported
// to Vulkan and which slice V11 deletes outright, so a Vulkan host composes
// none regardless of ACDREAM_DEVTOOLS. The flag still reaches
// VulkanInstanceFactory, where it selects the optional debug-utils
// instance extensions.
private bool DevToolsEnabled =>
_options.DevTools && _options.RenderBackend != RenderBackendKind.Vulkan;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
// VulkanGraphicsContext, where it selects the optional debug-utils
// instance/device extensions — see the ACDREAM_DEVTOOLS log line in
// Run() below, which is the one remaining observable effect of the flag.
private const bool DevToolsEnabled = false;
// Phase G.1-G.2 world lighting/time state. The environment owner keeps
// the clock, selected day group, and weather transitions coherent.
@ -922,26 +912,6 @@ public sealed class GameWindow :
_audioSink = value.HookSink;
}
void IGameWindowSettingsDevToolsPublication.PublishDevTools(
DevToolsCompositionOwner value)
{
ArgumentNullException.ThrowIfNull(value);
if (_devToolsComposition is not null
|| _devToolsFramePresenter is not null
|| _devToolsCommandBus is not null
|| _debugVm is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns developer tools.");
}
_devToolsComposition = value;
_devToolsFramePresenter = value.Presenter;
_devToolsCommandBus = value.CommandBus;
_vitalsVm = value.Vitals;
_debugVm = value.Debug;
}
void IGameWindowWorldRenderPublication.PublishBindlessSupport(
BindlessSupport value) =>
PublishCompositionOwner(
@ -1048,7 +1018,6 @@ public sealed class GameWindow :
{
_uiHost = retained.Host;
_retailUiRuntime = retained.Runtime;
_vitalsVm ??= retained.Vitals;
_retailChatVm = retained.Chat;
_characterSheetProvider = retained.CharacterSheet;
_frameScreenshots = retained.Screenshots;
@ -1347,65 +1316,15 @@ public sealed class GameWindow :
Console.Error.WriteLine),
this).Compose(platformResult, hostInputCamera),
(platformResult, hostInputCamera, contentEffectsAudio) =>
{
SettingsDevToolsOptionalDependencies? optionalDevTools = null;
if (DevToolsEnabled)
{
var devToolsWorldEntities =
new DeferredCanonicalWorldEntityCountSource();
var devToolsFrameDiagnostics =
new DeferredRenderFrameDiagnosticsSource();
var devToolsPlayerModeCommands =
new DeferredDevToolsPlayerModeCommands();
var devToolsFacts = new DevToolsRuntimeFacts(
_localPlayerMode,
_playerControllerSlot,
hostInputCamera.CameraController,
devToolsWorldEntities,
_animatedEntities,
_debugVmRenderFacts,
_physicsEngine,
_worldSceneDebugState,
_renderRange,
hostInputCamera.CameraPointerInput,
_worldEnvironment,
Lighting,
contentEffectsAudio.ParticleSystem,
devToolsFrameDiagnostics);
IRuntimeKeyBindingTarget? keyBindingTarget =
hostInputCamera.InputDispatcher is { } settingsDispatcher
? new RuntimeKeyBindingTarget(
settingsDispatcher,
_applicationPaths.KeyBindingsFile)
: null;
optionalDevTools = new SettingsDevToolsOptionalDependencies(
devToolsFacts,
keyBindingTarget,
devToolsWorldEntities,
devToolsFrameDiagnostics,
devToolsPlayerModeCommands);
}
return new SettingsDevToolsCompositionPhase(
new SettingsDevToolsCompositionPhase(
new SettingsDevToolsDependencies(
_window!,
_runtimeSettings,
new RuntimeSettingsStartupTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
hostInputCamera.CameraController,
contentEffectsAudio.Audio?.Engine),
_hostQuiescence,
_runtime,
optionalDevTools,
_runtimeDiagnosticCommands,
_combatFeedback,
_keyBindings,
_frameProfiler,
_framebufferResize,
Console.WriteLine),
this).Compose(platformResult, hostInputCamera, contentEffectsAudio);
},
contentEffectsAudio.Audio?.Engine)))
.Compose(platformResult, hostInputCamera, contentEffectsAudio),
(platformResult, contentEffectsAudio, settingsDevTools) =>
{
const uint initialCenterLandblockId = 0xA9B4FFFFu;
@ -1433,9 +1352,10 @@ public sealed class GameWindow :
},
(platformResult, hostInputCamera, contentEffectsAudio, settingsDevTools, worldRender) =>
{
Action<string>? compositionToast = settingsDevTools.DevTools is { } devTools
? text => devTools.Debug.AddToast(text)
: null;
// The ImGui developer-tools debug toast sink was removed at
// Campaign V slice V11 along with the rest of the ImGui
// frontend; there is no replacement toast surface yet.
Action<string>? compositionToast = null;
return new InteractionRetainedUiCompositionPhase(
new InteractionRetainedUiDependencies(
_options,
@ -1469,7 +1389,7 @@ public sealed class GameWindow :
_window!,
viewPlane),
_uiFrameDiagnostics,
settingsDevTools.DevTools?.Vitals,
ExistingVitals: null,
compositionToast,
ClientTimerNow,
Console.WriteLine,
@ -1490,9 +1410,10 @@ public sealed class GameWindow :
worldRender,
interactionUi) =>
{
Action<string>? compositionToast = settingsDevTools.DevTools is { } devTools
? text => devTools.Debug.AddToast(text)
: null;
// The ImGui developer-tools debug toast sink was removed at
// Campaign V slice V11 along with the rest of the ImGui
// frontend; there is no replacement toast surface yet.
Action<string>? compositionToast = null;
return new LivePresentationCompositionPhase(
new LivePresentationDependencies(
_options,
@ -1527,8 +1448,8 @@ public sealed class GameWindow :
_hookRouter,
_renderDiagnosticLog,
WorldTime,
settingsDevTools.DevTools?.LateBindings.WorldEntities,
settingsDevTools.DevTools?.LateBindings.FrameDiagnostics,
DevWorldEntities: null,
DevFrameDiagnostics: null,
_uiFrameDiagnostics,
Console.WriteLine,
compositionToast),
@ -1761,7 +1682,6 @@ public sealed class GameWindow :
_kbSource,
_retailUiLease,
_uiHost,
_devToolsComposition,
_runtime,
_runtimeSettings,
_movementInput,
@ -1797,7 +1717,6 @@ public sealed class GameWindow :
new RenderShutdownRoots(
_gpuFrameFlights,
_gpuDevice,
_devToolsComposition,
_localPlayerTeleport,
_portalTunnelFallback,
_paperdollViewportRenderer,

View file

@ -63,7 +63,6 @@ internal sealed record IngressShutdownRoots(
RetailUiRuntimeLease RetailUi,
// Keeps failed physical UI bindings alive through native-window release.
UiHost? RetainedUiHost,
DevToolsCompositionOwner? DevTools,
GameRuntime Runtime,
RuntimeSettingsController Settings,
DispatcherMovementInputSource MovementInput,
@ -102,7 +101,6 @@ internal sealed record LiveShutdownRoots(
internal sealed record RenderShutdownRoots(
GpuFrameFlightController? FrameFlights,
IGpuDevice? GpuDevice,
DevToolsCompositionOwner? DevTools,
LocalPlayerTeleportController? LocalTeleport,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
PaperdollViewportRenderer? Paperdoll,
@ -350,7 +348,6 @@ internal static class GameWindowShutdownManifest
Hard("mouse source", () => ingress.MouseSource?.Deactivate()),
Hard("keyboard source", () => ingress.KeyboardSource?.Deactivate()),
Hard("retained UI input", ingress.RetailUi.QuiesceInput),
Hard("developer tools input", () => ingress.DevTools?.DeactivateInput()),
Hard("game runtime session", ingress.Runtime.StopSession),
]),
new ResourceShutdownStage("physical ingress cleanup",
@ -359,7 +356,6 @@ internal static class GameWindowShutdownManifest
Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)),
Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)),
Soft("retained UI input", ingress.RetailUi.DeactivateInput),
Soft("developer tools input", () => ingress.DevTools?.DetachInput()),
Soft("camera pointer", () => DisposeCameraPointer(ingress.CameraPointer)),
Soft("dispatcher", () => DisposeDispatcher(ingress)),
Soft("mouse source", () => DisposeMouseSource(ingress.MouseSource)),
@ -420,7 +416,6 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("render frontends",
[
Hard("developer tools", () => DisposeDevToolsFrontend(render.DevTools)),
Hard("portal tunnel", () =>
{
render.LocalTeleport?.Dispose();
@ -600,12 +595,4 @@ internal static class GameWindowShutdownManifest
throw new InvalidOperationException("OpenAL native-resource cleanup remains pending.");
}
private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)
{
if (owner is null)
return;
owner.DisposeFrontend();
if (!owner.IsFrontendDisposalComplete)
throw new InvalidOperationException("Developer-tools frontend cleanup remains incomplete.");
}
}

View file

@ -5,6 +5,20 @@ internal interface IRenderWeatherFramePhase
void Tick(double deltaSeconds);
}
/// <summary>
/// Optional per-frame developer-presentation hook. Its one production
/// implementation (the ImGui developer-tools frontend) was removed at
/// Campaign V slice V11; the interface survives as the seam a follow-up
/// re-homing Settings/Debug onto the retained UI will implement, and every
/// current caller already treats it as optional (<c>null</c>-conditional).
/// </summary>
internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
{
void BeginFrame(float deltaSeconds);
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
}
/// <summary>
/// Preserves the accepted pre-world order after the GPU/resource transaction:
/// begin optional developer UI, advance render-time weather, then hand the

View file

@ -1,235 +0,0 @@
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Studio;
// ─────────────────────────────────────────────────────────────────────────────
// DumpLayout — load a panel from the retail UI layout dump
//
// The dump stores every node's rect in ABSOLUTE screen coordinates (the
// panel's design position in the retail UI, not relative to its parent).
// Evidence: for the "inventory" panel, the root node is at x=500,y=138 and
// its direct children are also at x=500,y=161 — the child y=161 is only
// 23 pixels below the parent y=138, which makes sense as a child offset
// (the header row), not as the raw rect. If the rects were parent-relative,
// (500,161) would place the child way off the window.
//
// DumpLayout converts absolute → parent-relative by computing:
// child.Left = child.Rect.X - parent.Rect.X
// child.Top = child.Rect.Y - parent.Rect.Y
//
// The root node (ParentTraversalIndex == null) is placed at (0,0) so the
// whole tree sits at the UiHost origin rather than at the panel's retail
// screen position.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a static <see cref="UiElement"/> tree from the retail UI layout dump
/// JSON. The tree is a hierarchy of <see cref="DumpSpriteElement"/> (draws its
/// sprite) or plain <see cref="UiElement"/> containers (Group nodes), with each
/// node's <see cref="UiElement.EventId"/> set to the dump's element_id and
/// <see cref="UiElement.Name"/> set to the widget_kind string.
///
/// <para>This source is STATIC — no controllers, no FixtureProvider, no live
/// game data. It is a build reference for the UI Studio showing any of the 26
/// retail windows without needing the production panel wired up.</para>
/// </summary>
public static class DumpLayout
{
/// <summary>
/// Parse the dump at <paramref name="dumpPath"/>, find the panel whose slug
/// matches <paramref name="slug"/>, and build a <see cref="UiElement"/> tree.
///
/// <para>
/// <paramref name="resolve"/> maps a RenderSurface id (0x06xxxxxx) to a
/// (GL texture handle, native width, native height) triple — pass
/// <c>RenderStack.ResolveChrome</c> from the studio, or a stub returning
/// (1,1,1) for tests.
/// </para>
///
/// <para>Returns null and sets <paramref name="error"/> on failure.</para>
/// </summary>
public static UiElement? Load(
string dumpPath,
string slug,
Func<uint, (uint, int, int)> resolve,
out string? error)
{
// ── 1. Parse the dump JSON ────────────────────────────────────────
var dump = UiDumpModel.Parse(dumpPath);
if (dump is null)
{
error = $"[dump] Failed to parse '{dumpPath}'.";
return null;
}
// ── 2. Find the requested panel ───────────────────────────────────
var panel = dump.Panels.FirstOrDefault(
p => string.Equals(p.Slug, slug, StringComparison.OrdinalIgnoreCase));
if (panel is null)
{
error = $"[dump] Panel slug '{slug}' not found. " +
$"Available: {string.Join(", ", dump.Panels.Select(p => p.Slug))}";
return null;
}
if (panel.Nodes.Count == 0)
{
error = $"[dump] Panel '{slug}' has no nodes.";
return null;
}
// ── 3. Build a traversal-index → node lookup ──────────────────────
var byIndex = new Dictionary<int, DumpNode>(panel.Nodes.Count);
foreach (var n in panel.Nodes)
byIndex[n.TraversalIndex] = n;
// ── 4. Create UiElement objects for every node ────────────────────
var elements = new Dictionary<int, UiElement>(panel.Nodes.Count);
foreach (var node in panel.Nodes)
{
var el = BuildElement(node, resolve);
elements[node.TraversalIndex] = el;
}
// ── 5. Wire parentchild relationships + set parent-relative coords ─
UiElement? root = null;
foreach (var node in panel.Nodes)
{
var el = elements[node.TraversalIndex];
if (node.ParentTraversalIndex is null)
{
// Root node — place at (0,0) so the tree sits at the UiHost origin.
// The panel's absolute rect offset is discarded here (it was the
// retail design position inside the retail screen, which we don't need).
el.Left = 0f;
el.Top = 0f;
root = el;
}
else
{
// Non-root: convert absolute → parent-relative by subtracting parent rect.
// child.Left = child.Rect.X - parent.Rect.X
// child.Top = child.Rect.Y - parent.Rect.Y
// This preserves the visual layout inside each group without placing the
// entire panel at its retail screen origin.
var parentNode = byIndex[node.ParentTraversalIndex.Value];
el.Left = node.Rect.X - parentNode.Rect.X;
el.Top = node.Rect.Y - parentNode.Rect.Y;
var parentEl = elements[node.ParentTraversalIndex.Value];
parentEl.AddChild(el);
}
}
if (root is null)
{
error = $"[dump] Panel '{slug}': no root node found (all nodes have a parent).";
return null;
}
// Give the root the full panel dimensions (from the dump's width/height record).
root.Width = panel.Width;
root.Height = panel.Height;
error = null;
return root;
}
// ── Private helpers ───────────────────────────────────────────────────────
private static UiElement BuildElement(
DumpNode node,
Func<uint, (uint, int, int)> resolve)
{
uint imageId = UiDumpModel.PickImageId(node);
var kind = node.WidgetKind ?? "Group";
UiElement el;
if (imageId != 0 && !string.Equals(kind, "Group", StringComparison.OrdinalIgnoreCase))
{
// Sprite/Button/Scrollbar/Slider — create a sprite-drawing element.
el = new DumpSpriteElement(imageId, resolve)
{
Name = kind,
ClickThrough = true, // static mockup; no behavior
Anchors = AnchorEdges.None,
};
}
else
{
// Group (or sprite without an image) — plain container, no own draw.
el = new DumpGroupElement()
{
Name = kind,
ClickThrough = true,
Anchors = AnchorEdges.None,
};
}
// EventId is set from the dump's element_id (cast to uint — the decimal
// values in the JSON represent the same dat handle used at runtime).
el.EventId = (uint)node.ElementId;
el.Left = node.Rect.X; // overwritten by caller per root/child logic
el.Top = node.Rect.Y;
el.Width = node.Rect.Width;
el.Height = node.Rect.Height;
return el;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// DumpSpriteElement — minimal element that draws a single sprite
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Draws a single sprite at its native size tiled to fill <see cref="UiElement.Width"/>
/// × <see cref="UiElement.Height"/>. Used for Sprite/Button/Scrollbar/Slider nodes from
/// the retail UI dump.
///
/// <para>We do NOT reuse <see cref="AcDream.App.UI.Layout.UiDatElement"/> here because
/// that class requires an <c>ElementInfo</c> with a populated <c>StateMedia</c>
/// dictionary — the dat-import plumbing — which is not needed for a static dump
/// preview. A minimal subclass keeps the code simpler and the dependency surface
/// smaller.</para>
/// </summary>
internal sealed class DumpSpriteElement : UiElement
{
private readonly uint _imageId;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public DumpSpriteElement(uint imageId, Func<uint, (uint tex, int w, int h)> resolve)
{
_imageId = imageId;
_resolve = resolve;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (_imageId == 0) return;
var (tex, tw, th) = _resolve(_imageId);
if (tex == 0 || tw == 0 || th == 0) return;
// Tile at native resolution (same as UiDatElement.OnDraw — UV-repeat on both
// axes via GL_REPEAT, Width/tw and Height/th tile the texture).
ctx.DrawSprite(tex, 0, 0, Width, Height,
0, 0, Width / tw, Height / th, Vector4.One);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// DumpGroupElement — pure container (Group nodes from the dump)
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Container element for dump Group nodes — no own draw, just hosts children.
/// Extending UiElement directly (no OnDraw override) gives transparent groups,
/// which matches Group nodes in the retail layout that have no background sprite.
/// </summary>
internal sealed class DumpGroupElement : UiElement
{
// No OnDraw — completely transparent container.
}

View file

@ -1,225 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Studio;
/// <summary>
/// Populates a loaded panel with sample data by calling the production
/// controller Bind methods against <see cref="SampleData"/>.
///
/// <para>
/// The studio is intentionally thin — there is no live game session, no
/// server connection, and no network. FixtureProvider bridges that gap by
/// feeding static fixtures (vitals percentages, a fake inventory, empty
/// shortcut lists) so the bound widgets show plausible state instead of
/// empty zeroes.
/// </para>
///
/// <para>
/// <b>IconIds approach:</b> raw-resolve stub — resolve the base <c>iconId</c>
/// via <see cref="RenderStack.ResolveChrome"/> and return the GL handle
/// directly. This is intentionally simpler than GameWindow's full
/// <see cref="IconComposer"/> (5-layer composite). The raw icon is enough
/// to confirm the grid cells draw something in the studio; the full
/// compositor is the live-game concern, not the layout preview concern.
/// </para>
/// </summary>
public static class FixtureProvider
{
/// <summary>
/// Populate <paramref name="layout"/> with sample data appropriate for
/// <paramref name="layoutId"/>. Calls the production controller Bind
/// methods so the panel's widgets drive off the same code path as the
/// full game.
/// </summary>
/// <param name="layoutId">The LayoutDesc dat id used to import the layout.</param>
/// <param name="layout">The imported layout whose widgets to populate.</param>
/// <param name="stack">The live render stack (for <see cref="RenderStack.ResolveChrome"/>
/// and <see cref="RenderStack.VitalsDatFont"/>).</param>
/// <param name="objects">A <see cref="ClientObjectTable"/> already seeded by
/// <see cref="SampleData.BuildObjectTable"/>.</param>
/// <param name="dats">The live DAT collection used to resolve per-list empty-slot sprites
/// (same lookup GameWindow.OnLoad performs for the production binding).</param>
public static IRetainedPanelController? Populate(
uint layoutId,
ImportedLayout layout,
RenderStack stack,
ClientObjectTable objects,
IDatReaderWriter dats)
{
switch (layoutId)
{
case 0x2100006Cu: // vitals
VitalsController.Bind(layout,
healthPct: () => SampleData.HealthPct,
staminaPct: () => SampleData.StaminaPct,
manaPct: () => SampleData.ManaPct,
healthText: () => "80/100",
staminaText: () => "60/100",
manaText: () => "90/100");
return null;
case RadarController.LayoutId: // gmRadarUI
return RadarController.Bind(
layout,
() => new UiRadarSnapshot(
PlayerHeadingDegrees: 32f,
Blips:
[
new UiRadarBlip(0x50000001u, "Portal to Holtburg", 82f, 43f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Portal),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000002u, "Drudge Prowler", 43f, 76f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Creature),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000003u, "Town Crier", 68f, 87f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.NPC),
AcDream.Core.Ui.RadarBlipShape.Box),
new UiRadarBlip(0x50000004u, "Selected PK", 39f, 39f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.PlayerKiller),
AcDream.Core.Ui.RadarBlipShape.X,
Selected: true),
],
CoordinatesText: "42.1N,33.3E"),
datFont: stack.VitalsDatFont);
case CombatUiController.LayoutId:
{
var combat = new CombatState();
var attacks = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
autoRepeatAttack: () => false);
GameplaySettings gameplay = GameplaySettings.Default;
CombatUiController? controller = CombatUiController.Bind(
layout,
combat,
attacks,
() => gameplay,
value => gameplay = value,
new CombatUiLabels(
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low"),
visible => layout.Root.Visible = visible);
combat.SetCombatMode(CombatMode.Melee);
return controller;
}
case 0x21000016u: // toolbar
return ToolbarController.Bind(
layout,
objects,
shortcuts: new ShortcutStore(),
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,
regularDigits: null,
ghostedDigits: null,
emptyDigits: null,
sendAddShortcut: null,
sendRemoveShortcut: null);
case 0x21000023u: // inventory + paperdoll
{
// Resolve the per-list empty-slot art from the dat cell template, matching the
// exact lookup GameWindow.OnLoad performs (UIElement_ItemList::InternalCreateItem
// 0x004e3570 → attr 0x1000000e → catalog 0x21000037 → ItemSlot_Empty).
uint contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021u, 0x100001C6u);
uint sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001CAu);
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
var paperdollEmpty = PaperdollSlotBackgrounds.ResolveEmptySprites(dats);
var iconIds = MakeIconIds(stack);
var selection = new AcDream.Core.Selection.SelectionState();
var itemInteraction = new ItemInteractionController(
objects,
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(
new InventoryTransactionState(objects)),
new AcDream.Runtime.Gameplay.InteractionState(),
() => SampleData.PlayerGuid,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
InventoryController inventory = InventoryController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
strength: () => 100,
selection: selection,
datFont: stack.VitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty);
// Bind the paperdoll equip slots with their authored per-location UIItem prototypes.
PaperdollController paperdoll = PaperdollController.Bind(
layout,
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
selection: selection,
itemInteraction: itemInteraction,
emptySlotSprite: contentsEmpty,
datFont: stack.VitalsDatFont,
emptySlotSprites: paperdollEmpty,
ownsItemInteraction: true);
return new RetainedPanelControllerGroup(inventory, paperdoll);
}
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
// total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report
// sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController).
// LargeDatFont (0x40000001, MaxCharHeight=18) is used for the attribute row text;
// fallback to VitalsDatFont (0x40000000, 16px) if unavailable.
CharacterStatController.Bind(
layout,
data: SampleData.SampleCharacter,
datFont: stack.VitalsDatFont,
rowDatFont: stack.LargeDatFont ?? stack.VitalsDatFont,
spriteResolve: stack.ResolveChrome);
return null;
default:
// Unknown layout — no-op; the panel renders structurally.
return null;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Build the <c>iconIds</c> delegate for toolbar / inventory controllers.
///
/// <para>
/// Raw-resolve stub: resolve the base <paramref name="iconId"/> (arg 2)
/// via <see cref="RenderStack.ResolveChrome"/> and return its GL handle.
/// The remaining args (type, underlayId, overlayId, effects) are ignored
/// for the studio — a single-layer icon is sufficient for layout preview.
/// </para>
///
/// <para>This is what the task spec calls "v1 raw-resolve stub".</para>
/// </summary>
private static Func<ItemType, uint, uint, uint, uint, uint> MakeIconIds(RenderStack stack)
=> (_, iconId, _, _, _) =>
{
if (iconId == 0u) return 0u;
var (handle, _, _) = stack.ResolveChrome(iconId);
return handle;
};
private static System.Numerics.Vector4 RadarColor(AcDream.Core.Ui.RadarBlipColors.Rgba color)
=> new(color.Red, color.Green, color.Blue, color.Alpha);
}

View file

@ -1,124 +0,0 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Studio;
/// <summary>Which kind of source the studio is currently previewing.</summary>
public enum LayoutSourceKind { DatLayout, Markup }
/// <summary>
/// Wraps the two ways the UI Studio can load a panel to preview:
/// a LayoutDesc dat id, or a KSML markup file path (Task 6 — unsupported now).
///
/// <para>Call <see cref="Load"/> with the current <see cref="StudioOptions"/> to
/// import the layout and get the root <see cref="UiElement"/>. The result is also
/// cached in <see cref="CurrentLayout"/> so <see cref="Reload"/> can re-run the same
/// source without re-reading the options.</para>
/// </summary>
public sealed class LayoutSource
{
private readonly IDatReaderWriter _dats;
private readonly Func<uint, (uint, int, int)> _resolve;
private readonly UiDatFont? _datFont;
private readonly Func<uint, UiDatFont?>? _fontResolve;
public LayoutSourceKind Kind { get; private set; }
public uint? LayoutId { get; private set; }
public string? MarkupPath { get; private set; }
public string? LastError { get; private set; }
public ImportedLayout? CurrentLayout { get; private set; }
/// <summary>
/// Create a LayoutSource.
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver: FontDid →
/// <see cref="UiDatFont"/> (null when the font isn't in the dats). When supplied,
/// elements with a non-zero FontDid receive their own dat font at build time
/// instead of the shared <paramref name="datFont"/> global. Controllers that
/// explicitly set <see cref="UiText.DatFont"/> after
/// <see cref="ImportedLayout.FindElement"/> still override the build-time value.
/// Pass null (default) for the original single-font behavior — the live
/// <see cref="GameWindow"/> path passes null so it is provably unchanged.</param>
public LayoutSource(
IDatReaderWriter dats,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_datFont = datFont;
_fontResolve = fontResolve;
}
/// <summary>
/// Load the layout described by <paramref name="opts"/>. For a dat layout
/// (<see cref="StudioOptions.LayoutId"/> is non-null) calls
/// <see cref="LayoutImporter.Import"/>. For a markup path sets
/// <see cref="LastError"/> and returns null (Task 6, not yet implemented).
///
/// Returns the root <see cref="UiElement"/> on success, or null on failure
/// (check <see cref="LastError"/>).
/// </summary>
public UiElement? Load(StudioOptions opts)
{
LastError = null;
CurrentLayout = null;
if (opts.MarkupPath is not null)
{
Kind = LayoutSourceKind.Markup;
MarkupPath = opts.MarkupPath;
LastError = "markup unsupported (Task 6)";
return null;
}
if (opts.LayoutId is null)
{
LastError = "ui-studio: no layout id or markup path specified.";
return null;
}
Kind = LayoutSourceKind.DatLayout;
LayoutId = opts.LayoutId;
return LoadDat(opts.LayoutId.Value);
}
/// <summary>Re-run the most-recently-configured source without re-reading options.</summary>
public UiElement? Reload()
{
LastError = null;
CurrentLayout = null;
if (Kind == LayoutSourceKind.Markup)
{
LastError = "markup unsupported (Task 6)";
return null;
}
if (LayoutId is null)
{
LastError = "ui-studio: no layout id to reload.";
return null;
}
return LoadDat(LayoutId.Value);
}
// ── Private ──────────────────────────────────────────────────────────────────
private UiElement? LoadDat(uint layoutId)
{
var imported = LayoutImporter.Import(_dats, layoutId, _resolve, _datFont, _fontResolve);
if (imported is null)
{
LastError = $"ui-studio: LayoutDesc 0x{layoutId:X8} not found in dats.";
return null;
}
CurrentLayout = imported;
return imported.Root;
}
}

View file

@ -1,493 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using DatReaderWriter;
using AcDream.Content;
using System.Numerics;
namespace AcDream.App.Studio;
/// <summary>
/// Builds a multi-window UI desktop for the studio. It mounts several real
/// imported panels into one production <see cref="UiHost"/> so drag, resize,
/// click, focus, and z-order behavior all run through the same UI root as the
/// game.
/// </summary>
internal static class MockupDesktop
{
public static void Load(IDatReaderWriter dats, RenderStack stack)
{
var objects = SampleData.BuildObjectTable();
var windows = new List<MockupWindow>();
if (MountVitals(dats, stack, objects) is { } vitals)
RecordWindow(windows, "Vitals", vitals, zOrder: 10);
if (MountToolbar(dats, stack, objects) is { } toolbar)
RecordWindow(windows, "Toolbar", toolbar, zOrder: 20);
if (MountCharacter(dats, stack, objects) is { } character)
RecordWindow(windows, "Character", character, zOrder: 30);
if (MountInventory(dats, stack, objects) is { } inventory)
RecordWindow(windows, "Inventory", inventory, zOrder: 40);
if (MountChat(dats, stack) is { } chat)
RecordWindow(windows, "Chat", chat, zOrder: 50);
MountControls(stack, windows);
}
private static ImportedLayout? Import(IDatReaderWriter dats, uint layoutId, RenderStack stack)
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont);
private static RetailWindowHandle? MountVitals(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x2100006Cu, stack);
if (layout is null) return null;
IRetainedPanelController? controller =
FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats);
return RetailWindowFrame.Mount(
stack.UiHost.Root,
layout.Root,
stack.ResolveChrome,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Vitals,
Chrome = RetailWindowChrome.Imported,
Left = 12f,
Top = 18f,
ResizeX = true,
ResizeY = false,
MinWidth = 40f,
ContentClickThrough = false,
Controller = controller,
});
}
private static RetailWindowHandle? MountToolbar(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x21000016u, stack);
if (layout is null) return null;
IRetainedPanelController? controller =
FixtureProvider.Populate(0x21000016u, layout, stack, objects, dats);
const int border = RetailChromeSprites.Border;
var toolbarRoot = layout.Root;
float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f;
float contentH = toolbarRoot.Height;
RetailWindowHandle handle = RetailWindowFrame.Mount(
stack.UiHost.Root,
toolbarRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Toolbar,
Chrome = RetailWindowChrome.CollapsibleNineSlice,
Left = 12f,
Top = 220f,
ContentWidth = contentW,
ContentHeight = contentH,
Resizable = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
Controller = controller,
});
var frame = (UiCollapsibleFrame)handle.OuterFrame;
ConfigureToolbarCollapse(layout, frame, contentH, border);
return handle;
}
private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border)
{
uint[] row2Ids =
{
0x100006B6u,
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
0x100006C0u,
};
var row2 = new List<UiElement>();
float minRow2Top = float.MaxValue;
foreach (var id in row2Ids)
{
if (layout.FindElement(id) is not { } element) continue;
row2.Add(element);
if (element.Top < minRow2Top) minRow2Top = element.Top;
}
if (row2.Count == 0) return;
float expandedH = contentH + 2 * border;
float collapsedH = minRow2Top + 2 * border;
frame.CollapsedHeight = collapsedH;
frame.ExpandedHeight = expandedH;
frame.SecondRow = row2;
frame.Resizable = true;
frame.ResizableEdges = ResizeEdges.Bottom;
frame.MinHeight = collapsedH;
frame.MaxHeight = expandedH;
}
private static RetailWindowHandle? MountCharacter(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x2100002Eu, stack);
if (layout is null) return null;
IRetainedPanelController? controller =
FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats);
return RetailWindowFrame.Mount(
stack.UiHost.Root,
layout.Root,
stack.ResolveChrome,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
Left = 540f,
Top = 18f,
MaxHeight = 760f,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
Controller = controller,
});
}
private static RetailWindowHandle? MountInventory(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{
var layout = Import(dats, 0x21000023u, stack);
if (layout is null) return null;
IRetainedPanelController? controller =
FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats);
var inventoryRoot = layout.Root;
float contentW = inventoryRoot.Width;
float contentH = inventoryRoot.Height;
RetailWindowHandle handle = RetailWindowFrame.Mount(
stack.UiHost.Root,
inventoryRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Inventory,
Chrome = RetailWindowChrome.NineSlice,
Left = 900f,
Top = 18f,
ContentWidth = contentW,
ContentHeight = contentH,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
MaxHeight = Math.Max(
contentH + 2f * RetailChromeSprites.Border,
stack.UiHost.Root.Height - 18f),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
Controller = controller,
});
StretchV(layout, 0x100001D0u);
StretchV(layout, 0x100001CFu);
StretchV(layout, 0x100001C6u);
StretchV(layout, 0x100001C7u);
PinTopLeft(layout, 0x100001CDu);
PinTopLeft(layout, 0x100001CEu);
return handle;
}
private static void StretchV(ImportedLayout layout, uint id)
{
if (layout.FindElement(id) is { } element)
element.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
}
private static void PinTopLeft(ImportedLayout layout, uint id)
{
if (layout.FindElement(id) is { } element)
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
}
private static RetailWindowHandle? MountChat(IDatReaderWriter dats, RenderStack stack)
{
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont,
stringResolve: strings.Resolve);
var chatLog = new ChatLog();
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);
chatLog.OnSystemMessage("Welcome to the acdream UI mockup.", 0);
chatLog.OnLocalSpeech("You", "This desktop is running through UiHost.", SampleData.PlayerGuid, isRanged: false);
chatLog.OnChannelBroadcast(1, "Caith", "Drag and resize the windows here.", "General");
chatLog.OnTellReceived("Alicia", "The old UI is starting to wake up.", 0x5000ABCDu);
var vm = new ChatVM(chatLog, displayLimit: 200);
var controller = ChatWindowController.Bind(
rootInfo,
layout,
vm,
() => NullCommandBus.Instance,
stack.VitalsDatFont,
debugFont: null,
stack.ResolveChrome);
if (controller is null) return null;
var chatRoot = controller.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
stack.UiHost.Root,
chatRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 12f,
Top = 390f,
ContentWidth = 490f,
ContentHeight = chatRoot.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
StateController = controller,
});
controller.AttachWindow(handle);
stack.UiHost.Root.DefaultTextInput = controller.Input;
return handle;
}
private static void RecordWindow(
List<MockupWindow> windows,
string title,
RetailWindowHandle handle,
int zOrder)
{
UiElement element = handle.OuterFrame;
element.ZOrder = zOrder;
windows.Add(new MockupWindow(
handle.Name,
title,
element,
new MockupRect(element.Left, element.Top, element.Width, element.Height),
zOrder));
}
private static void MountControls(RenderStack stack, IReadOnlyList<MockupWindow> windows)
{
if (windows.Count == 0) return;
var font = stack.VitalsDatFont;
var panel = new MockupControlPanel
{
Name = "mockup-controls",
Left = 1010f,
Top = 430f,
Width = 258f,
Height = 242f,
Anchors = AnchorEdges.None,
Draggable = true,
Resizable = false,
BackgroundColor = new Vector4(0.03f, 0.035f, 0.04f, 0.82f),
BorderColor = new Vector4(0.55f, 0.45f, 0.27f, 0.95f),
BorderThickness = 1f,
ZOrder = 1_000,
};
panel.AddChild(new MockupLabel(font)
{
Left = 12f,
Top = 10f,
Width = 220f,
Height = 18f,
Text = "Mockup",
TextColor = new Vector4(0.95f, 0.86f, 0.62f, 1f),
});
var toggles = new List<(MockupWindow Window, MockupButton Button)>();
panel.AddChild(MakeButton(font, "Arrange", 12f, 34f, 108f, 24f, () =>
{
Arrange(windows, showAll: false);
RefreshToggles(toggles);
}));
panel.AddChild(MakeButton(font, "Reset", 132f, 34f, 108f, 24f, () =>
{
Arrange(windows, showAll: true);
RefreshToggles(toggles);
}));
float y = 70f;
foreach (var window in windows)
{
var button = MakeButton(font, "", 12f, y, 228f, 24f, () =>
{
stack.UiHost.ToggleWindow(window.Name);
RefreshToggles(toggles);
});
toggles.Add((window, button));
panel.AddChild(button);
y += 30f;
}
RefreshToggles(toggles);
stack.UiHost.Root.AddChild(panel);
}
private static MockupButton MakeButton(UiDatFont? font, string text,
float left, float top, float width, float height, Action onClick)
{
var button = new MockupButton(font)
{
Left = left,
Top = top,
Width = width,
Height = height,
Text = text,
};
button.Click += onClick;
return button;
}
private static void Arrange(IReadOnlyList<MockupWindow> windows, bool showAll)
{
foreach (var window in windows)
{
var element = window.Element;
element.Left = window.DefaultRect.Left;
element.Top = window.DefaultRect.Top;
element.Width = window.DefaultRect.Width;
element.Height = window.DefaultRect.Height;
element.ZOrder = window.DefaultZOrder;
if (showAll)
element.Visible = true;
}
}
private static void RefreshToggles(IReadOnlyList<(MockupWindow Window, MockupButton Button)> toggles)
{
foreach (var (window, button) in toggles)
{
bool visible = window.Element.Visible;
button.Text = visible ? $"{window.Title} on" : $"{window.Title} off";
button.TextColor = visible
? new Vector4(0.96f, 0.93f, 0.82f, 1f)
: new Vector4(0.62f, 0.62f, 0.62f, 1f);
button.BackgroundColor = visible
? new Vector4(0.14f, 0.13f, 0.10f, 0.94f)
: new Vector4(0.06f, 0.065f, 0.07f, 0.82f);
}
}
private sealed record MockupWindow(
string Name,
string Title,
UiElement Element,
MockupRect DefaultRect,
int DefaultZOrder);
private readonly record struct MockupRect(float Left, float Top, float Width, float Height);
private sealed class MockupControlPanel : UiPanel
{
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundColor.W > 0f)
ctx.DrawFill(0f, 0f, Width, Height, BackgroundColor);
if (BorderColor.W > 0f && BorderThickness > 0f)
ctx.DrawRectOutline(0f, 0f, Width, Height, BorderColor, BorderThickness);
}
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
if (Parent is null) return;
int top = ZOrder;
foreach (var sibling in Parent.Children)
{
if (!ReferenceEquals(sibling, this))
top = Math.Max(top, sibling.ZOrder + 1);
}
ZOrder = top;
}
}
private sealed class MockupLabel : UiElement
{
private readonly UiDatFont? _font;
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = Vector4.One;
public MockupLabel(UiDatFont? font)
{
_font = font;
ClickThrough = true;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (_font is null || Text.Length == 0) return;
ctx.DrawStringDat(_font, Text, 0f, 0f, TextColor);
}
}
private sealed class MockupButton : UiPanel
{
private readonly UiDatFont? _font;
public event Action? Click;
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(0.96f, 0.93f, 0.82f, 1f);
public MockupButton(UiDatFont? font)
{
_font = font;
BackgroundColor = new Vector4(0.14f, 0.13f, 0.10f, 0.94f);
BorderColor = new Vector4(0.45f, 0.38f, 0.24f, 1f);
BorderThickness = 1f;
}
public override bool HandlesClick => true;
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.Click || !Enabled) return false;
Click?.Invoke();
return true;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundColor.W > 0f)
ctx.DrawFill(0f, 0f, Width, Height, BackgroundColor);
if (BorderColor.W > 0f && BorderThickness > 0f)
ctx.DrawRectOutline(0f, 0f, Width, Height, BorderColor, BorderThickness);
if (_font is null || Text.Length == 0) return;
float textW = _font.MeasureWidth(Text);
float tx = MathF.Max(4f, (Width - textW) * 0.5f);
float ty = (Height - _font.LineHeight) * 0.5f;
ctx.DrawStringDat(_font, Text, tx, ty, TextColor);
}
}
}

View file

@ -1,156 +0,0 @@
using System;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using Silk.NET.OpenGL;
namespace AcDream.App.Studio;
/// <summary>
/// Renders a <see cref="UiHost"/> into an off-screen FBO each frame and
/// returns the color texture handle for display in ImGui.
///
/// <para>Pattern lifted verbatim from <see cref="AcDream.App.Rendering.PaperdollViewportRenderer"/>:
/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on
/// size change. The entire 2-D UI pass is sealed in a <see cref="GLStateScope"/>
/// so it cannot disturb the surrounding ImGui GL state.</para>
///
/// <para>FBO origin is bottom-left (GL convention). The caller must flip V when
/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image)
/// so the image appears right-side-up in ImGui's top-left coordinate system.</para>
/// </summary>
public sealed unsafe class PanelFbo : IDisposable
{
private readonly GL _gl;
// Off-screen target — lazily (re)created when the requested size changes.
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
public PanelFbo(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
/// <summary>
/// Render <paramref name="host"/> (a full <see cref="UiHost"/> draw pass) into a
/// private FBO at <paramref name="width"/> × <paramref name="height"/> pixels.
/// Returns the GL color texture handle (0 on failure). The texture is valid until
/// the next call to <see cref="Render"/> with a different size, or until <see cref="Dispose"/>.
/// </summary>
public uint Render(int width, int height, UiHost host)
{
if (width <= 0 || height <= 0 || host is null) return 0u;
EnsureFramebuffer(width, height);
if (_fbo == 0) return 0u;
// Seal the entire pass: GLStateScope saves + restores every GL state the
// UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state
// — set up by BeginFrame and expected intact by Render — is untouched.
using var scope = new GLStateScope(_gl);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // opaque dark-grey canvas background (the FBO IS the canvas)
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
host.Draw(new Vector2(width, height));
// FBO stays bound here; GLStateScope.Dispose() restores the previous binding.
return _colorTex;
}
/// <summary>
/// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
/// Must be called AFTER <see cref="Render"/> for the same <paramref name="width"/> and
/// <paramref name="height"/> (so the FBO exists and is the right size).
///
/// <para>FBO origin is bottom-left (GL convention). The caller is responsible for
/// flipping rows vertically before saving as a top-left-origin image format (PNG).</para>
///
/// <para>Returns an empty array when the FBO is not ready.</para>
/// </summary>
public unsafe byte[] ReadColorRgba(int width, int height)
{
if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty<byte>();
int byteCount = width * height * 4;
var buf = new byte[byteCount];
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
fixed (byte* p = buf)
{
_gl.ReadPixels(0, 0, (uint)width, (uint)height,
PixelFormat.Rgba, PixelType.UnsignedByte, p);
}
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
return buf;
}
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
private void EnsureFramebuffer(int width, int height)
{
if (_fbo != 0 && width == _fbW && height == _fbH) return;
DeleteFramebuffer();
_fbW = width;
_fbH = height;
_colorTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8,
(uint)width, (uint)height, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_depthRbo = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer,
InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
_fbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer,
FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D, _colorTex, 0);
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer,
FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer, _depthRbo);
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
if (status != GLEnum.FramebufferComplete)
{
Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})");
DeleteFramebuffer();
}
}
private void DeleteFramebuffer()
{
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
_fbW = _fbH = 0;
}
public void Dispose() => DeleteFramebuffer();
}

View file

@ -1,261 +0,0 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
namespace AcDream.App.Studio;
/// <summary>
/// Static sample data for the UI Studio fixture provider.
/// Provides a pre-built <see cref="ClientObjectTable"/> populated with a
/// representative player, their main-pack items, side bags, and equipped gear,
/// so the 2-D inventory / paperdoll panels render populated when previewed in
/// the studio without a live game session.
///
/// Icon ids used (all real 0x06xxxxxx RenderSurface ids confirmed in the dats):
/// Inventory empty-slot sprite : 0x06004D20
/// Generic "misc" item : 0x060011D4 (icon_misc_underlay / fallback)
/// Iron Sword (melee weapon) : 0x060011CBu (acclient default weapon underlay)
/// Leather Breastplate (armor) : 0x060011CFu (acclient default armor underlay)
/// Leather Gloves : 0x060011F3u (acclient default clothing underlay)
/// Steel Ring : 0x060011D5u (acclient default jewelry underlay)
/// Healing Kit : 0x060011D4u (generic misc fallback)
/// Spell components : 0x060011D4u (generic misc fallback)
/// Side-bag 1 (Container) : 0x06004D20u
/// Side-bag 2 (Container) : 0x06004D20u
/// Equipped helm (HeadWear) : 0x060011F3u
/// Equipped chest armor : 0x060011CFu
/// Equipped melee weapon : 0x060011CBu
///
/// These are the icon *base* RenderSurface ids — the same ids GameWindow passes
/// as `iconId` into the iconIds lambda. FixtureProvider resolves them via
/// <see cref="Rendering.RenderStack.ResolveChrome"/> and returns the raw GL handle.
/// </summary>
public static class SampleData
{
// ── Guids ────────────────────────────────────────────────────────────────
/// <summary>Fake server guid for the studio's synthetic player.</summary>
public const uint PlayerGuid = 0x50000001u;
// Items in main pack (slots 05).
private const uint SwordGuid = 0x50000010u;
private const uint ChestGuid = 0x50000011u;
private const uint GlovesGuid = 0x50000012u;
private const uint RingGuid = 0x50000013u;
private const uint HealKitGuid = 0x50000014u;
private const uint CompGuid = 0x50000015u;
// Side bags (also in main pack, ContainerId = PlayerGuid; slots 6 & 7).
private const uint Bag1Guid = 0x50000020u;
private const uint Bag2Guid = 0x50000021u;
// Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set).
private const uint HelmGuid = 0x50000030u;
private const uint ChestEqGuid = 0x50000031u;
private const uint WeaponEqGuid = 0x50000032u;
// ── Icon ids (0x06xxxxxx RenderSurface dat ids) ───────────────────────────
// These are the same underlay/fallback icon ids the IconComposer tests pin.
private const uint IconWeapon = 0x060011CBu; // weapon underlay
private const uint IconArmor = 0x060011CFu; // armor underlay
private const uint IconClothing = 0x060011F3u; // clothing underlay
private const uint IconJewelry = 0x060011D5u; // jewelry underlay
private const uint IconMisc = 0x060011D4u; // misc / fallback underlay
// ── Public API ──────────────────────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="ClientObjectTable"/> populated with the
/// studio's sample player + a realistic inventory snapshot.
/// The table is owned by the caller and should be kept alive for the
/// window's lifetime.
/// </summary>
public static ClientObjectTable BuildObjectTable()
{
var t = new ClientObjectTable();
// ── Player object ─────────────────────────────────────────────────
t.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Name = "Studio Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
// Show all three authored sigil locations in inventory/paperdoll previews.
t.UpdateIntProperty(
PlayerGuid,
AetheriaUnlocks.PropertyId,
(int)AetheriaUnlockState.All);
// ── Loose items in main pack (slots 05) ──────────────────────────
AddItem(t, SwordGuid, ItemType.MeleeWeapon, IconWeapon, "Iron Sword", PlayerGuid, 0, burden: 60);
AddItem(t, ChestGuid, ItemType.Armor, IconArmor, "Leather Breastplate", PlayerGuid, 1, burden: 200);
AddItem(t, GlovesGuid, ItemType.Clothing, IconClothing, "Leather Gloves", PlayerGuid, 2, burden: 50);
AddItem(t, RingGuid, ItemType.Jewelry, IconJewelry, "Steel Ring", PlayerGuid, 3, burden: 10);
AddItem(t, HealKitGuid, ItemType.Misc, IconMisc, "Healing Kit", PlayerGuid, 4, burden: 30);
AddItem(t, CompGuid, ItemType.SpellComponents, IconMisc, "Spell Comps", PlayerGuid, 5, burden: 25, stackSize: 50, stackMax: 100);
// ── Side bags (Container items in main pack, slots 6 & 7) ─────────
AddItem(t, Bag1Guid, ItemType.Container, IconMisc, "Small Pack 1",
containerId: PlayerGuid, slot: 6, burden: 20, itemsCapacity: 24);
AddItem(t, Bag2Guid, ItemType.Container, IconMisc, "Small Pack 2",
containerId: PlayerGuid, slot: 7, burden: 20, itemsCapacity: 24);
// ── Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set) ──
AddEquipped(t, HelmGuid, ItemType.Armor, IconClothing, "Tin Helm", EquipMask.HeadWear);
AddEquipped(t, ChestEqGuid, ItemType.Armor, IconArmor, "Chain Coat", EquipMask.ChestArmor);
AddEquipped(t, WeaponEqGuid, ItemType.MeleeWeapon, IconWeapon, "Wooden Sword", EquipMask.MeleeWeapon);
return t;
}
// ── Sample vital constants (used by FixtureProvider) ────────────────────
public const float HealthPct = 0.8f;
public const float StaminaPct = 0.6f;
public const float ManaPct = 0.9f;
// ── Sample character sheet (used by CharacterController in the Studio) ───
/// <summary>
/// Returns a representative <see cref="CharacterSheet"/> for the studio's
/// synthetic character. Values are plausible retail-scale numbers so the
/// report renders with well-proportioned text in all sections.
/// </summary>
public static CharacterSheet SampleCharacter() => SampleCharacter(null);
public static CharacterSheet SampleCharacter(string? name) => new()
{
Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name,
Level = 126,
Gender = "Female",
Heritage = "Aluvian",
Title = "the Adventurer",
BirthDate = "January 5, 2001",
PlayTime = "2 years, 114 days, 4 hours",
Deaths = 42,
PkStatus = "Non-Player Killer",
TotalXp = 1_250_000_000,
XpToNextLevel = 42_000_000,
XpFraction = 0.63f,
// Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
HealthCurrent = 5, HealthMax = 5,
StaminaCurrent = 10, StaminaMax = 10,
ManaCurrent = 10, ManaMax = 10,
// Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
Strength = 200,
Endurance = 10,
Quickness = 200,
Coordination = 10,
Focus = 10,
Self = 10,
UnspentSkillCredits = 12,
SpecializedSkillCredits = 4,
ChessRank = 12,
FishingSkill = 4,
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
SkillCredits = 96,
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
UnassignedXp = 87_757_321_741L,
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
// Focus, Self, Health, Stamina, Mana).
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
// Formula bracket at value=10: ExperienceToAttributeLevel(11) ExperienceToAttributeLevel(10).
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L },
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.
Skills = new CharacterSkill[]
{
new( 6, "Melee Defense", 0x06000165u, CharacterSkillAdvancementClass.Specialized, 350, 354, false, 10, 20, 18_250_000L, 182_500_000L),
new(34, "War Magic", 0x06001365u, CharacterSkillAdvancementClass.Specialized, 280, 285, false, 16, 28, 11_100_000L, 111_000_000L),
new(14, "Arcane Lore", 0x0600016Eu, CharacterSkillAdvancementClass.Trained, 260, 269, false, 4, 6, 7_500_000L, 75_000_000L),
new(33, "Life Magic", 0x06001364u, CharacterSkillAdvancementClass.Trained, 250, 252, false, 12, 20, 6_800_000L, 68_000_000L),
new(47, "Missile Weapons", 0x0600015Fu, CharacterSkillAdvancementClass.Trained, 220, 221, false, 6, 12, 5_250_000L, 52_500_000L),
new(21, "Healing", 0x06000133u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 6, 10, 0L),
new(22, "Jump", 0x0600016Bu, CharacterSkillAdvancementClass.Untrained, 210, 210, true, 0, 4, 0L),
new(36, "Loyalty", 0x06001367u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 0, 2, 0L),
new(24, "Run", 0x06000173u, CharacterSkillAdvancementClass.Untrained, 390, 390, true, 0, 4, 0L),
new(38, "Alchemy", 0x060019E4u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 6, 12, 0L),
new(39, "Cooking", 0x06001A54u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
},
CharacterInfoProperties = new Dictionary<uint, int>
{
[0x162u] = 2, // Swords melee mastery
},
BurdenCurrent = 1200,
BurdenMax = 4500,
};
// ── Helpers ─────────────────────────────────────────────────────────────
private static void AddItem(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
uint containerId,
int slot,
int burden = 0,
int stackSize = 1,
int stackMax = 1,
int itemsCapacity = 0)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
Burden = burden,
StackSize = stackSize,
StackSizeMax = stackMax,
ItemsCapacity = itemsCapacity,
});
t.MoveItem(guid, containerId, slot);
}
private static void AddEquipped(
ClientObjectTable t,
uint guid,
ItemType type,
uint iconId,
string name,
EquipMask equipMask)
{
t.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = name,
Type = type,
IconId = iconId,
ValidLocations = equipMask,
CurrentlyEquippedLocation = equipMask,
ContainerId = PlayerGuid,
});
// Update the equip location on the object via MoveItem (sets ContainerId +
// CurrentlyEquippedLocation via the equip overload).
t.MoveItem(guid, PlayerGuid, newSlot: -1, newEquipLocation: equipMask);
}
}

View file

@ -1,30 +0,0 @@
namespace AcDream.App.Studio;
/// <summary>
/// Defers a close requested by render work until the caller has completed the
/// active render-frame transaction. Silk dispatches <c>Closing</c>
/// synchronously, so closing directly from a render callback would otherwise
/// dispose GPU resources while submitted work is still owned by that frame.
/// </summary>
internal sealed class StudioFrameCloseGate
{
private bool _requested;
public bool IsRequested => _requested;
public void Request()
{
_requested = true;
}
public void CompleteFrame(Action close)
{
ArgumentNullException.ThrowIfNull(close);
if (!_requested)
return;
_requested = false;
close();
}
}

View file

@ -1,295 +0,0 @@
using System.Numerics;
using AcDream.App.UI;
using ImGuiNET;
namespace AcDream.App.Studio;
/// <summary>
/// All canvas mouse events gathered by <see cref="StudioInspector.DrawCanvas"/> in one frame.
/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot).
/// </summary>
public readonly struct CanvasInputEvent
{
/// <summary>Mouse is currently hovering the canvas image. When false all other fields are 0 / false.</summary>
public readonly bool IsHovered;
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
public readonly int MouseX;
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
public readonly int MouseY;
/// <summary>Left-button went down this frame.</summary>
public readonly bool LeftDown;
/// <summary>Left-button came up this frame.</summary>
public readonly bool LeftUp;
/// <summary>Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll.</summary>
public readonly int ScrollDelta;
public CanvasInputEvent(bool hovered, int mx, int my, bool ld, bool lu, int scroll)
{
IsHovered = hovered;
MouseX = mx;
MouseY = my;
LeftDown = ld;
LeftUp = lu;
ScrollDelta = scroll;
}
}
/// <summary>
/// Four-pane ImGui IDE for the acdream UI Studio:
/// <list type="bullet">
/// <item><b>Toolbar</b> — panel picker (slug combo) across the top.</item>
/// <item><b>Canvas</b> — shows the panel FBO texture; in Interact mode mouse events
/// are forwarded to the panel UiHost (buttons/tabs respond); in Inspect mode a
/// left-click hit-tests and selects the element under the cursor.</item>
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
/// sets <see cref="Selected"/>.</item>
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
/// anchors, and z-order.</item>
/// </list>
///
/// <para><b>Coordinate mapping for the canvas:</b>
/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui
/// sub-window. After <c>ImGui.Image</c> we call <c>ImGui.GetItemRectMin()</c> to get the
/// screen-space top-left of the drawn image (accounting for the sub-window's title bar,
/// padding, and any scrolling). Subtracting that from the raw mouse screen position gives
/// panel-local pixels directly — no additional scale factor is needed because the image is
/// drawn 1:1.</para>
///
/// <para><b>V-flip — no extra Y inversion needed:</b>
/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to
/// <c>ImGui.Image</c> to flip V. After this flip, displayed row 0 (top of the image on
/// screen) corresponds to panel Y=0 (the top of the UI panel), matching UiRoot's
/// top-left origin. Therefore the panel-local Y computed above maps directly into UiRoot
/// without further inversion — do NOT flip Y again.</para>
///
/// <para>Layout: the four panes call <c>SetNextWindowPos</c> + <c>SetNextWindowSize</c>
/// with <c>ImGuiCond.FirstUseEver</c> so they start docked but can be freely dragged.</para>
/// </summary>
public sealed class StudioInspector
{
/// <summary>Currently selected element (set by tree-click or canvas-click in Inspect mode).</summary>
public UiElement? Selected { get; set; }
/// <summary>
/// When true (default) canvas mouse events are forwarded to the panel UiHost so elements
/// respond to clicks. When false a canvas click hit-tests and selects an element in the
/// inspector tree instead. Toggle via the "Interact / Inspect" checkbox in the toolbar.
/// </summary>
public bool InteractMode { get; set; } = true;
// ── Toolbar ───────────────────────────────────────────────────────────────────
/// <summary>
/// Draw the "Studio" toolbar window (top strip) containing a slug combo-box and
/// the Interact / Inspect mode toggle. Returns the newly-selected slug when the
/// user picks a different panel, or null when unchanged.
/// <para>The mode toggle sets <see cref="InteractMode"/>: checked = Interact (panel
/// elements respond to clicks), unchecked = Inspect (clicks select elements in the
/// tree).</para>
/// </summary>
/// <param name="slugs">All available panel slugs (from <c>UiDumpModel.ListSlugs</c>).</param>
/// <param name="current">The slug of the panel currently loaded.</param>
/// <param name="windowW">Studio window width (pixels).</param>
public string? DrawToolbar(IReadOnlyList<string> slugs, string? current, int windowW)
{
ImGui.SetNextWindowPos(new Vector2(0f, 0f), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, 40f), ImGuiCond.FirstUseEver);
ImGui.Begin("Studio",
ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse);
ImGui.SetNextItemWidth(300f);
string? result = null;
string preview = current ?? "(none)";
if (ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in slugs)
{
bool selected = string.Equals(slug, current, StringComparison.OrdinalIgnoreCase);
if (ImGui.Selectable(slug, selected) && !selected)
result = slug;
if (selected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
ImGui.SameLine();
bool interact = InteractMode;
if (ImGui.Checkbox("Interact", ref interact))
InteractMode = interact;
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Interact: canvas clicks reach the panel (buttons/tabs respond).\nUncheck to Inspect: clicks select elements in the tree.");
ImGui.End();
return result;
}
// ── Canvas ────────────────────────────────────────────────────────────────────
/// <summary>
/// Draw the "Canvas" ImGui window containing the panel FBO texture and return all
/// canvas mouse events for this frame as a <see cref="CanvasInputEvent"/>.
///
/// <para><b>Coordinate mapping:</b> After <c>ImGui.Image</c>, <c>GetItemRectMin()</c>
/// returns the actual screen-space top-left of the drawn image (accounting for the
/// sub-window title bar, padding, and scrolling). Subtracting that from the raw ImGui
/// mouse position gives panel-local pixels directly — no scale factor because the
/// image is drawn 1:1.</para>
///
/// <para><b>V-flip — no extra Y inversion:</b> we pass uv0=(0,1) / uv1=(1,0) so the
/// GL bottom-left origin is flipped to top-left on screen. After the flip, screen
/// row 0 = panel Y 0 (top of the UI), so the computed Y already matches UiRoot's
/// top-left origin — do NOT flip Y again.</para>
///
/// <para>If <see cref="Selected"/> is non-null a bright-green 2-pixel outline is
/// drawn over it using the window draw list.</para>
/// </summary>
public CanvasInputEvent DrawCanvas(nint panelTex, int width, int height,
int windowX, int windowW, int windowY, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Canvas");
var imageSize = new Vector2(width, height);
// V-flip: FBO origin is bottom-left; ImGui images expect top-left.
// uv0 = bottom-left of texture = top of the panel in screen space.
// uv1 = top-right of texture = bottom of the panel in screen space.
var uv0 = new Vector2(0f, 1f);
var uv1 = new Vector2(1f, 0f);
ImGui.Image(panelTex, imageSize, uv0, uv1);
// rectMin: screen-space top-left of the image AFTER ImGui.Image + any chrome offset.
// This is what lets us translate raw mouse screen coords into panel-local pixels.
var rectMin = ImGui.GetItemRectMin();
// ── Selection highlight ───────────────────────────────────────────────
var el = Selected;
if (el is not null && el.Width > 0f && el.Height > 0f)
{
var sp = el.ScreenPosition;
var p0 = new Vector2(rectMin.X + sp.X, rectMin.Y + sp.Y);
var p1 = new Vector2(p0.X + el.Width, p0.Y + el.Height);
var dl = ImGui.GetWindowDrawList();
dl.AddRect(p0, p1,
ImGui.GetColorU32(new Vector4(0.2f, 1f, 0.4f, 1f)),
0f, ImDrawFlags.None, 2f);
}
// ── Gather canvas mouse events ────────────────────────────────────────
// IsItemHovered is true when the mouse is over the Image item (not just the window).
bool hovered = ImGui.IsItemHovered();
int mx = 0, my = 0;
bool leftDown = false, leftUp = false;
int scroll = 0;
if (hovered)
{
var mousePos = ImGui.GetMousePos();
// Panel-local pixel = mouse offset from the image's screen-space top-left.
// Scale is 1:1 (image drawn at full FBO size). Y needs no extra flip — see summary.
int ix = (int)(mousePos.X - rectMin.X);
int iy = (int)(mousePos.Y - rectMin.Y);
// Clamp to image bounds (mouse can be on the image edge pixel).
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
{
mx = ix;
my = iy;
leftDown = ImGui.IsMouseClicked(ImGuiMouseButton.Left);
leftUp = ImGui.IsMouseReleased(ImGuiMouseButton.Left);
float wheelY = ImGui.GetIO().MouseWheel;
scroll = (int)wheelY; // positive = scroll up
}
else
{
// Mouse is over ImGui chrome (title bar, padding) adjacent to image — not over the panel.
hovered = false;
}
}
ImGui.End();
return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll);
}
// ── Tree ──────────────────────────────────────────────────────────────────────
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
public void DrawTree(UiElement root, int windowX, int windowY, int windowW, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Tree");
DrawTreeNode(root);
ImGui.End();
}
private void DrawTreeNode(UiElement el)
{
// Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
bool isSelected = ReferenceEquals(el, Selected);
bool hasChildren = el.Children.Count > 0;
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
| ImGuiTreeNodeFlags.SpanAvailWidth;
if (!hasChildren)
flags |= ImGuiTreeNodeFlags.Leaf;
if (isSelected)
flags |= ImGuiTreeNodeFlags.Selected;
bool open = ImGui.TreeNodeEx(label, flags);
// Click on the node label (not the arrow) selects it.
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
Selected = el;
if (open)
{
foreach (var child in el.Children)
DrawTreeNode(child);
ImGui.TreePop();
}
}
// ── Properties ───────────────────────────────────────────────────────────────
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
public void DrawProperties(int windowX, int windowY, int windowW, int windowH)
{
ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Properties");
var el = Selected;
if (el is null)
{
ImGui.TextUnformatted("(nothing selected)");
ImGui.End();
return;
}
ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
ImGui.TextUnformatted($"Type: {el.GetType().Name}");
ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
ImGui.Separator();
ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
ImGui.TextUnformatted($"Anchors: {el.Anchors}");
ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
ImGui.Separator();
ImGui.TextUnformatted($"Visible: {el.Visible}");
ImGui.TextUnformatted($"Enabled: {el.Enabled}");
ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
ImGui.TextUnformatted($"Draggable: {el.Draggable}");
ImGui.TextUnformatted($"Resizable: {el.Resizable}");
ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
ImGui.Separator();
var sp = el.ScreenPosition;
ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
ImGui.TextUnformatted($"Children: {el.Children.Count}");
ImGui.End();
}
}

View file

@ -1,143 +0,0 @@
namespace AcDream.App.Studio;
/// <summary>
/// Parsed options for the acdream UI Studio standalone tool.
/// Constructed by <see cref="Parse"/> from the command-line tokens that follow
/// the <c>ui-studio</c> dispatch token.
/// </summary>
public sealed record StudioOptions(
string DatDir,
uint? LayoutId,
string? MarkupPath,
string? DumpSlug = null,
string? DumpFile = null,
string? ScreenshotPath = null,
bool Mockup = false,
string? CapabilityReportPath = null,
bool AudioSmoke = false)
{
/// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
///
/// <para>Positional (first non-flag arg): dat directory. Falls back to
/// <c>ACDREAM_DAT_DIR</c> when omitted.</para>
/// <para><c>--layout 0xNNNN</c>: hex LayoutDesc dat id to preview.</para>
/// <para><c>--markup &lt;path&gt;</c>: path to a KSML markup file (Task 6, unsupported for now).</para>
/// <para><c>--dump &lt;slug&gt;</c>: load a panel from the retail UI dump JSON by slug
/// (e.g. <c>inventory</c>, <c>radar</c>, <c>toolbar</c>). Static mockup — no controllers.</para>
/// <para><c>--dump-file &lt;path&gt;</c>: override the default dump file path
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root).
/// Only meaningful when <c>--dump</c> is also given.</para>
/// <para><c>--screenshot &lt;path&gt;</c>: headless mode — render the loaded panel to a PNG
/// at <paramref name="path"/> and exit without showing an interactive window.
/// Combines with <c>--dump</c> or <c>--layout</c>.</para>
/// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the
/// default layout <c>0x2100006C</c> (vitals) is used.</para>
/// </summary>
public static StudioOptions Parse(string[] args)
{
string? datDir = null;
uint? layoutId = null;
string? markupPath = null;
string? dumpSlug = null;
string? dumpFile = null;
string? screenshotPath = null;
string? capabilityReportPath = null;
bool mockup = false;
bool audioSmoke = false;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--layout" && i + 1 < args.Length)
{
var raw = args[++i];
// Accept 0xNNNN or plain hex.
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
raw = raw[2..];
if (uint.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out var id))
layoutId = id;
}
else if (args[i] == "--markup" && i + 1 < args.Length)
{
markupPath = args[++i];
}
else if (args[i] == "--dump" && i + 1 < args.Length)
{
dumpSlug = args[++i];
}
else if (args[i] == "--dump-file" && i + 1 < args.Length)
{
dumpFile = args[++i];
}
else if (args[i] == "--screenshot" && i + 1 < args.Length)
{
screenshotPath = args[++i];
}
else if (args[i] == "--capability-report" && i + 1 < args.Length)
{
capabilityReportPath = args[++i];
}
else if (args[i] == "--audio-smoke")
{
audioSmoke = true;
}
else if (args[i] == "--mockup")
{
mockup = true;
}
else if (!args[i].StartsWith('-'))
{
datDir ??= args[i];
}
}
// Fall back to ACDREAM_DAT_DIR when no positional dat dir was given.
datDir ??= Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir))
throw new InvalidOperationException(
"ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR.");
// Default layout: vitals (0x2100006C), unless a dump slug or markup is requested.
if (!mockup && layoutId is null && markupPath is null && dumpSlug is null)
layoutId = 0x2100006Cu;
return new StudioOptions(
datDir,
layoutId,
markupPath,
dumpSlug,
dumpFile,
screenshotPath,
mockup,
capabilityReportPath,
audioSmoke);
}
/// <summary>
/// Resolve the dump file path for this session:
/// <list type="bullet">
/// <item><see cref="DumpFile"/> if explicitly set.</item>
/// <item>Otherwise <c>&lt;solutionRoot&gt;/docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</item>
/// </list>
/// Returns null when neither the override nor the default file exists.
/// </summary>
public string? ResolveDumpFile()
{
if (!string.IsNullOrEmpty(DumpFile))
return DumpFile;
// Walk up from the App binary to the solution root (same approach as
// ConformanceDats.SolutionRoot in the test project).
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
var candidate = Path.Combine(dir, "docs", "research",
"2026-06-25-retail-ui-layout-dump.json");
if (File.Exists(candidate))
return candidate;
dir = Path.GetDirectoryName(dir);
}
return null;
}
}

View file

@ -1,675 +0,0 @@
using System.Numerics;
using AcDream.Content;
using AcDream.App.Platform;
using AcDream.App.Audio;
using AcDream.Core.Audio;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Runtime.Platform;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio;
/// <summary>
/// Standalone Silk.NET window that boots the production render stack
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
/// identified by a <see cref="LayoutSource"/>.
///
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] [--mockup]
///
/// Task 3 adds an ImGui IDE on top of the panel FBO:
/// <list type="bullet">
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
/// </list>
///
/// The window is intentionally thin: no game world, no physics, no streaming —
/// just GL + UiHost + the layout under test, identical to how the panel
/// appears inside <c>GameWindow</c>.
/// </summary>
public sealed class StudioWindow : IDisposable
{
private readonly StudioOptions _opts;
private readonly ApplicationPathSet _applicationPaths;
private readonly GraphicalHostPlatformServices _platformServices;
// Created in OnLoad, released in OnClosing.
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
private OpenAlAudioEngine? _audio;
private IDatReaderWriter? _dats;
private RenderStack? _stack;
private LayoutSource? _source;
// Task 3 additions.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
private PanelFbo? _panelFbo;
private StudioInspector? _inspector;
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
// UX-pass additions: panel picker + current-slug tracking.
private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
private string? _dumpFile; // resolved dump file path (once, in OnLoad)
private IReadOnlyList<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
private IRetainedPanelController? _fixtureController;
// Headless screenshot mode: set when --screenshot was passed.
// True after the first OnRender fires the screenshot (guard against repeat).
private bool _screenshotDone;
private readonly StudioFrameCloseGate _frameCloseGate = new();
private GraphicalCapabilityRecord? _capabilities;
private string? _capabilityReportPath;
public StudioWindow(StudioOptions opts)
: this(
opts,
GraphicalHostPlatformServices.Resolve())
{
}
internal StudioWindow(
StudioOptions opts,
ApplicationPathSet applicationPaths)
: this(
opts,
GraphicalHostPlatformServices.Resolve() with
{
Paths = applicationPaths
?? throw new ArgumentNullException(
nameof(applicationPaths)),
})
{
}
internal StudioWindow(
StudioOptions opts,
GraphicalHostPlatformServices platformServices)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
_applicationPaths = _platformServices.Paths;
}
/// <summary>
/// Open the window and block until it is closed.
/// Mirrors <c>GameWindow.Run()</c>.
/// </summary>
public void Run()
{
_platformServices.ConfigureWindowBackend();
// Resolve quality settings the same way GameWindow.Run() does
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
_applicationPaths.SettingsFile);
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream UI Studio",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = false,
// MSAA from quality preset — must be baked into the GL context at creation.
Samples = startupQuality.MsaaSamples,
PreferredStencilBufferBits = 8,
// Headless screenshot mode: hide the window so no desktop flash occurs.
// The GL context is still fully valid on a hidden window; FBO rendering
// is off-screen and independent of window visibility.
IsVisible = _opts.ScreenshotPath is null,
};
_window = Window.Create(options);
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.Closing += OnClosing;
_window.Run();
}
private void OnLoad()
{
_gl = GL.GetApi(_window!);
_input = _window!.CreateInput();
_capabilityReportPath = Path.GetFullPath(
_opts.CapabilityReportPath
?? Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json"));
_capabilities = GraphicalCapabilityGuard.CaptureVerifyAndWrite(
_gl,
_window!,
_input,
_platformServices,
_capabilityReportPath);
GraphicalCapabilityGuard.ThrowIfUnsupported(
_capabilities,
_capabilityReportPath);
if (_opts.AudioSmoke)
{
_audio = new OpenAlAudioEngine();
bool playback = _audio.IsAvailable
&& _audio.PlayUiWave(
0xFFFF_FF01u,
CreateAudioSmokeWave(),
volume: 0.05f);
_capabilities = _capabilities with
{
Audio = new GraphicalAudioCapabilities(
Requested: true,
Available: _audio.IsAvailable,
PlaybackSubmitted: playback,
DisposalComplete: _audio.IsDisposalComplete,
Backend: "OpenAL Soft default device"),
Lifecycle = _capabilities.Lifecycle with
{
OwnedAudioEngineCount = 1,
},
};
GraphicalCapabilityReportWriter.Write(
_capabilityReportPath,
_capabilities);
}
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
_applicationPaths.SettingsFile);
var display = store.LoadDisplay();
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
_stack = RenderBootstrap.Create(
_gl,
_dats,
new RenderBootstrapOptions(
quality,
_applicationPaths.DiagnosticsDirectory));
if (_opts.Mockup)
MockupDesktop.Load(_dats, _stack);
// Load the panel described by options and add it to the UI tree.
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
//
// Fix C: pass the per-element font resolver into LayoutSource so that elements
// with a non-zero FontDid get their own dat font at build time. This is wired
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
fontResolve: _stack.ResolveDatFont);
// Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
_dumpFile = _opts.ResolveDumpFile();
if (_dumpFile is not null)
_dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
UiElement? root = null;
if (!_opts.Mockup && _opts.DumpSlug is not null)
{
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
"or pass --dump-file <path>.");
root = null;
}
else
{
root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
if (root is null)
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
else
_currentSlug = _opts.DumpSlug;
}
}
else if (!_opts.Mockup)
{
root = _source.Load(_opts);
if (root is null)
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
else
NormalizeSinglePanelRoot(root);
}
_panelRoot = root;
if (root is not null)
{
_stack.UiHost.Root.AddChild(root);
// Task 4: populate the panel with sample data via production controllers,
// so inventory / vitals / toolbar panels render with plausible content.
// Dump source is static — no FixtureProvider needed.
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
{
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
_objects = SampleData.BuildObjectTable();
_fixtureController = FixtureProvider.Populate(
layoutId, _source.CurrentLayout, _stack, _objects, _dats);
}
}
// Task 3: ImGui IDE — interactive mode only.
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(_gl);
if (_opts.ScreenshotPath is null)
{
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
if (_opts.Mockup)
foreach (var mouse in _input.Mice)
_stack.UiHost.WireMouse(mouse);
foreach (var kb in _input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
if (_opts.Mockup)
return;
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(
_gl,
_window!,
_input);
_inspector = new StudioInspector();
}
}
private static WaveData CreateAudioSmokeWave()
{
const int sampleRate = 8_000;
const int sampleCount = 400;
var pcm = new byte[sampleCount * sizeof(short)];
for (int i = 0; i < sampleCount; i++)
{
double phase = i * (2.0 * Math.PI * 440.0 / sampleRate);
short sample = (short)(Math.Sin(phase) * short.MaxValue * 0.08);
BitConverter.TryWriteBytes(
pcm.AsSpan(i * sizeof(short), sizeof(short)),
sample);
}
return new WaveData
{
ChannelCount = 1,
SampleRate = sampleRate,
BitsPerSample = 16,
PcmBytes = pcm,
Duration = TimeSpan.FromSeconds(
sampleCount / (double)sampleRate),
};
}
private void OnUpdate(double dt) { }
private void OnRender(double dt)
{
if (_stack is null || _panelFbo is null) return;
RenderStack frameStack = _stack;
frameStack.BeginFrame();
Exception? renderFailure = null;
bool frameClosed = false;
try
{
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
if (_opts.ScreenshotPath is not null)
{
if (_screenshotDone) return; // fire exactly once
_screenshotDone = true;
// Pick render size from the loaded root's bounds (clamped to sane limits).
// Fall back to 1280×720 when the root has no explicit size.
int w = 1280, h = 720;
if (!_opts.Mockup && _panelRoot is not null)
{
float rw = _panelRoot.Width;
float rh = _panelRoot.Height;
if (rw >= 1f && rh >= 1f)
{
w = Math.Clamp((int)rw, 256, 2048);
h = Math.Clamp((int)rh, 256, 2048);
}
}
// Tick once so widget state is initialised (e.g. bar fills).
_stack.UiHost.Tick(dt);
// Render the panel into the FBO.
_panelFbo.Render(w, h, _stack.UiHost);
// Read back RGBA pixels (FBO origin = bottom-left).
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
if (pixels.Length == 0)
{
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
_frameCloseGate.Request();
return;
}
// Flip rows vertically: FBO bottom-left → PNG top-left.
int stride = w * 4;
byte[] flipped = new byte[pixels.Length];
for (int row = 0; row < h; row++)
{
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
}
// Build ImageSharp image and save as PNG.
var path = _opts.ScreenshotPath;
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
img.SaveAsPng(path);
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
_frameCloseGate.Request();
return;
}
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
if (_opts.Mockup)
{
var mockupGl = _stack.Gl;
int mockupW = _window!.Size.X;
int mockupH = _window!.Size.Y;
_stack.UiHost.Tick(dt);
mockupGl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
mockupGl.Viewport(0, 0, (uint)mockupW, (uint)mockupH);
mockupGl.ClearColor(0.08f, 0.08f, 0.08f, 1f);
mockupGl.Clear(ColorBufferBit | DepthBufferBit);
_stack.UiHost.Draw(new Vector2(mockupW, mockupH));
return;
}
if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl;
int iw = _window!.Size.X;
int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_stack.UiHost.Tick(dt);
// 2. Render the panel into the off-screen FBO; get the color texture.
// The FBO is the same logical size as the window, so element rects map 1:1 to
// FBO pixels — no scale factor needed when displaying the canvas at full size.
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
gl.Clear(ColorBufferBit | DepthBufferBit);
// 4. Begin the ImGui frame.
_imgui.BeginFrame((float)dt);
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
// is never covered by the floating panes (replaces the old 40px toolbar).
// Tree: 280px wide on the left, below menu bar.
// Canvas: centre strip between tree and properties.
// Props: 340px wide on the right, below menu bar.
const int kMenuBarH = 22; // ImGui default main menu bar height
const int kTreeW = 280;
const int kPropsW = 340;
int canvasX = kTreeW;
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
int propsX = iw - kPropsW;
int paneY = kMenuBarH;
int paneH = Math.Max(1, ih - kMenuBarH);
// 5. Main menu bar — panel picker combo pinned to the window top.
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
string? pickedSlug = null;
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
ImGuiNET.ImGui.SetNextItemWidth(300f);
string preview = _currentSlug ?? "(none)";
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in _dumpSlugs)
{
bool selected = string.Equals(slug, _currentSlug,
System.StringComparison.OrdinalIgnoreCase);
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
pickedSlug = slug;
if (selected)
ImGuiNET.ImGui.SetItemDefaultFocus();
}
ImGuiNET.ImGui.EndCombo();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
var canvasEvt = default(CanvasInputEvent);
if (panelTex != 0)
canvasEvt = _inspector.DrawCanvas(
(nint)panelTex, iw, ih,
canvasX, canvasW, paneY, paneH);
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
//
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
//
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
// A Console.WriteLine confirms each forwarded left-click for live verification.
//
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
if (canvasEvt.IsHovered)
{
int mx = canvasEvt.MouseX;
int my = canvasEvt.MouseY;
var root = _stack.UiHost.Root;
// Always forward mouse-move so hover highlights / tooltips in the panel work.
root.OnMouseMove(mx, my);
if (_inspector.InteractMode)
{
// ── Interact: live panel interaction ──────────────────────────────
if (canvasEvt.LeftDown)
{
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
root.OnMouseDown(UiMouseButton.Left, mx, my);
}
if (canvasEvt.LeftUp)
root.OnMouseUp(UiMouseButton.Left, mx, my);
if (canvasEvt.ScrollDelta != 0)
root.OnScroll(canvasEvt.ScrollDelta);
}
else
{
// ── Inspect: click selects an element in the tree ─────────────────
if (canvasEvt.LeftDown)
{
var hit = root.Pick(mx, my);
if (hit is not null)
_inspector.Selected = hit;
}
}
}
// 8. Element tree pane.
if (_panelRoot is not null)
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
// 9. Properties pane.
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
// 9. Finalise ImGui and flush draw data to the window.
_imgui.Render();
}
catch (Exception ex)
{
renderFailure = ex;
throw;
}
finally
{
try
{
frameStack.EndFrame();
frameClosed = true;
}
catch (Exception closeFailure) when (renderFailure is not null)
{
throw new AggregateException(
"UI Studio rendering failed and its GPU frame could not be closed.",
renderFailure,
closeFailure);
}
if (frameClosed)
_frameCloseGate.CompleteFrame(() => _window?.Close());
}
}
/// <summary>
/// Load a different dump panel at runtime (no relaunch required).
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
/// loads the named slug from the dump, and installs the new root.
/// Resets <see cref="StudioInspector.Selected"/> to null.
/// No-op when the dump file is not available or the slug fails to load.
/// </summary>
public void LoadDumpPanel(string slug)
{
if (_stack is null || _inspector is null) return;
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
return;
}
// Remove the existing panel root from the tree. Fixture controllers
// own subscriptions into the sample table and end with their panel.
_fixtureController?.Dispose();
_fixtureController = null;
if (_panelRoot is not null)
{
_stack.UiHost.Root.RemoveChild(_panelRoot);
_panelRoot = null;
}
// Load the new panel.
var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
if (newRoot is null)
{
Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
_currentSlug = null;
return;
}
_stack.UiHost.Root.AddChild(newRoot);
_panelRoot = newRoot;
_currentSlug = slug;
_inspector.Selected = null;
}
/// <summary>
/// Studio dat-layout previews show one panel in isolation, not at its live-game
/// screen position. Some top-level layouts (inventory: x=500,y=138) otherwise
/// draw entirely outside the root-sized screenshot FBO.
/// </summary>
internal static void NormalizeSinglePanelRoot(UiElement root)
{
root.Left = 0f;
root.Top = 0f;
root.Anchors = AnchorEdges.None;
}
private void OnClosing()
{
ReleaseContextResources();
}
public void Dispose()
{
ReleaseContextResources();
_window?.Dispose();
_window = null;
UpdateFinalCapabilityReport();
}
private void ReleaseContextResources()
{
_fixtureController?.Dispose();
_fixtureController = null;
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
_stack?.Dispose();
_dats?.Dispose();
_audio?.Dispose();
_input?.Dispose();
_gl?.Dispose();
_dats = null;
_stack = null;
_audio = null;
_input = null;
_gl = null;
}
private void UpdateFinalCapabilityReport()
{
if (_capabilities is null
|| string.IsNullOrWhiteSpace(_capabilityReportPath))
{
return;
}
_capabilities = _capabilities with
{
Audio = _capabilities.Audio with
{
DisposalComplete = true,
},
Lifecycle = new GraphicalSmokeLifecycleCapabilities(
OwnedWindowCount: 0,
OwnedGlApiCount: 0,
OwnedInputContextCount: 0,
OwnedAudioEngineCount: 0,
ShutdownComplete: true),
};
GraphicalCapabilityReportWriter.Write(
_capabilityReportPath,
_capabilities);
}
}

View file

@ -1,198 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Studio;
// ─────────────────────────────────────────────────────────────────────────────
// UiDumpModel — POCOs for docs/research/2026-06-25-retail-ui-layout-dump.json
//
// Schema (v1):
// { "version":1, "panels":[ { "id":int, "slug":string, "title":string,
// "bucket":string, "parent_slug":string|null,
// "width":int, "height":int,
// "nodes":[ { "traversal_index":int, "element_id":int,
// "layout_id":int, "parent_layout_id":int|null,
// "parent_traversal_index":int|null, "base_layout_id":int,
// "rect":{x,y,width,height},
// "widget_kind":"Group"|"Sprite"|"Button"|"Scrollbar"|"Slider",
// "state_set":{ "default_image":{image_id,alpha_image_id}|null,
// "states":[{state_id,image:{...}}] }
// } ] } ] }
//
// All ids in the dump are DECIMAL ints (e.g. element_id=268435925 = 0x100001D5,
// image_id=100693194 = 0x060074CA). Cast to uint before use in dat/GL APIs.
//
// Rect coordinates are ABSOLUTE (screen-space origin = panel's design position
// in retail layout, NOT relative to the parent). DumpLayout.Load converts them
// to parent-relative when building the UiElement tree.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>Top-level container for the retail UI layout dump.</summary>
public sealed class UiDump
{
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("panels")]
public List<DumpPanel> Panels { get; set; } = new();
}
/// <summary>One panel (window) exported from the retail UI.</summary>
public sealed class DumpPanel
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("slug")]
public string Slug { get; set; } = "";
[JsonPropertyName("title")]
public string Title { get; set; } = "";
[JsonPropertyName("bucket")]
public string Bucket { get; set; } = "";
[JsonPropertyName("parent_slug")]
public string? ParentSlug { get; set; }
[JsonPropertyName("width")]
public float Width { get; set; }
[JsonPropertyName("height")]
public float Height { get; set; }
[JsonPropertyName("nodes")]
public List<DumpNode> Nodes { get; set; } = new();
}
/// <summary>One element node within a panel's traversal list.</summary>
public sealed class DumpNode
{
[JsonPropertyName("traversal_index")]
public int TraversalIndex { get; set; }
[JsonPropertyName("element_id")]
public long ElementId { get; set; }
[JsonPropertyName("layout_id")]
public long LayoutId { get; set; }
[JsonPropertyName("parent_layout_id")]
public long? ParentLayoutId { get; set; }
[JsonPropertyName("parent_traversal_index")]
public int? ParentTraversalIndex { get; set; }
[JsonPropertyName("base_layout_id")]
public long BaseLayoutId { get; set; }
[JsonPropertyName("rect")]
public DumpRect Rect { get; set; } = new();
[JsonPropertyName("widget_kind")]
public string WidgetKind { get; set; } = "Group";
[JsonPropertyName("state_set")]
public DumpStateSet StateSet { get; set; } = new();
}
/// <summary>Absolute screen-space rect (see comment above — must subtract parent rect for UiElement).</summary>
public sealed class DumpRect
{
[JsonPropertyName("x")]
public float X { get; set; }
[JsonPropertyName("y")]
public float Y { get; set; }
[JsonPropertyName("width")]
public float Width { get; set; }
[JsonPropertyName("height")]
public float Height { get; set; }
}
/// <summary>State set for a node — default image plus per-state overrides.</summary>
public sealed class DumpStateSet
{
[JsonPropertyName("default_image")]
public DumpImage? DefaultImage { get; set; }
[JsonPropertyName("states")]
public List<DumpState> States { get; set; } = new();
}
/// <summary>Image reference (RenderSurface dat id + optional separate alpha surface).</summary>
public sealed class DumpImage
{
[JsonPropertyName("image_id")]
public long ImageId { get; set; }
[JsonPropertyName("alpha_image_id")]
public long? AlphaImageId { get; set; }
}
/// <summary>A named state override.</summary>
public sealed class DumpState
{
[JsonPropertyName("state_id")]
public int StateId { get; set; }
[JsonPropertyName("image")]
public DumpImage Image { get; set; } = new();
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper statics
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Parsing helpers for the retail UI dump JSON.
/// </summary>
public static class UiDumpModel
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
/// <summary>Parse the full dump from a file path. Returns null on failure.</summary>
public static UiDump? Parse(string path)
{
try
{
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<UiDump>(stream, _opts);
}
catch
{
return null;
}
}
/// <summary>
/// Return the list of slugs in the dump (for smoke-testing every panel).
/// Returns an empty list if the file cannot be parsed.
/// </summary>
public static IReadOnlyList<string> ListSlugs(string path)
{
var dump = Parse(path);
if (dump is null) return Array.Empty<string>();
return dump.Panels.Select(p => p.Slug).ToList();
}
/// <summary>
/// Pick the sprite id to use for a node: prefer default_image.image_id;
/// fall back to states[0].image.image_id; return 0 if neither exists.
/// </summary>
public static uint PickImageId(DumpNode node)
{
if (node.StateSet.DefaultImage is { ImageId: > 0 } di)
return (uint)di.ImageId;
if (node.StateSet.States.Count > 0 && node.StateSet.States[0].Image.ImageId > 0)
return (uint)node.StateSet.States[0].Image.ImageId;
return 0u;
}
}

View file

@ -0,0 +1,94 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Static sample character-sheet data. It is the character-sheet fallback
/// used when no live session has produced a real <see cref="CharacterSheet"/>
/// yet, and the fixture the character/attribute UI panel tests bind against.
/// </summary>
public static class SampleData
{
/// <summary>
/// Returns a representative <see cref="CharacterSheet"/>. Values are
/// plausible retail-scale numbers so the report renders with
/// well-proportioned text in all sections.
/// </summary>
public static CharacterSheet SampleCharacter() => SampleCharacter(null);
public static CharacterSheet SampleCharacter(string? name) => new()
{
Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name,
Level = 126,
Gender = "Female",
Heritage = "Aluvian",
Title = "the Adventurer",
BirthDate = "January 5, 2001",
PlayTime = "2 years, 114 days, 4 hours",
Deaths = 42,
PkStatus = "Non-Player Killer",
TotalXp = 1_250_000_000,
XpToNextLevel = 42_000_000,
XpFraction = 0.63f,
// Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
HealthCurrent = 5, HealthMax = 5,
StaminaCurrent = 10, StaminaMax = 10,
ManaCurrent = 10, ManaMax = 10,
// Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
Strength = 200,
Endurance = 10,
Quickness = 200,
Coordination = 10,
Focus = 10,
Self = 10,
UnspentSkillCredits = 12,
SpecializedSkillCredits = 4,
ChessRank = 12,
FishingSkill = 4,
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
SkillCredits = 96,
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
UnassignedXp = 87_757_321_741L,
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
// Focus, Self, Health, Stamina, Mana).
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
// Formula bracket at value=10: ExperienceToAttributeLevel(11) ExperienceToAttributeLevel(10).
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L },
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.
Skills = new CharacterSkill[]
{
new( 6, "Melee Defense", 0x06000165u, CharacterSkillAdvancementClass.Specialized, 350, 354, false, 10, 20, 18_250_000L, 182_500_000L),
new(34, "War Magic", 0x06001365u, CharacterSkillAdvancementClass.Specialized, 280, 285, false, 16, 28, 11_100_000L, 111_000_000L),
new(14, "Arcane Lore", 0x0600016Eu, CharacterSkillAdvancementClass.Trained, 260, 269, false, 4, 6, 7_500_000L, 75_000_000L),
new(33, "Life Magic", 0x06001364u, CharacterSkillAdvancementClass.Trained, 250, 252, false, 12, 20, 6_800_000L, 68_000_000L),
new(47, "Missile Weapons", 0x0600015Fu, CharacterSkillAdvancementClass.Trained, 220, 221, false, 6, 12, 5_250_000L, 52_500_000L),
new(21, "Healing", 0x06000133u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 6, 10, 0L),
new(22, "Jump", 0x0600016Bu, CharacterSkillAdvancementClass.Untrained, 210, 210, true, 0, 4, 0L),
new(36, "Loyalty", 0x06001367u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 0, 2, 0L),
new(24, "Run", 0x06000173u, CharacterSkillAdvancementClass.Untrained, 390, 390, true, 0, 4, 0L),
new(38, "Alchemy", 0x060019E4u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 6, 12, 0L),
new(39, "Cooking", 0x06001A54u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
},
CharacterInfoProperties = new Dictionary<uint, int>
{
[0x162u] = 2, // Swords melee mastery
},
BurdenCurrent = 1200,
BurdenMax = 4500,
};
}

View file

@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- 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" />
</ItemGroup>
</Project>

View file

@ -1,112 +0,0 @@
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ImGui;
using Silk.NET.Windowing;
namespace AcDream.UI.ImGui;
/// <summary>
/// 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>
/// 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 interface IImGuiBootstrapper : IDisposable
{
void BeginFrame(float deltaSeconds);
void Render();
void AbortFrame();
}
/// <summary>
/// The upstream Silk controller does not expose a partially constructed
/// instance. A constructor failure therefore requires teardown of the owning
/// GL/window host instead of being treated as a recoverable optional-frontend
/// failure.
/// </summary>
public sealed class ImGuiBootstrapperConstructionException : Exception
{
internal ImGuiBootstrapperConstructionException(Exception innerException)
: base(
"Silk ImGui construction did not publish a cleanup owner; " +
"the graphics host must be torn down.",
innerException)
{
}
}
public sealed class ImGuiBootstrapper : IImGuiBootstrapper
{
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
nint priorContext = ImGuiNET.ImGui.GetCurrentContext();
try
{
_controller = new ImGuiController(gl, window, input);
}
catch (Exception failure)
{
nint partialContext = ImGuiNET.ImGui.GetCurrentContext();
if (partialContext != 0 && partialContext != priorContext)
{
ImGuiNET.ImGui.DestroyContext(partialContext);
ImGuiNET.ImGui.SetCurrentContext(priorContext);
}
throw new ImGuiBootstrapperConstructionException(failure);
}
}
/// <summary>
/// Begin an ImGui frame. Call BEFORE any <c>ImGui.*</c> widget calls.
/// Internally: consumes buffered input events, calls <c>ImGui.NewFrame()</c>.
/// </summary>
public void BeginFrame(float deltaSeconds) => _controller.Update(deltaSeconds);
/// <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();
/// <summary>
/// Close an unfinished frame through the owning Silk controller. Silk's
/// controller clears its private frame-begun flag before uploading draw
/// data, so this is both the correct abort path for pre-render failures and
/// an idempotent no-op when a render upload failed after logical close.
/// Calling raw <c>ImGui.EndFrame</c> here would leave the controller's state
/// open and poison its next <see cref="BeginFrame"/> call.
/// </summary>
public void AbortFrame() => _controller.Render();
public void Dispose() => _controller.Dispose();
}

View file

@ -1,46 +0,0 @@
using AcDream.UI.Abstractions;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelHost"/> implementation for the ImGui backend. Owns the
/// registered panel set; iterates + draws every frame when the caller is
/// inside an ImGui frame (between <c>ImGui.NewFrame</c> and
/// <c>ImGui.Render</c>).
///
/// <para>
/// <b>This class does not call <c>ImGui.NewFrame</c> / <c>ImGui.Render</c>
/// itself.</b> Those belong to the caller (GameWindow) so GL-state
/// ownership is explicit and the render-loop integration point is obvious.
/// </para>
/// </summary>
public sealed class ImGuiPanelHost : IPanelHost
{
private readonly Dictionary<string, IPanel> _panels = new();
private readonly ImGuiPanelRenderer _renderer = new();
/// <inheritdoc />
public void Register(IPanel panel)
{
ArgumentNullException.ThrowIfNull(panel);
_panels[panel.Id] = panel; // idempotent by Id
}
/// <inheritdoc />
public void Unregister(string panelId) => _panels.Remove(panelId);
/// <inheritdoc />
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);
}
}
/// <summary>Current registered count (for diagnostics).</summary>
public int Count => _panels.Count;
}

View file

@ -1,256 +0,0 @@
using System.Numerics;
using AcDream.UI.Abstractions;
using ImGuiNET;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelRenderer"/> 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.
/// </summary>
public sealed class ImGuiPanelRenderer : IPanelRenderer
{
/// <inheritdoc />
public bool Begin(string title) => ImGuiNET.ImGui.Begin(title);
/// <inheritdoc />
public void End() => ImGuiNET.ImGui.End();
/// <inheritdoc />
public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text);
/// <inheritdoc />
public void SameLine() => ImGuiNET.ImGui.SameLine();
/// <inheritdoc />
public void Separator() => ImGuiNET.ImGui.Separator();
/// <inheritdoc />
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);
}
// -- Phase I.1 widget extensions ---------------------------------
/// <inheritdoc />
public void TextColored(Vector4 rgba, string text)
=> ImGuiNET.ImGui.TextColored(rgba, text);
/// <inheritdoc />
public bool CollapsingHeader(string label, bool defaultOpen = true)
=> ImGuiNET.ImGui.CollapsingHeader(
label,
defaultOpen ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None);
/// <inheritdoc />
public bool TreeNode(string label) => ImGuiNET.ImGui.TreeNode(label);
/// <inheritdoc />
public void TreePop() => ImGuiNET.ImGui.TreePop();
/// <inheritdoc />
public bool Checkbox(string label, ref bool value)
=> ImGuiNET.ImGui.Checkbox(label, ref value);
/// <inheritdoc />
public bool Button(string label) => ImGuiNET.ImGui.Button(label);
/// <inheritdoc />
public bool Combo(string label, ref int selectedIndex, string[] items)
=> ImGuiNET.ImGui.Combo(label, ref selectedIndex, items, items.Length);
/// <inheritdoc />
public bool SliderFloat(string label, ref float value, float min, float max)
=> ImGuiNET.ImGui.SliderFloat(label, ref value, min, max);
/// <inheritdoc />
public void PlotLines(
string label,
float[] values,
int count,
int offset = 0,
string? overlay = null,
float? min = null,
float? max = null,
Vector2? size = null)
{
// ImGui.NET 1.91.6.1's PlotLines binding takes `ref float values`
// (pointer-to-first-element semantics) plus a separate values_count
// and values_offset. The "no fixed bound" / "default size" sentinels
// are float.MaxValue and Vector2.Zero respectively — we pass those
// when the caller leaves the optional args null.
if (count <= 0 || values.Length == 0)
{
// Nothing to plot — emit the label so layout doesn't shift but
// skip the native call (ref to values[0] would NRE on empty).
ImGuiNET.ImGui.TextUnformatted(label);
return;
}
float scaleMin = min ?? float.MaxValue;
float scaleMax = max ?? float.MaxValue;
Vector2 graphSize = size ?? Vector2.Zero;
ImGuiNET.ImGui.PlotLines(
label,
ref values[0],
count,
offset,
overlay ?? string.Empty,
scaleMin,
scaleMax,
graphSize);
}
/// <inheritdoc />
public void BeginTable(string id, int columns)
=> ImGuiNET.ImGui.BeginTable(id, columns);
/// <inheritdoc />
public void TableNextColumn() => ImGuiNET.ImGui.TableNextColumn();
/// <inheritdoc />
public void EndTable() => ImGuiNET.ImGui.EndTable();
/// <inheritdoc />
public bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted)
{
// EnterReturnsTrue: the call returns true on the frame the user
// pressed Enter. On every other frame ImGui still mutates `buffer`
// as the user types; we just don't surface a submit.
bool entered = ImGuiNET.ImGui.InputText(
label,
ref buffer,
(uint)maxLen,
ImGuiInputTextFlags.EnterReturnsTrue);
if (entered)
{
submitted = buffer;
buffer = string.Empty; // contract: clear for next frame
return true;
}
submitted = null;
return false;
}
/// <inheritdoc />
public void Spacing() => ImGuiNET.ImGui.Spacing();
/// <inheritdoc />
public void Dummy(Vector2 size) => ImGuiNET.ImGui.Dummy(size);
/// <inheritdoc />
public void TextWrapped(string text) => ImGuiNET.ImGui.TextWrapped(text);
// -- Phase J Tier 3 — scrollable child for chat-style layouts ---------
/// <inheritdoc />
public bool BeginChild(string id, Vector2 size, bool border = false)
{
// ImGuiChildFlags has changed names across ImGui.NET versions
// (Border vs Borders); 0x01 is the stable bit value for "draw
// a border". Casting from a numeric literal sidesteps the
// version-skew without requiring a hard reference to either
// enum spelling.
bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
if (open)
{
// Title-bar-only drag fix (chat tail specifically): empty
// clicks inside a scrollable child fall through to the
// parent window for drag-init, which is exactly what the
// user reported in the chat panel ("clicking anywhere
// moves the window"). An InvisibleButton sized to the
// child's content region absorbs those clicks so they
// don't propagate. Real widgets drawn afterwards still
// claim their own clicks (click priority = "last drawn,
// first checked"). Wheel scrolling is window-level, not
// item-level, so the absorber doesn't interfere with
// the chat tail's auto-scroll.
//
// Scoped to BeginChild only (NOT Begin) because Begin's
// body might host tab bars whose hit-testing competes with
// an absorber on equal terms — adding it at Begin level
// broke Settings tab clicks.
var avail = ImGuiNET.ImGui.GetContentRegionAvail();
if (avail.X > 0f && avail.Y > 0f)
{
var savedCursor = ImGuiNET.ImGui.GetCursorPos();
ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail);
ImGuiNET.ImGui.SetCursorPos(savedCursor);
}
}
return open;
}
/// <inheritdoc />
public void EndChild() => ImGuiNET.ImGui.EndChild();
/// <inheritdoc />
public float FrameHeightWithSpacing() => ImGuiNET.ImGui.GetFrameHeightWithSpacing();
/// <inheritdoc />
public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio);
/// <inheritdoc />
public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere();
// -- Phase K.3 — main menu bar -----------------------------------------
/// <inheritdoc />
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
/// <inheritdoc />
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
/// <inheritdoc />
public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label);
/// <inheritdoc />
public void EndMenu() => ImGuiNET.ImGui.EndMenu();
/// <inheritdoc />
public bool MenuItem(string label, string? shortcut = null)
=> shortcut is null
? ImGuiNET.ImGui.MenuItem(label)
: ImGuiNET.ImGui.MenuItem(label, shortcut);
// -- Tab bar -----------------------------------------------------------
/// <inheritdoc />
public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
/// <inheritdoc />
public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
/// <inheritdoc />
public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
/// <inheritdoc />
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
// -- Selectable / copyable text ---------------------------------------
/// <inheritdoc />
public void TextMultilineReadOnly(string id, string content, Vector2 size)
{
// ImGui's InputTextMultiline takes a `ref string` even with the
// ReadOnly flag — we just hand it a local copy. maxLength caps
// what the user could type if ReadOnly were ever cleared; we
// size it to the current content (+1 for ImGui's internal NUL
// terminator in some bindings). Min of 1 keeps the empty case
// from confusing native bindings.
string buffer = content;
uint maxLen = (uint)System.Math.Max(content.Length + 1, 1);
ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size,
ImGuiInputTextFlags.ReadOnly);
}
}