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

@ -11,7 +11,6 @@
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
<Project Path="src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />

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

View file

@ -1,526 +0,0 @@
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Combat;
using AcDream.App.Composition;
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.Runtime.Gameplay;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using AcDream.UI.ImGui;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Tests.Composition;
public sealed class SettingsDevToolsCompositionTests
{
[Fact]
public void DisabledFrontendAppliesSettingsAndAcquiresNothingOptional()
{
using var fixture = new Fixture(enabled: false);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.False(
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
Assert.Equal(1, fixture.Startup.DisplayCalls);
Assert.Equal(1, fixture.Startup.AudioCalls);
Assert.Equal(0, fixture.Factory.Calls);
Assert.Equal(
[SettingsDevToolsCompositionPoint.SettingsApplied],
fixture.Points);
}
[Fact]
public void UnsupportedInputDisablesBeforeAcquiringFrontendResources()
{
using var fixture = new Fixture(inputSupported: false);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.Null(fixture.Factory.Input);
Assert.Null(fixture.Factory.Bootstrap);
Assert.Equal(1, fixture.Factory.Calls);
}
[Fact]
public void EnabledFrontendPublishesOneCompleteOwnerAfterEveryEdge()
{
using var fixture = new Fixture();
SettingsDevToolsResult result = fixture.Compose();
Assert.Same(fixture.Publication.Owner, result.DevTools);
Assert.Equal(
Enum.GetValues<SettingsDevToolsCompositionPoint>(),
fixture.Points);
Assert.True(fixture.Factory.Input!.Activated);
Assert.Equal(4, fixture.Factory.Backend!.LayoutCalls);
Assert.Equal(1, fixture.Publication.PublishCalls);
}
[Theory]
[MemberData(nameof(OptionalFailurePoints))]
public void OptionalPrefixFailureDisablesOnlyAfterCompleteReverseCleanup(
int pointValue)
{
var point = (SettingsDevToolsCompositionPoint)pointValue;
using var fixture = new Fixture(failurePoint: point);
SettingsDevToolsResult result = fixture.Compose();
Assert.Null(result.DevTools);
Assert.Null(fixture.Publication.Owner);
Assert.Equal(
Enum.GetValues<SettingsDevToolsCompositionPoint>()
.TakeWhile(candidate => candidate <= point),
fixture.Points);
if (fixture.Factory.Input is { } input)
Assert.True(input.IsDisposalComplete);
if (fixture.Factory.Bootstrap is { } bootstrap)
Assert.Equal(1, bootstrap.DisposeCalls);
if (fixture.Factory.Backend is { } backend)
Assert.Equal(1, backend.DisposeCalls);
}
public static TheoryData<int> OptionalFailurePoints()
{
var data = new TheoryData<int>();
foreach (SettingsDevToolsCompositionPoint point in
Enum.GetValues<SettingsDevToolsCompositionPoint>())
{
if (point is SettingsDevToolsCompositionPoint.SettingsApplied
or SettingsDevToolsCompositionPoint.DevToolsPublished)
{
continue;
}
data.Add((int)point);
}
return data;
}
[Fact]
public void FailureAfterPublicationPropagatesToLifetimeOwner()
{
using var fixture = new Fixture(
failurePoint: SettingsDevToolsCompositionPoint.DevToolsPublished);
Assert.Throws<InvalidOperationException>(fixture.Compose);
Assert.NotNull(fixture.Publication.Owner);
Assert.False(fixture.Publication.Owner!.IsDisposalComplete);
}
[Fact]
public void CleanupFailurePropagatesAndRetrySkipsCompletedOperations()
{
using var fixture = new Fixture(
failurePoint: SettingsDevToolsCompositionPoint.InitialLayoutApplied,
backendDisposeFailures: 1);
var failure = Assert.Throws<CompositionAcquisitionException>(fixture.Compose);
Assert.False(failure.IsCleanupComplete);
Assert.Equal(1, fixture.Factory.Backend!.DisposeCalls);
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal(2, fixture.Factory.Backend.DisposeCalls);
Assert.Equal(1, fixture.Factory.Input.DisposeCalls);
}
[Fact]
public void BootstrapConstructionWithoutCleanupOwnerAbortsHostStartup()
{
var bootstrapFailure = (ImGuiBootstrapperConstructionException)
Activator.CreateInstance(
typeof(ImGuiBootstrapperConstructionException),
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
args: [new InvalidOperationException("partial Silk construction")],
culture: null)!;
using var fixture = new Fixture(bootstrapFailure: bootstrapFailure);
Assert.Throws<ImGuiBootstrapperConstructionException>(fixture.Compose);
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
Assert.Null(fixture.Publication.Owner);
Assert.DoesNotContain(
fixture.Logs,
message => message.Contains("devtools disabled", StringComparison.Ordinal));
}
[Fact]
public void GameWindowUsesProductionPhaseAndDoesNotRetainHostClosures()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new SettingsDevToolsCompositionPhase(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("new AcDream.UI.ImGui.ImGuiBootstrapper(", source,
StringComparison.Ordinal);
Assert.DoesNotContain("getPlayerPosition: () => GetDebugPlayerPosition()", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_runtimeSettings.ApplyStartup(", source,
StringComparison.Ordinal);
}
private sealed class Fixture : IDisposable
{
private readonly SettingsDevToolsCompositionPoint? _failurePoint;
private readonly InputDispatcher _dispatcher;
private readonly FrameProfiler _profiler = new();
public Fixture(
bool enabled = true,
SettingsDevToolsCompositionPoint? failurePoint = null,
int backendDisposeFailures = 0,
bool inputSupported = true,
Exception? bootstrapFailure = null)
{
_failurePoint = failurePoint;
Factory = new Factory(
backendDisposeFailures,
inputSupported,
bootstrapFailure);
Publication = new Publication();
Startup = new StartupTarget();
Settings = new RuntimeSettingsController(
new Storage(),
QualitySettings.From,
static _ => { });
_dispatcher = InputDispatcher.CreateDetached(
new KeyboardSource(),
new MouseSource(),
new KeyBindings());
_dispatcher.Attach();
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
Host = new HostInputCameraResult(
null!,
null!,
null!,
null!,
null!,
null,
null,
null,
null,
_dispatcher,
camera,
null);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!);
Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject(
typeof(ContentEffectsAudioResult));
Dependencies = new SettingsDevToolsDependencies(
DispatchProxy.Create<IView, ViewProxy>(),
Settings,
Startup,
new HostQuiescenceGate(),
GameRuntimeTestFactory.Create(),
enabled
? new SettingsDevToolsOptionalDependencies(
new Facts(),
new KeyBindingTarget(),
new DeferredCanonicalWorldEntityCountSource(),
new DeferredRenderFrameDiagnosticsSource(),
new DeferredDevToolsPlayerModeCommands())
: null,
new RuntimeDiagnosticCommandSlot(),
new CombatFeedbackSlot(),
new KeyBindings(),
_profiler,
new FramebufferResizeController(new ViewportAspectState()),
Logs.Add);
}
public List<SettingsDevToolsCompositionPoint> Points { get; } = [];
public List<string> Logs { get; } = [];
public Factory Factory { get; }
public Publication Publication { get; }
public StartupTarget Startup { get; }
public RuntimeSettingsController Settings { get; }
public SettingsDevToolsDependencies Dependencies { get; }
public HostInputCameraResult Host { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> Platform { get; }
public ContentEffectsAudioResult Content { get; }
public SettingsDevToolsResult Compose() =>
new SettingsDevToolsCompositionPhase(
Dependencies,
Publication,
Factory,
point =>
{
Points.Add(point);
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(Platform, Host, Content);
public void Dispose()
{
Dependencies.Runtime.Dispose();
Publication.Owner?.Dispose();
_dispatcher.Dispose();
_profiler.Dispose();
}
}
private sealed class Publication : IGameWindowSettingsDevToolsPublication
{
public DevToolsCompositionOwner? Owner { get; private set; }
public int PublishCalls { get; private set; }
public void PublishDevTools(DevToolsCompositionOwner value)
{
if (Owner is not null)
throw new InvalidOperationException("duplicate publication");
Owner = value;
PublishCalls++;
}
}
private sealed class Factory(
int backendDisposeFailures,
bool inputSupported,
Exception? bootstrapFailure)
: ISettingsDevToolsCompositionFactory
{
public int Calls { get; private set; }
public InputContext? Input { get; private set; }
public Bootstrap? Bootstrap { get; private set; }
public Backend? Backend { get; private set; }
public bool IsSupported(IInputContext input)
{
Calls++;
return inputSupported;
}
public IDevToolsInputContext CreateInputContext(
IInputContext input,
HostQuiescenceGate quiescence)
{
Calls++;
return Input = new InputContext();
}
public IImGuiBootstrapper CreateBootstrap(
GL gl,
IView window,
IInputContext input)
{
Calls++;
if (bootstrapFailure is not null)
throw bootstrapFailure;
return Bootstrap = new Bootstrap();
}
public ImGuiPanelHost CreatePanelHost()
{
Calls++;
return new ImGuiPanelHost();
}
public IDevToolsFrameBackend CreateBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels)
{
Calls++;
return Backend = new Backend(
(Bootstrap)bootstrap,
backendDisposeFailures);
}
}
private sealed class InputContext : IDevToolsInputContext
{
public bool Activated { get; private set; }
public int DisposeCalls { get; private set; }
public bool IsDisposalComplete { get; private set; }
public nint Handle => 0;
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
public IReadOnlyList<IKeyboard> Keyboards { get; } = [];
public IReadOnlyList<IMouse> Mice { get; } = [];
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
#pragma warning disable CS0067
public event Action<IInputDevice, bool>? ConnectionChanged;
#pragma warning restore CS0067
public void Activate() => Activated = true;
public void Deactivate() => Activated = false;
public void Dispose()
{
DisposeCalls++;
Activated = false;
IsDisposalComplete = true;
}
}
private sealed class Bootstrap : IImGuiBootstrapper
{
public int DisposeCalls { get; private set; }
public void BeginFrame(float deltaSeconds) { }
public void Render() { }
public void AbortFrame() { }
public void Dispose() => DisposeCalls++;
}
private sealed class Backend(
Bootstrap bootstrap,
int remainingDisposeFailures) : IDevToolsFrameBackend
{
private int _remainingDisposeFailures = remainingDisposeFailures;
public int DisposeCalls { get; private set; }
public int LayoutCalls { get; private set; }
public void BeginFrame(float deltaSeconds) { }
public void AbortFrame() { }
public bool BeginMainMenuBar() => false;
public void EndMainMenuBar() { }
public bool BeginMenu(string label) => false;
public void EndMenu() { }
public bool MenuItem(string label, string? shortcut = null, bool selected = false) => false;
public void Separator() { }
public void RenderPanels(AcDream.UI.Abstractions.PanelContext context) { }
public void RenderDrawData() { }
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition) => LayoutCalls++;
public void Dispose()
{
DisposeCalls++;
if (_remainingDisposeFailures-- > 0)
throw new InvalidOperationException("backend cleanup failed");
bootstrap.Dispose();
}
}
private sealed class StartupTarget : IRuntimeSettingsStartupTarget
{
public int DisplayCalls { get; private set; }
public int AudioCalls { get; private set; }
public void ApplyDisplay(DisplaySettings display) => DisplayCalls++;
public void ApplyAudio(AudioSettings audio) => AudioCalls++;
}
private sealed class Storage : IRuntimeSettingsStorage
{
public SettingsStore? LayoutStore => null;
public string Location => "memory://settings";
public DisplaySettings LoadDisplay() => DisplaySettings.Default;
public AudioSettings LoadAudio() => AudioSettings.Default;
public GameplaySettings LoadGameplay() => GameplaySettings.Default;
public ChatSettings LoadChat() => ChatSettings.Default;
public CharacterSettings LoadCharacter(string toonKey) => CharacterSettings.Default;
public void SaveDisplay(DisplaySettings display) { }
public void SaveAudio(AudioSettings audio) { }
public void SaveGameplay(GameplaySettings gameplay) { }
public void SaveChat(ChatSettings chat) { }
public void SaveCharacter(string toonKey, CharacterSettings character) { }
}
private sealed class KeyBindingTarget : IRuntimeKeyBindingTarget
{
public void Apply(KeyBindings bindings) { }
}
private sealed class Facts : IDevToolsRuntimeFacts
{
public Vector3 PlayerPosition => default;
public float PlayerHeadingDegrees => 0;
public uint PlayerCellId => 0;
public bool PlayerOnGround => false;
public bool InPlayerMode => false;
public bool InFlyMode => false;
public float VerticalVelocity => 0;
public int EntityCount => 0;
public int AnimatedCount => 0;
public int VisibleLandblocks => 0;
public int TotalLandblocks => 0;
public int ShadowObjectCount => 0;
public float NearestObjectDistance => float.PositiveInfinity;
public string NearestObjectLabel => "-";
public bool Colliding => false;
public bool CollisionWireframesVisible => false;
public int StreamingRadius => 0;
public float MouseSensitivity => 1;
public float ChaseDistance => 0;
public bool RmbOrbitHeld => false;
public string HourName => "0";
public float DayFraction => 0;
public string Weather => "Clear";
public int ActiveLights => 0;
public int RegisteredLights => 0;
public int ParticleCount => 0;
public float Fps => 60;
public float FrameMilliseconds => 16.7f;
}
private sealed class KeyboardSource : IKeyboardSource
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class MouseSource : IMouseSource
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
public class ViewProxy : DispatchProxy
{
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod?.Name == "get_Size")
return new Vector2D<int>(1280, 720);
Type type = targetMethod?.ReturnType ?? typeof(void);
if (type == typeof(void))
return null;
return type.IsValueType ? Activator.CreateInstance(type) : null;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -196,8 +196,7 @@ public sealed class WorldRenderCompositionTests
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High),
null));
QualitySettings.From(QualityPreset.High)));
}
private sealed class RenderLifetime(TerrainAtlas atlas)

View file

@ -12,7 +12,9 @@ public sealed class GraphicalHostPlatformServicesTests
Assert.NotNull(platform.Paths);
Assert.NotNull(platform.FramePacingWaiters);
Assert.Equal(3, platform.NativeDependencies.Count);
// Campaign V slice V11 removed the ImGui developer-tools frontend and
// its cimgui native dependency, leaving window/input + audio.
Assert.Equal(2, platform.NativeDependencies.Count);
Assert.All(
platform.NativeDependencies,
dependency =>

View file

@ -1,455 +0,0 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Rendering;
public sealed class DevToolsFramePresenterTests
{
[Fact]
public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DevToolsCommandBusSource();
var first = new TestUiSessionTarget();
var second = new TestUiSessionTarget();
IDisposable firstBinding = source.BindOwned(first);
Assert.Same(first.Commands, source.Current);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
Assert.Same(second.Commands, source.Current);
}
private sealed class TestUiSessionTarget : ILiveUiSessionTarget
{
public bool IsInWorld => false;
public AcDream.Core.Net.WorldSession? CurrentSession => null;
public ICommandBus Commands { get; } = new RecordingCommandBus();
}
[Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls);
var commands = new RecordingCommandSource();
var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.25f);
presenter.Render(0.5, 1280, 720);
Assert.Equal(
[
"begin:0.25",
"begin-bar",
"begin-menu:View",
"begin-menu:Camera",
"end-bar",
"panels:0.5",
"draw-data",
],
calls);
Assert.Same(commands.Current, backend.Contexts.Single().Commands);
}
[Fact]
public void Render_ResolvesTheCurrentCommandBusEveryFrame()
{
var backend = new RecordingBackend([]);
var first = new RecordingCommandBus();
var second = new RecordingCommandBus();
var commands = new RecordingCommandSource { Current = first };
var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 800, 600);
commands.Current = second;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Same(first, backend.Contexts[0].Commands);
Assert.Same(second, backend.Contexts[1].Commands);
}
[Fact]
public void ViewMenu_TogglesExactPanelsAndUsesCurrentViewportForReset()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("View");
backend.ClickedItems.UnionWith(
["Settings", "Vitals", "Chat", "Debug", "Reset window layout"]);
var panels = new RecordingPanels();
var presenter = Create(backend, panels: panels);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1920, 1080);
Assert.Equal(
[
DevToolsPanelKind.Settings,
DevToolsPanelKind.Vitals,
DevToolsPanelKind.Chat,
DevToolsPanelKind.Debug,
],
panels.Toggles);
Assert.Equal(
[
new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)),
new LayoutCall("Chat", new Vector2(10f, 760f), new Vector2(450f, 300f)),
new LayoutCall("Debug", new Vector2(1540f, 30f), new Vector2(370f, 520f)),
new LayoutCall("Settings", new Vector2(610f, 290f), new Vector2(700f, 500f)),
],
backend.Layouts.Select(call => call.WithoutCondition()));
Assert.All(
backend.Layouts,
call => Assert.Equal(DevToolsPanelLayoutCondition.Always, call.Condition));
}
[Fact]
public void CameraMenu_UsesFlyLabelAndRoundTripsCollisionState()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("Camera");
backend.ClickedItems.UnionWith(
["Exit Free-Fly Mode", "Collide Camera (spring arm)"]);
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
var presenter = Create(backend, camera: camera);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.Equal(1, camera.ToggleCount);
Assert.False(camera.CollideCamera);
Assert.Contains(
backend.MenuItems,
item => item.Label == "Collide Camera (spring arm)" && item.Selected);
}
[Fact]
public void MissingSettingsPanel_IsInertAndSkippedFromLayout()
{
var backend = new RecordingBackend([]);
backend.OpenMenus.Add("View");
backend.ClickedItems.Add("Settings");
var panels = new RecordingPanels { HasSettings = false };
var presenter = Create(backend, panels: panels);
presenter.ToggleSettingsPanel();
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
Assert.DoesNotContain(backend.MenuItems, item => item.Label == "Settings");
Assert.DoesNotContain(backend.Layouts, call => call.Title == "Settings");
}
[Theory]
[InlineData(1280, 720, 900, 400, 290, 110)]
[InlineData(1920, 1080, 1540, 760, 610, 290)]
[InlineData(100, 100, 100, 0, -110, -90)]
public void ResetLayout_PreservesExactFormulasAndMinimumViewport(
int width,
int height,
int debugX,
int chatY,
int settingsX,
int settingsY)
{
var backend = new RecordingBackend([]);
var presenter = Create(backend);
presenter.ResetLayout(width, height, DevToolsPanelLayoutCondition.FirstUseEver);
Assert.Collection(
backend.Layouts,
call => Assert.Equal(
new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall("Chat", new Vector2(10f, chatY), new Vector2(450f, 300f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall("Debug", new Vector2(debugX, 30f), new Vector2(370f, 520f)),
call.WithoutCondition()),
call => Assert.Equal(
new LayoutCall(
"Settings",
new Vector2(settingsX, settingsY),
new Vector2(700f, 500f)),
call.WithoutCondition()));
}
[Fact]
public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls);
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
presenter.AbortFrame();
presenter.AbortFrame();
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
Assert.Equal(1, calls.Count(call => call == "draw-data"));
}
[Fact]
public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
DrawFailure = new InvalidOperationException("draw"),
};
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
Assert.Throws<InvalidOperationException>(() =>
presenter.Render(0.1, 800, 600));
presenter.AbortFrame();
backend.DrawFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(0, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call == "draw-data"));
}
[Fact]
public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
BeginFailure = new InvalidOperationException("begin"),
};
var presenter = Create(backend);
Assert.Throws<InvalidOperationException>(() => presenter.BeginFrame(0.1f));
presenter.AbortFrame();
backend.BeginFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
}
[Fact]
public void InputFacingActionsDelegateToTheTypedPanelOwner()
{
var panels = new RecordingPanels();
var presenter = Create(new RecordingBackend([]), panels: panels);
presenter.ToggleDebugPanel();
presenter.FocusChatInput();
presenter.ToggleSettingsPanel();
Assert.Equal(
[DevToolsPanelKind.Debug, DevToolsPanelKind.Settings],
panels.Toggles);
Assert.Equal(1, panels.FocusChatCount);
Assert.Equal(["Debug panel ON"], panels.Toasts);
}
[Fact]
public void PresenterAndAdaptersHaveNoWindowOrDelegateBackReferences()
{
Type[] owners =
[
typeof(DevToolsFramePresenter),
typeof(ImGuiDevToolsFrameBackend),
typeof(DevToolsCameraMenuOperations),
typeof(DevToolsCommandBusSource),
typeof(DevToolsPanelSet),
];
foreach (Type owner in owners)
{
FieldInfo[] fields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
Assert.DoesNotContain(
fields,
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
}
Assert.True(typeof(IDisposable).IsAssignableFrom(
typeof(ImGuiDevToolsFrameBackend)));
}
private static DevToolsFramePresenter Create(
RecordingBackend backend,
RecordingCamera? camera = null,
RecordingCommandSource? commands = null,
RecordingPanels? panels = null) =>
new(
backend,
camera ?? new RecordingCamera(),
commands ?? new RecordingCommandSource(),
new FrameProfiler(),
panels ?? new RecordingPanels());
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
{
public void Dispose() { }
public HashSet<string> OpenMenus { get; } = [];
public HashSet<string> ClickedItems { get; } = [];
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> Layouts { get; } = [];
public Exception? BeginFailure { get; set; }
public Exception? DrawFailure { get; set; }
public bool ControllerFrameOpen { get; private set; }
public void BeginFrame(float deltaSeconds)
{
calls.Add($"begin:{deltaSeconds}");
ControllerFrameOpen = true;
if (BeginFailure is not null)
throw BeginFailure;
}
public void AbortFrame()
{
// Silk ImGuiController.Render is idempotent. It closes an active
// controller frame, but is a no-op if Render already cleared its
// private _frameBegun flag before a GL upload failure.
if (!ControllerFrameOpen)
return;
ControllerFrameOpen = false;
calls.Add("abort");
}
public bool BeginMainMenuBar()
{
calls.Add("begin-bar");
return true;
}
public void EndMainMenuBar() => calls.Add("end-bar");
public bool BeginMenu(string label)
{
calls.Add($"begin-menu:{label}");
return OpenMenus.Contains(label);
}
public void EndMenu() => calls.Add("end-menu");
public bool MenuItem(string label, string? shortcut = null, bool selected = false)
{
MenuItems.Add((label, shortcut, selected));
return ClickedItems.Contains(label);
}
public void Separator() => calls.Add("separator");
public void RenderPanels(PanelContext context)
{
Contexts.Add(context);
calls.Add($"panels:{context.DeltaSeconds}");
}
public void RenderDrawData()
{
Assert.True(ControllerFrameOpen);
ControllerFrameOpen = false;
calls.Add("draw-data");
if (DrawFailure is not null)
throw DrawFailure;
}
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition) =>
Layouts.Add(new LayoutCallWithCondition(title, position, size, condition));
}
private sealed class RecordingCamera : IDevToolsCameraMenuOperations
{
public bool IsFlyMode { get; init; }
public bool CollideCamera { get; set; }
public int ToggleCount { get; private set; }
public void ToggleFlyOrChase() => ToggleCount++;
}
private sealed class RecordingCommandSource : IDevToolsCommandBusSource
{
public ICommandBus Current { get; set; } = new RecordingCommandBus();
}
private sealed class RecordingCommandBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull
{
}
}
private sealed class RecordingPanels : IDevToolsPanelSet
{
private readonly HashSet<DevToolsPanelKind> _visible = [];
public bool HasSettings { get; init; } = true;
public List<DevToolsPanelKind> Toggles { get; } = [];
public List<string> Toasts { get; } = [];
public int FocusChatCount { get; private set; }
public bool Contains(DevToolsPanelKind kind) =>
kind != DevToolsPanelKind.Settings || HasSettings;
public string? GetTitle(DevToolsPanelKind kind) => Contains(kind)
? kind.ToString()
: null;
public bool IsVisible(DevToolsPanelKind kind) => _visible.Contains(kind);
public void Toggle(DevToolsPanelKind kind)
{
if (!Contains(kind))
return;
Toggles.Add(kind);
if (!_visible.Add(kind))
_visible.Remove(kind);
}
public void FocusChatInput() => FocusChatCount++;
public void AddDebugToast(string message) => Toasts.Add(message);
}
private readonly record struct LayoutCall(
string Title,
Vector2 Position,
Vector2 Size);
private readonly record struct LayoutCallWithCondition(
string Title,
Vector2 Position,
Vector2 Size,
DevToolsPanelLayoutCondition Condition)
{
public LayoutCall WithoutCondition() => new(Title, Position, Size);
}
}

View file

@ -108,13 +108,6 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new PaperdollFramePresenter(", LivePresentationSource());
string settingsComposition = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SettingsDevToolsComposition.cs"));
Assert.Contains("new DevToolsFramePresenter(", settingsComposition);
string framePhase = FrameRootSource();
Assert.Contains("new RenderFrameResourceController(", framePhase);
Assert.Contains("new RenderWeatherFrameController(", framePhase);
@ -131,23 +124,21 @@ public sealed class GameWindowRenderLeafCompositionTests
}
[Fact]
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
public void Shutdown_DrainsGpuBeforeFrontendsAndPreservesFrameBorrowerOrder()
{
// Campaign V slice V11 removed the ImGui developer-tools frontend and
// its "developer tools" shutdown stage entry along with it; this test
// used to pin that entry's position and is now renamed to pin what
// survives it.
string source = GameWindowLifetimeSource();
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"Hard(\"portal tunnel\"",
"Hard(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
AssertAppearsInOrder(
source,
"private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)",
"owner.DisposeFrontend()",
"if (!owner.IsFrontendDisposalComplete)");
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
@ -161,7 +152,7 @@ public sealed class GameWindowRenderLeafCompositionTests
AssertAppearsInOrder(
source,
"frame.FrameGraphPublication?.Dispose()",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"input context\"",
"platform.Input?.Dispose()",
"new ResourceShutdownStage(\"OpenGL context\"");

View file

@ -58,7 +58,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ContentEffectsAudioCompositionPhase(",
"this).Compose(platformResult, hostInputCamera),",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platformResult, hostInputCamera, contentEffectsAudio);",
".Compose(platformResult, hostInputCamera, contentEffectsAudio),",
"new InteractionRetainedUiCompositionPhase(",
"this).Compose(",
"new LivePresentationCompositionPhase(",
@ -197,17 +197,6 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal);
string settingsPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SettingsDevToolsComposition.cs"));
AssertAppearsInOrder(
settingsPhase,
"debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;",
"debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;",
"debugVm.ToggleCollisionWires =");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -349,7 +338,7 @@ public sealed class GameWindowSlice8BoundaryTests
load,
"GameWindowCompositionPipeline.Run<",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platformResult, hostInputCamera, contentEffectsAudio);",
".Compose(platformResult, hostInputCamera, contentEffectsAudio),",
"new WorldRenderCompositionPhase(",
"new SessionPlayerCompositionPhase(",
"new FrameRootCompositionPhase(",
@ -371,10 +360,10 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App",
"Composition",
"SettingsDevToolsComposition.cs"));
AssertAppearsInOrder(
settingsPhase,
Assert.Contains(
"_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);",
"_dependencies.DevTools is { } optional");
settingsPhase,
StringComparison.Ordinal);
string worldPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",

View file

@ -162,9 +162,6 @@ public sealed class PrivatePresentationRendererTests
Assert.Contains(
typeof(AcDream.App.UI.UiElement),
FieldTypes(typeof(PaperdollInventoryVisibility)));
Assert.Contains(
typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM),
FieldTypes(typeof(DevToolsPanelSet)));
Assert.Contains(
typeof(AcDream.App.Settings.IRuntimeSettingsPreviewSource),
FieldTypes(typeof(RuntimeWorldFrameSettingsPreview)));

View file

@ -1,118 +0,0 @@
using AcDream.App.Studio;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Pure-math tests for the canvas → panel-local coordinate mapping used by
/// <see cref="StudioInspector.DrawCanvas"/>.
///
/// <para>No GL context required — we're just verifying the formula:
/// panel_local = (raw_mouse_screen) - (image_screen_top_left)</para>
///
/// <para>The image_screen_top_left is what ImGui.GetItemRectMin() returns after
/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any
/// scrolling. We model it as a constant offset in these tests.</para>
///
/// <para>V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left
/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image)
/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied.</para>
/// </summary>
public class CanvasCoordMappingTests
{
// Simulates the mapping DrawCanvas performs:
// panel pixel = mouse_screen - image_rectMin
// Returns null when the result falls outside [0, width) x [0, height).
private static (int px, int py)? Map(
float mouseScreenX, float mouseScreenY,
float imageOriginX, float imageOriginY,
int panelWidth, int panelHeight)
{
int ix = (int)(mouseScreenX - imageOriginX);
int iy = (int)(mouseScreenY - imageOriginY);
if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight)
return null;
return (ix, iy);
}
[Fact]
public void TopLeft_of_image_maps_to_panel_origin()
{
// The canvas image starts at screen (300, 50) (after sub-window chrome).
// A click exactly at the image's screen top-left → panel (0, 0).
var result = Map(mouseScreenX: 300f, mouseScreenY: 50f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((0, 0), result);
}
[Fact]
public void Interior_point_maps_correctly()
{
// Image origin at screen (300, 50). Mouse at screen (780, 230).
// Expected panel coord: (780-300, 230-50) = (480, 180).
var result = Map(mouseScreenX: 780f, mouseScreenY: 230f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((480, 180), result);
}
[Fact]
public void Bottom_right_corner_maps_to_last_valid_pixel()
{
// Image is 1280×720, origin at screen (300, 50).
// Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769).
var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((1279, 719), result);
}
[Fact]
public void Mouse_on_ImGui_chrome_above_image_returns_null()
{
// The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50.
// The mouse there should NOT produce a panel event.
var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Mouse_below_image_returns_null()
{
// screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720).
var result = Map(mouseScreenX: 400f, mouseScreenY: 770f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Y_is_not_inverted_after_vflip()
{
// Confirm the "no extra Y inversion" contract:
// The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0.
// A click near the TOP of the image should give a SMALL panel Y, not a large one.
// Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top).
var result = Map(mouseScreenX: 400f, mouseScreenY: 55f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((100, 5), result);
// NOT (100, 715) — which would be the result if Y were incorrectly inverted.
Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom");
}
[Fact]
public void LargeChrome_offset_is_fully_absorbed()
{
// Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px)
// puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50.
// Also a wide tree pane puts imageOriginX = 280 + padding.
// A click at screen (400, 110) with origin (290, 50) → panel (110, 60).
var result = Map(mouseScreenX: 400f, mouseScreenY: 110f,
imageOriginX: 290f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((110, 60), result);
}
}

View file

@ -1,188 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Tests for <see cref="DumpLayout"/> — parsing the retail UI dump JSON and
/// building a <see cref="UiElement"/> tree from it.
///
/// These tests load the real dump file from the source tree
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>). The test
/// skips cleanly when the file is absent (should not happen in a normal dev
/// checkout, but guards against stripped CI machines).
/// </summary>
public class DumpLayoutTests
{
private static string DumpPath()
{
// Walk up from the test output directory to the solution root,
// mirroring ConformanceDats.SolutionRoot().
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return Path.Combine(dir, "docs", "research",
"2026-06-25-retail-ui-layout-dump.json");
dir = Path.GetDirectoryName(dir);
}
// Fallback: try a relative path (won't find it but skip rather than throw)
return Path.Combine(AppContext.BaseDirectory,
"docs", "research", "2026-06-25-retail-ui-layout-dump.json");
}
private static (uint, int, int) NoTex(uint _) => (1u, 1, 1);
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>Depth-first search for an element with the given EventId.</summary>
private static UiElement? FindById(UiElement root, uint id)
{
if (root.EventId == id) return root;
foreach (var c in root.Children)
{
var found = FindById(c, id);
if (found is not null) return found;
}
return null;
}
/// <summary>Count the total elements in the tree (self + all descendants).</summary>
private static int CountAll(UiElement root)
{
int n = 1;
foreach (var c in root.Children) n += CountAll(c);
return n;
}
// ── Tests ─────────────────────────────────────────────────────────────
/// <summary>
/// Loading the "inventory" slug should succeed and the returned tree should
/// contain an element with EventId == 0x100001D5 (the doll viewport node)
/// and at least 40 elements in total.
/// </summary>
[Fact]
public void Load_Inventory_ReturnsTreeWithDollViewport()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip: dump not available.
var root = DumpLayout.Load(path, "inventory", NoTex, out var err);
Assert.NotNull(root);
Assert.Null(err);
// The doll viewport element must appear somewhere in the tree.
const uint dollViewportId = 0x100001D5u;
var found = FindById(root!, dollViewportId);
Assert.NotNull(found);
// The full tree must be reasonably deep — 59 dump nodes → >= 40 elements.
int total = CountAll(root!);
Assert.True(total >= 40,
$"Expected >= 40 elements in inventory tree; got {total}");
}
/// <summary>
/// Loading an unknown slug must return null and a non-empty error string.
/// </summary>
[Fact]
public void Load_UnknownSlug_ReturnsNullWithError()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err);
Assert.Null(root);
Assert.NotNull(err);
Assert.NotEmpty(err!);
}
/// <summary>
/// The root element's Left/Top should be (0,0) (the panel's rect offset has
/// been stripped so the tree sits at the window origin), and its Width/Height
/// should match the dump panel dimensions.
/// </summary>
[Fact]
public void Load_Inventory_RootAtOrigin()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// Root always placed at (0,0) by DumpLayout (origin of the UiHost).
Assert.Equal(0f, root!.Left);
Assert.Equal(0f, root.Top);
// Width/Height come from the panel record in the dump.
Assert.True(root.Width > 0, "Root width must be > 0");
Assert.True(root.Height > 0, "Root height must be > 0");
}
/// <summary>
/// Children must use parent-relative coordinates (the dump rects are absolute;
/// DumpLayout subtracts the parent rect to produce parent-local offsets).
/// Verify that at least the direct children of the root have Left/Top values
/// that are NOT equal to the absolute rect they had in the dump (since the root
/// was at x>0 in screen space but we place it at 0,0).
/// </summary>
[Fact]
public void Load_Inventory_ChildrenAreParentRelative()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// If the dump has children at absolute x>=500 but the root is at 0,
// a correct parent-relative placement will give children x < 500.
// (The inventory panel root is at absolute x=500; children in the dump
// also start at x=500 — after subtraction they should land near x=0.)
bool anyChildAtAbsoluteX = false;
foreach (var child in root!.Children)
{
if (child.Left >= 490f) // would indicate absolute not relative
{
anyChildAtAbsoluteX = true;
break;
}
}
Assert.False(anyChildAtAbsoluteX,
"Children appear to have absolute coords (Left >= 490) — " +
"DumpLayout must subtract the parent rect.");
}
/// <summary>
/// Every panel slug known in the dump must load without error.
/// This is a smoke test that the JSON parse + tree build does not
/// crash on any of the 26 panels.
/// </summary>
[Fact]
public void Load_AllSlugs_Succeed()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var slugs = UiDumpModel.ListSlugs(path);
Assert.True(slugs.Count >= 20,
$"Expected >= 20 panel slugs in dump; got {slugs.Count}");
foreach (var slug in slugs)
{
var root = DumpLayout.Load(path, slug, NoTex, out var err);
Assert.True(root is not null || err is not null,
$"Load('{slug}') returned both null root AND null error — one must be set.");
if (root is null)
Assert.Fail($"Slug '{slug}' failed with error: {err}");
}
}
}

View file

@ -1,160 +0,0 @@
using AcDream.App.Studio;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="FixtureProvider"/> and <see cref="SampleData"/>.
/// These tests have NO GL/dat dependency — they only exercise the in-memory
/// ClientObjectTable population that FixtureProvider uses.
/// </summary>
public class FixtureProviderTests
{
/// <summary>
/// SampleData.BuildObjectTable() must place at least 6 items in the
/// player's main pack (ContainerId == PlayerGuid) and the player object
/// itself must exist with the right capacities.
/// </summary>
[Fact]
public void SampleTable_hasPackContents()
{
var t = SampleData.BuildObjectTable();
// Player object must exist.
var player = t.Get(SampleData.PlayerGuid);
Assert.NotNull(player);
Assert.Equal(102, player!.ItemsCapacity);
Assert.Equal(7, player.ContainersCapacity);
// At least 6 loose items must be in the main pack.
var contents = t.GetContents(SampleData.PlayerGuid);
Assert.True(contents.Count >= 6,
$"Expected >= 6 items in player pack; got {contents.Count}");
// Verify the first item has a non-zero IconId and a recognised Type.
var firstId = contents[0];
var first = t.Get(firstId);
Assert.NotNull(first);
Assert.NotEqual(0u, first!.IconId);
Assert.NotEqual(ItemType.None, first.Type);
}
/// <summary>
/// Spot-check that the sample table also seeds a sword-like item
/// (MeleeWeapon) and a piece of armor so icon resolution has
/// recognisable item types to work with.
/// </summary>
[Fact]
public void SampleTable_hasWeaponAndArmor()
{
var t = SampleData.BuildObjectTable();
var contents = t.GetContents(SampleData.PlayerGuid);
bool hasMelee = false, hasArmor = false;
foreach (var guid in contents)
{
var obj = t.Get(guid);
if (obj is null) continue;
if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true;
if ((obj.Type & ItemType.Armor) != 0) hasArmor = true;
}
Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack");
Assert.True(hasArmor, "Expected at least one Armor item in sample pack");
}
/// <summary>
/// Sample table must include at least 1 equipped item whose
/// CurrentlyEquippedLocation is non-None.
/// </summary>
[Fact]
public void SampleTable_hasEquippedItems()
{
var t = SampleData.BuildObjectTable();
bool anyEquipped = false;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation != EquipMask.None)
{ anyEquipped = true; break; }
}
Assert.True(anyEquipped, "Expected at least one equipped item in sample table");
}
/// <summary>
/// Sample table must include at least 2 side-bag containers in the
/// player's pack (ContainerId == PlayerGuid, Type has Container bit).
/// </summary>
[Fact]
public void SampleTable_hasSideBags()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is not null && (obj.Type & ItemType.Container) != 0)
bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
/// <summary>
/// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity &gt; 0.
/// This ensures they appear in the side-bag column even if the exact Type flag changes.
/// </summary>
[Fact]
public void SampleTable_sideBags_matchInventoryControllerFilter()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is null) continue;
// InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0
bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0;
if (isBag) bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 items matching the side-bag filter; got {bagCount}");
}
/// <summary>
/// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after
/// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate
/// can find them. These are the two conditions PaperdollController checks on every
/// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND
/// CurrentlyEquippedLocation != None).
/// </summary>
[Fact]
public void SampleTable_equippedItems_retainLocationAndAreInContents()
{
var t = SampleData.BuildObjectTable();
var contents = new System.Collections.Generic.HashSet<uint>(t.GetContents(SampleData.PlayerGuid));
int equippedCount = 0;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation == EquipMask.None) continue;
equippedCount++;
// Must appear in GetContents so the controller can pick them up.
Assert.True(contents.Contains(obj.ObjectId),
$"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " +
$"is NOT in GetContents(PlayerGuid). PaperdollController will miss it.");
// The equip location must survive the MoveItem call.
Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation);
}
Assert.True(equippedCount >= 3,
$"Expected >= 3 equipped items in sample table; got {equippedCount}");
}
}

View file

@ -1,60 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="LayoutSource"/>. The dat-backed test skips cleanly
/// when the real dats are not present (CI / dev machines without AC installed).
/// </summary>
public class LayoutSourceTests
{
private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1);
/// <summary>Resolve the client dat directory, or null if unavailable (skip the test).</summary>
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
/// <summary>
/// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify
/// that LayoutSource returns a non-null root with the layout id findable.
/// Skips when the dats are unavailable.
///
/// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat
/// id, not an element id in the widget tree. The actual vitals root element id
/// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert
/// FindElement(0x100005F9) here which verifies the same integration path: the
/// layout was successfully loaded and the element dict was populated.
/// </summary>
[Fact]
public void LoadsDatLayout_byId()
{
var dir = ResolveDatDir();
if (dir is null)
return; // Skip: dats not available on this machine / CI.
using var dats = new DatCollection(dir, DatAccessType.Read);
var src = new LayoutSource(dats, NoTex, datFont: null);
var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null));
// root must be non-null: Import found the LayoutDesc and built the tree.
Assert.NotNull(root);
Assert.NotNull(src.CurrentLayout);
// The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id).
// Asserting FindElement verifies the byId dict was populated by the importer.
Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u));
Assert.Equal(LayoutSourceKind.DatLayout, src.Kind);
}
}

View file

@ -1,73 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
namespace AcDream.App.Tests.Studio;
public class StudioWindowTests
{
[Fact]
public void FrameCloseGate_defersCloseUntilFrameCompletion()
{
var gate = new StudioFrameCloseGate();
int closeCount = 0;
gate.Request();
Assert.True(gate.IsRequested);
Assert.Equal(0, closeCount);
gate.CompleteFrame(() => closeCount++);
gate.CompleteFrame(() => closeCount++);
Assert.False(gate.IsRequested);
Assert.Equal(1, closeCount);
}
[Fact]
public void ParseMockup_doesNotDefaultToSinglePanelLayout()
{
var opts = StudioOptions.Parse(new[] { @"C:\fake-dats", "--mockup" });
Assert.True(opts.Mockup);
Assert.Null(opts.LayoutId);
Assert.Null(opts.DumpSlug);
Assert.Null(opts.MarkupPath);
}
[Fact]
public void ParseCapabilitySmokeOptions()
{
var opts = StudioOptions.Parse(
[
@"C:\fake-dats",
"--mockup",
"--capability-report",
"report.json",
"--audio-smoke",
]);
Assert.Equal("report.json", opts.CapabilityReportPath);
Assert.True(opts.AudioSmoke);
}
[Fact]
public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin()
{
var root = new UiPanel
{
Left = 500f,
Top = 138f,
Width = 300f,
Height = 362f,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
};
StudioWindow.NormalizeSinglePanelRoot(root);
Assert.Equal(0f, root.Left);
Assert.Equal(0f, root.Top);
Assert.Equal(300f, root.Width);
Assert.Equal(362f, root.Height);
Assert.Equal(AnchorEdges.None, root.Anchors);
}
}

View file

@ -1,4 +1,3 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;

View file

@ -1,4 +1,3 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using System.Numerics;

View file

@ -296,8 +296,7 @@ public sealed class RuntimeEntityOwnershipTests
string? ns = type.Namespace;
return ns?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|| ns?.StartsWith("AcDream.UI", StringComparison.Ordinal) == true
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|| ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true;
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true;
}
private static bool IsGuidDictionary(FieldInfo field) =>

View file

@ -151,8 +151,7 @@ public sealed class RuntimeEntityDirectoryTests
Assert.DoesNotContain(exposed, type =>
type.Namespace?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|| type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|| type.Namespace?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true);
|| type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
}
[Fact]