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:
parent
db4426d5ef
commit
844cf092a1
48 changed files with 227 additions and 6201 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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\*.*">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 parent–child 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.
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 0–5).
|
||||
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 0–5) ──────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <path></c>: path to a KSML markup file (Task 6, unsupported for now).</para>
|
||||
/// <para><c>--dump <slug></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 <path></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 <path></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><solutionRoot>/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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
94
src/AcDream.App/UI/Layout/SampleData.cs
Normal file
94
src/AcDream.App/UI/Layout/SampleData.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 > 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using System.Numerics;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue