diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 516f9f12..e9b658d1 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -191,8 +191,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
-| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
-| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; paperdoll mount wiring | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
+| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
+| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
| AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` |
| ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
index 0a1e41dd..ac50d0e7 100644
--- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
+++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
@@ -16,7 +16,7 @@ port, or resource-lifetime redesign.
- [x] A — freeze the complete render graph, correct the architecture SSOT, and
introduce data-only frame contracts plus deterministic order/failure tests.
-- [ ] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf
+- [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf
owners while `GameWindow` still orders the frame.
- [ ] C — extract frame-resource begin/upload/publication and live display,
and weather/display foundations that do not depend on camera/root
@@ -350,6 +350,21 @@ corrected-diff reviews are clean; production `GameWindow.OnRender` is unchanged.
DebugVM providers and cross-domain world-lifecycle sampling at composition;
- keep call sites in their current `GameWindow.OnRender` positions.
+Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to
+`PaperdollFramePresenter`; retained surfaces are narrow and optional, while
+live identity and DAT pose application resolve through focused adapters.
+`DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input
+actions without taking shutdown ownership of the borrowed bootstrap.
+`WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and
+`RenderFrameDiagnosticsController` own the former render probes, debug facts,
+title/resource cadence, and public snapshot. The production handoff now reports
+the observed login/world and screenshot outcomes, preserves the original shared
+TERRAIN→FRAME diagnostic transaction, and keeps the devtools-off scan path
+allocation-free. `GameWindow.cs` is 6,270 raw lines, 60.1% below the 15,723-line
+campaign baseline. Sixty-two focused tests, the Release build, and the complete
+suite pass (7,255 passed / 5 skipped). Retail, architecture, and adversarial
+corrected-diff reviews are clean.
+
### C — frame resources and live preparation
- extract ordered per-resource `BeginFrame` calls, clear/state establishment,
diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
index e1f4fcc5..8a050504 100644
--- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
+++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
@@ -76,10 +76,10 @@ internal sealed class FrameScreenshotController
&& status.State == CaptureState.Complete;
/// Captures at most one queued image on the current GL thread.
- public void CapturePending()
+ public bool CapturePending()
{
if (_pending.Count == 0)
- return;
+ return false;
string name = _pending.Dequeue();
try
@@ -104,12 +104,14 @@ internal sealed class FrameScreenshotController
_status[name] = new CaptureStatus(CaptureState.Complete);
_log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}");
+ return true;
}
catch (Exception exception)
{
string message = $"screenshot '{name}' failed: {exception.Message}";
_status[name] = new CaptureStatus(CaptureState.Failed, message);
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
+ return false;
}
}
diff --git a/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs b/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs
new file mode 100644
index 00000000..e111c26b
--- /dev/null
+++ b/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs
@@ -0,0 +1,79 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Rendering;
+
+/// Same-frame render facts consumed by the developer DebugVM.
+internal readonly record struct DebugVmRenderFacts(
+ int VisibleLandblocks,
+ int TotalLandblocks,
+ float NearestObjectDistance,
+ string NearestObjectLabel,
+ bool Colliding)
+{
+ public static DebugVmRenderFacts Initial => new(
+ VisibleLandblocks: 0,
+ TotalLandblocks: 0,
+ NearestObjectDistance: float.PositiveInfinity,
+ NearestObjectLabel: "-",
+ Colliding: false);
+}
+
+internal interface IDebugVmRenderFactsSource
+{
+ DebugVmRenderFacts DebugVmFacts { get; }
+}
+
+///
+/// Owns render-derived DebugVM facts without retaining the physics registry,
+/// camera, player controller, world state, or any other canonical runtime owner.
+/// The pass-local diagnostics owner publishes through this focused query surface.
+///
+internal sealed class DebugVmRenderFactsPublisher : IDebugVmRenderFactsSource
+{
+ internal const float PlayerCollisionRadius = 0.48f;
+ internal const float ContactThreshold = 0.05f;
+
+ public DebugVmRenderFacts DebugVmFacts { get; private set; } =
+ DebugVmRenderFacts.Initial;
+
+ ///
+ /// Publishes the facts immediately before private presentation. A disabled
+ /// consumer leaves the prior snapshot untouched and does not enumerate the
+ /// borrowed shadow-object sequence.
+ ///
+ public void PublishDebugVmFacts(
+ bool consumerActive,
+ int visibleLandblocks,
+ int totalLandblocks,
+ Vector3 nearestOrigin,
+ IEnumerable? shadowObjects)
+ {
+ if (!consumerActive)
+ return;
+ ArgumentNullException.ThrowIfNull(shadowObjects);
+
+ float nearestDistance = float.PositiveInfinity;
+ string nearestLabel = "-";
+ foreach (ShadowEntry shadow in shadowObjects)
+ {
+ float dx = shadow.Position.X - nearestOrigin.X;
+ float dy = shadow.Position.Y - nearestOrigin.Y;
+ float distance = MathF.Sqrt(dx * dx + dy * dy)
+ - shadow.Radius
+ - PlayerCollisionRadius;
+ if (distance < nearestDistance)
+ {
+ nearestDistance = distance;
+ nearestLabel = $"0x{shadow.EntityId:X8} {shadow.CollisionType}";
+ }
+ }
+
+ DebugVmFacts = new DebugVmRenderFacts(
+ visibleLandblocks,
+ totalLandblocks,
+ nearestDistance < 0f ? 0f : nearestDistance,
+ nearestLabel,
+ nearestDistance < ContactThreshold);
+ }
+}
diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs
new file mode 100644
index 00000000..d1bb4143
--- /dev/null
+++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs
@@ -0,0 +1,387 @@
+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 IDevToolsFrameBackend
+{
+ void BeginFrame(float deltaSeconds);
+
+ 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);
+}
+
+/// Concrete ImGui backend for the developer presentation owner.
+internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
+{
+ private readonly ImGuiBootstrapper _bootstrap;
+ private readonly ImGuiPanelHost _panels;
+
+ public ImGuiDevToolsFrameBackend(
+ ImGuiBootstrapper 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 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);
+ }
+
+}
+
+/// Mutable late-composition seam for player-mode menu operations.
+internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
+{
+ private readonly CameraController _camera;
+ private PlayerModeController? _playerMode;
+
+ public DevToolsCameraMenuOperations(CameraController camera)
+ {
+ _camera = camera ?? throw new ArgumentNullException(nameof(camera));
+ }
+
+ public bool IsFlyMode => _camera.IsFlyMode;
+
+ public bool CollideCamera
+ {
+ get => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
+ set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
+ }
+
+ public void Bind(PlayerModeController playerMode)
+ {
+ _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
+ }
+
+ public void ToggleFlyOrChase() => _playerMode?.ToggleFlyOrChase();
+}
+
+/// Resolves the reconnect-safe command bus at draw time.
+internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
+{
+ private LiveSessionController? _session;
+
+ public ICommandBus Current =>
+ _session?.Commands ?? NullCommandBus.Instance;
+
+ public void Bind(LiveSessionController session)
+ {
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ }
+}
+
+/// Typed panel operations used by menu and input presentation.
+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)),
+ };
+}
+
+///
+/// Owns the optional ImGui developer frame, menu policy, panel actions, and
+/// reusable default layout. Retained gameplay UI remains a separate earlier
+/// presentation phase.
+///
+internal sealed class DevToolsFramePresenter
+{
+ private readonly IDevToolsFrameBackend _backend;
+ private readonly IDevToolsCameraMenuOperations _camera;
+ private readonly IDevToolsCommandBusSource _commands;
+ private readonly IDevToolsPanelSet _panels;
+ private readonly AcDream.App.Diagnostics.FrameProfiler _profiler;
+
+ 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) => _backend.BeginFrame(deltaSeconds);
+
+ public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
+ {
+ 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();
+ }
+
+ 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);
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 87a119c0..654acb7d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -78,30 +78,6 @@ public sealed class GameWindow : IDisposable
// scene. They are no longer used for any debug overlay.
private TextRenderer? _textRenderer;
private BitmapFont? _debugFont;
- // Last-computed perf values so the HUD always has something to show even
- // though the title-bar FPS is only updated every 0.5s.
- private double _lastFps = 60.0;
- private double _lastFrameMs = 16.7;
-
- // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel
- // VM closures. Computed once per render pass alongside the frustum
- // walk + nearest-object scan; the VM closures just read the cached
- // values. Skipped when DevTools are off (zero cost).
- private int _lastVisibleLandblocks;
- private int _lastTotalLandblocks;
- private float _lastNearestObjDist = float.PositiveInfinity;
- private string _lastNearestObjLabel = "-";
- private bool _lastColliding;
-
- // Phase N.5b: CPU timing for [TERRAIN-DIAG] under ACDREAM_WB_DIAG=1
- // (parallel diagnostic to [WB-DIAG] in WbDrawDispatcher — same env var
- // gate so flipping one switch turns on both dispatcher rollups). Mirrors
- // the rolling-256-sample buffer pattern from WbDrawDispatcher.
- private readonly System.Diagnostics.Stopwatch _terrainCpuStopwatch = new();
- private readonly long[] _terrainCpuSamples = new long[256]; // microseconds
- private int _terrainCpuSampleCursor;
- private long _terrainLastDiagTick;
-
// [FRAME-DIAG]: retain only the render-thread entity upload distribution.
// Landblock publication timing/counts live with the focused publishers and
// are read from their typed snapshots when diagnostics flush.
@@ -109,13 +85,22 @@ public sealed class GameWindow : IDisposable
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
System.StringComparison.Ordinal);
- private readonly long[] _entityUploadSamples = new long[256];
- private int _entityUploadSampleCursor;
+ private readonly AcDream.App.Rendering.RollingTimingSampleWindow
+ _entityUploadTiming = new(256);
+ private long _frameDiagLastPublicationMilliseconds;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
+ private readonly AcDream.App.Rendering.IRenderFrameDiagnosticLog
+ _renderDiagnosticLog =
+ new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog();
+ private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher
+ _debugVmRenderFacts = new();
+ private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics;
+ private AcDream.App.Rendering.RenderFrameDiagnosticsController?
+ _renderFrameDiagnostics;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
@@ -249,9 +234,6 @@ public sealed class GameWindow : IDisposable
private readonly HashSet _outdoorSceneParticleEntityIds = new();
private readonly HashSet _visibleSceneParticleEntityIds = new();
private readonly HashSet _noSceneParticleEntityIds = new();
- private string? _lastRenderSignature;
- private int _renderSignatureFrame;
- private int _renderSignatureStableFrames;
///
/// Phase 6.4: per-entity animation playback state for entities whose
@@ -440,8 +422,10 @@ public sealed class GameWindow : IDisposable
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
- private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
- private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
+ private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend;
+ private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
+ private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu;
+ private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
@@ -462,12 +446,12 @@ public sealed class GameWindow : IDisposable
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
- private bool _paperdollDollDirty = true;
+ private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter;
// 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. Lives for as long as
- // _panelHost does. Self-subscribes to CombatState in its ctor, so
- // disposing isn't required (panel host holds the only ref).
+ // 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.
private bool DevToolsEnabled => _options.DevTools;
@@ -833,6 +817,9 @@ public sealed class GameWindow : IDisposable
_gl = GL.GetApi(_window!);
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
+ _worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
+ new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
+ _renderDiagnosticLog);
_input = _window!.CreateInput();
// Phase K.1b — every keyboard/mouse handler routes through the
@@ -877,8 +864,7 @@ public sealed class GameWindow : IDisposable
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
// surviving direct-mouse handler. Suppresses RMB orbit /
// FlyCamera look while ImGui has the mouse focus.
- if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
- || (_uiHost?.Root.WantsMouse ?? false))
+ if (_inputCapture.WantCaptureMouse)
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
@@ -1110,10 +1096,12 @@ public sealed class GameWindow : IDisposable
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
if (DevToolsEnabled)
{
+ AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
try
{
- _imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
- _panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
+ imguiBootstrap =
+ new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
+ var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
@@ -1121,8 +1109,9 @@ public sealed class GameWindow : IDisposable
// bars surface only after the first PlayerDescription has
// populated LocalPlayer (Issue #5).
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
- _vitalsPanel = new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
- _panelHost.Register(_vitalsPanel);
+ var vitalsPanel =
+ new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
+ panelHost.Register(vitalsPanel);
// ChatPanel: reads the tail of the shared ChatLog. No GUID
// dependency — works pre-login (empty) and post-login (live
@@ -1133,11 +1122,12 @@ public sealed class GameWindow : IDisposable
// delegates, no panel-vs-renderer-vs-state coupling.
var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat)
{
- FpsProvider = () => (float)_lastFps,
+ FpsProvider = () =>
+ (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
PositionProvider = () => GetDebugPlayerPosition(),
};
- _chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
- _panelHost.Register(_chatPanel);
+ var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
+ panelHost.Register(chatPanel);
// Phase I.2: DebugPanel — replaces the deleted custom
// DebugOverlay (six floating panels + hint bar + toast).
@@ -1156,12 +1146,17 @@ public sealed class GameWindow : IDisposable
getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f,
getEntityCount: () => _worldState.Entities.Count,
getAnimatedCount: () => _animatedEntities.Count,
- getLandblocksVisible: () => _lastVisibleLandblocks,
- getLandblocksTotal: () => _lastTotalLandblocks,
+ getLandblocksVisible: () =>
+ _debugVmRenderFacts.DebugVmFacts.VisibleLandblocks,
+ getLandblocksTotal: () =>
+ _debugVmRenderFacts.DebugVmFacts.TotalLandblocks,
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
- getNearestObjDist: () => _lastNearestObjDist,
- getNearestObjLabel: () => _lastNearestObjLabel,
- getColliding: () => _lastColliding,
+ getNearestObjDist: () =>
+ _debugVmRenderFacts.DebugVmFacts.NearestObjectDistance,
+ getNearestObjLabel: () =>
+ _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
+ getColliding: () =>
+ _debugVmRenderFacts.DebugVmFacts.Colliding,
getDebugWireframes: () => _debugCollisionVisible,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
@@ -1174,8 +1169,10 @@ public sealed class GameWindow : IDisposable
getActiveLights: () => Lighting.ActiveCount,
getRegisteredLights: () => Lighting.RegisteredCount,
getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0,
- getFps: () => (float)_lastFps,
- getFrameMs: () => (float)_lastFrameMs,
+ getFps: () =>
+ (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
+ getFrameMs: () =>
+ (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
combat: Combat);
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
_debugVm.CycleWeather = CycleWeather;
@@ -1188,8 +1185,9 @@ public sealed class GameWindow : IDisposable
_debugVm.ToggleFlyMode = () =>
_playerModeController?.ToggleFlyOrChase();
_combatFeedback.ViewModel = _debugVm;
- _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
- _panelHost.Register(_debugPanel);
+ var debugPanel =
+ new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
+ panelHost.Register(debugPanel);
// Phase K.3 — Settings panel. SettingsVM owns a draft
// copy of the active KeyBindings. Save replaces the
@@ -1197,6 +1195,7 @@ public sealed class GameWindow : IDisposable
// the draft. Construction is null-safe vs. the
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
+ AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null;
if (_inputDispatcher is not null && _settingsStore is not null)
{
// L.0 — SettingsStore + persisted-settings load + apply
@@ -1336,8 +1335,9 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"settings: character save failed: {ex.Message}");
}
});
- _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
- _panelHost.Register(_settingsPanel);
+ settingsPanel =
+ new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
+ panelHost.Register(settingsPanel);
}
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
@@ -1348,22 +1348,46 @@ public sealed class GameWindow : IDisposable
// dragged positions persist. Without this, the first-run
// experience stacks every panel at (0,0) which looks
// broken.
- ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver);
+ _devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend(
+ imguiBootstrap,
+ panelHost);
+ _devToolsCameraMenu =
+ new AcDream.App.Rendering.DevToolsCameraMenuOperations(
+ _cameraController!);
+ _devToolsCommandBus =
+ new AcDream.App.Rendering.DevToolsCommandBusSource();
+ _devToolsFramePresenter =
+ new AcDream.App.Rendering.DevToolsFramePresenter(
+ _devToolsBackend,
+ _devToolsCameraMenu,
+ _devToolsCommandBus,
+ _frameProfiler,
+ new AcDream.App.Rendering.DevToolsPanelSet(
+ vitalsPanel,
+ chatPanel,
+ debugPanel,
+ _debugVm,
+ settingsPanel));
+ _devToolsFramePresenter.ResetLayout(
+ _window!.Size.X,
+ _window.Size.Y,
+ AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver);
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
- _imguiBootstrap?.Dispose();
- _imguiBootstrap = null;
- _panelHost = null;
+ // The focused backend borrows the bootstrap during normal
+ // operation. Construction failure still owns this local and
+ // must release it before abandoning the optional frontend.
+ imguiBootstrap?.Dispose();
+ _devToolsBackend = null;
+ _devToolsFramePresenter = null;
+ _devToolsCameraMenu = null;
+ _devToolsCommandBus = null;
_vitalsVm = null;
- _vitalsPanel = null;
_combatFeedback.ViewModel = null;
_debugVm = null;
- _debugPanel = null;
- _chatPanel = null;
_settingsVm = null;
- _settingsPanel = null;
}
}
@@ -1790,7 +1814,7 @@ public sealed class GameWindow : IDisposable
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Fps: new AcDream.App.UI.FpsRuntimeBindings(
- () => _lastFps,
+ () => _renderFrameDiagnostics?.Snapshot.Fps ?? 60.0,
// Retail displays either the user degrade bias or the live
// distance-driven multiplier. acdream does not yet implement
// adaptive distance degradation (tracked by TS-15), so its
@@ -2259,11 +2283,27 @@ public sealed class GameWindow : IDisposable
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport
+ && _retailUiRuntime.InventoryFrame is { } paperdollInventoryFrame
&& _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!);
paperdollViewport.Renderer = _paperdollViewportRenderer;
+ _paperdollFramePresenter =
+ new AcDream.App.Rendering.PaperdollFramePresenter(
+ _paperdollViewportRenderer,
+ new AcDream.App.Rendering.RetailPaperdollFrameView(
+ paperdollViewport,
+ new AcDream.App.Rendering.PaperdollInventoryVisibility(
+ paperdollInventoryFrame)),
+ new AcDream.App.Rendering.RetailPaperdollDollFactory(
+ new AcDream.App.Rendering.LivePaperdollEntityLookup(
+ _liveEntities),
+ _localPlayerIdentity,
+ new AcDream.App.Rendering.RetailPaperdollPoseApplicator(
+ _dats!,
+ _animLoader!,
+ _datLock)));
}
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
@@ -2357,6 +2397,33 @@ public sealed class GameWindow : IDisposable
_wbMeshAdapter,
_retailAlphaQueue);
+ AcDream.App.Rendering.IRenderFrameResourceDiagnosticsSource? resourceDiagnostics =
+ _options.UiProbeDump
+ ? new AcDream.App.Rendering.RuntimeRenderFrameResourceDiagnosticsSource(
+ _particleSystem,
+ _particleSink,
+ _wbDrawDispatcher,
+ _envCellRenderer,
+ _particleRenderer,
+ _uiHost?.TextRenderer,
+ _portalDepthMask,
+ _clipFrame,
+ _terrain,
+ _sceneLightingUbo,
+ _wbMeshAdapter,
+ _textureCache)
+ : null;
+ _renderFrameDiagnostics =
+ new AcDream.App.Rendering.RenderFrameDiagnosticsController(
+ new AcDream.App.Rendering.RuntimeRenderFrameTitleFactsSource(
+ _worldState,
+ _animatedEntities,
+ WorldTime),
+ new AcDream.App.Rendering.SilkRenderFrameTitleSink(_window!),
+ _renderDiagnosticLog,
+ _options.UiProbeDump,
+ resourceDiagnostics);
+
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
// LoadAndApplyPersistedSettings() above, absorbing all env-var overrides.
@@ -2576,7 +2643,7 @@ public sealed class GameWindow : IDisposable
_liveEntityHydration.AppearanceApplied += guid =>
{
if (guid == _playerServerGuid)
- _paperdollDollDirty = true;
+ _paperdollFramePresenter?.MarkDirty();
};
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
@@ -2730,6 +2797,8 @@ public sealed class GameWindow : IDisposable
_movementTruthDiagnostics,
_localPlayerSkills,
_viewportAspect);
+ _devToolsCameraMenu?.Bind(_playerModeController);
+ _devToolsCommandBus?.Bind(_liveSessionController);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
_liveSessionController,
@@ -2934,7 +3003,7 @@ public sealed class GameWindow : IDisposable
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
- _paperdollDollDirty = true;
+ _paperdollFramePresenter?.MarkDirty();
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic
@@ -3154,115 +3223,6 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
- ///
- /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
- /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
- /// part overrides into a dedicated entity posed at the scene origin
- /// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the
- /// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the
- /// player entity isn't available yet.
- ///
- private bool RefreshPaperdollDoll()
- {
- if (_paperdollViewportRenderer is null) return false;
- if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0)
- {
- _paperdollViewportRenderer.SetDoll(null);
- return false; // player not ready — retry next frame
- }
-
- uint? basePal = null;
- List<(uint, byte, byte)>? subs = null;
- if (pe.PaletteOverride is { } po)
- {
- basePal = po.BasePaletteId;
- subs = new List<(uint, byte, byte)>();
- foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length));
- }
-
- List<(byte, uint)>? parts = null;
- if (pe.PartOverrides.Count > 0)
- {
- parts = new List<(byte, uint)>(pe.PartOverrides.Count);
- foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
- }
-
- var meshRefsCopy = new List(pe.MeshRefs); // dressed parts (player's live frame)
- var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
- pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
- ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
- _paperdollViewportRenderer.SetDoll(doll);
- return true;
- }
-
- ///
- /// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
- /// gmPaperDollUI ctor (decomp 174243) sets m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7),
- /// which is DBCache::GetDIDFromEnumStatic (decomp 20380) = master[MasterMapId][0x10000005]
- /// → submap 0x25000009 → key 7. (Icon effects use keys 1-6 of the same submap; key 7 is the
- /// paperdoll pose.) Per-race UpdateForRace override deferred — the ctor default applies to all
- /// body-types for now. Returns 0 if the chain can't resolve.
- ///
- private uint ResolvePaperdollPoseDid()
- {
- var dats = _dats;
- if (dats is null) return 0u;
- uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
- if (masterDid == 0) return 0u;
- if (!dats.Portal.TryGet(masterDid, out var master)) return 0u;
- // DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
- // GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
- if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
- if (!dats.Portal.TryGet(subDid, out var sub)) return 0u;
- return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
- }
-
- ///
- /// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
- /// retail set_sequence_animation(doll, m_didAnimation, …). Keeps each cloned part's GfxObjId +
- /// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
- /// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
- /// animation tick (, GameWindow.cs ~9999). Static (the pose is fixed). If the
- /// DID does not resolve to an Animation, the doll keeps its cloned pose (no regression) — the log
- /// line surfaces what resolved so the pose is verified, not guessed.
- ///
- private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
- {
- var dats = _dats;
- if (dats is null) return;
- // Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
- _animatedEntities.Remove(doll.Id);
- // poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
- // did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
- // garbage frame count → OOM).
- uint poseDid = ResolvePaperdollPoseDid();
- if ((poseDid >> 24) != 0x03u) return;
- var anim = _animLoader?.LoadAnimation(poseDid);
- var setup = dats.Get(setupId);
- if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
-
- // Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
- // RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
- // frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
- // arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
- int frameIdx = anim.PartFrames.Count - 1;
- var frame = anim.PartFrames[frameIdx];
- var src = doll.MeshRefs;
- var reposed = new List(src.Count);
- for (int i = 0; i < src.Count; i++)
- {
- var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
- System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
- System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
- if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
- var transform = System.Numerics.Matrix4x4.CreateScale(scale)
- * System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
- * System.Numerics.Matrix4x4.CreateTranslation(origin);
- reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
- }
- doll.MeshRefs = reposed;
- }
-
private bool TryGetLoginWorldCell(out uint cell)
{
cell = 0;
@@ -3363,10 +3323,6 @@ public sealed class GameWindow : IDisposable
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
entity.Id;
- // #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
- private string? _lastOutStagePtSig;
- private readonly HashSet _outStageUnmatchedScratch = new();
- private readonly HashSet _outStageMatchedScratch = new();
private static System.Numerics.Vector3 SkyPesAnchor(
AcDream.Core.World.SkyObjectData obj,
@@ -3478,9 +3434,6 @@ public sealed class GameWindow : IDisposable
// Performance overlay state — updated every ~0.5s and written to the
// window title so there's zero rendering cost (no font/overlay needed).
- private double _perfAccum;
- private int _perfFrameCount;
-
private void OnRender(double deltaSeconds)
{
_gpuFrameFlights!.BeginFrame();
@@ -3559,8 +3512,8 @@ public sealed class GameWindow : IDisposable
// §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the
// state entering the world passes differs from the previous frame.
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled)
- EmitGlStateTripwireIfChanged();
+ _worldRenderDiagnostics?.EmitGlStateTripwireIfChanged(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
@@ -3589,14 +3542,15 @@ public sealed class GameWindow : IDisposable
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
}
if (_frameDiag)
- FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor,
+ {
+ _entityUploadTiming.PushStopwatchTicks(
System.Diagnostics.Stopwatch.GetTimestamp() - fdE0);
+ }
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
- if (DevToolsEnabled && _imguiBootstrap is not null)
- _imguiBootstrap.BeginFrame((float)deltaSeconds);
+ _devToolsFramePresenter?.BeginFrame((float)deltaSeconds);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
@@ -3624,6 +3578,7 @@ public sealed class GameWindow : IDisposable
int visibleLandblocks = 0;
int totalLandblocks = 0;
+ bool normalWorldDrawn = false;
_retailSelectionScene?.BeginFrame();
if (_cameraController is not null && !portalViewportVisible)
@@ -4101,17 +4056,17 @@ public sealed class GameWindow : IDisposable
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup.
EnableClipDistances();
- _terrainCpuStopwatch.Restart();
+ _worldRenderDiagnostics?.BeginTerrainDraw();
sigTerrainDrawn = true;
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
- _terrainCpuStopwatch.Stop();
- _terrainCpuSamples[_terrainCpuSampleCursor] =
- (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);
- _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
- MaybeFlushTerrainDiag();
+ PublishTerrainDrawDiagnostics();
}
+ // Reaching the retail normal-world decision below proves this is
+ // neither a portal replacement nor the sky-only login-wait path.
+ normalWorldDrawn = true;
+
// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
// INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global
// entity pass + global shell pass are NOT issued — visibility IS the cull, so the
@@ -4225,13 +4180,18 @@ public sealed class GameWindow : IDisposable
DrawDynamicsParticles = survivors =>
DrawRetailPViewDynamicsParticles(survivors, camera, camPos),
EmitDiagnostics = result =>
- EmitRetailPViewDiagnostics(
+ _worldRenderDiagnostics?.EmitRetailPViewDiagnostics(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled,
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled,
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled,
result,
clipRoot,
viewerCellId,
playerCellId,
camPos,
- playerViewPos),
+ playerViewPos,
+ camera.View,
+ _cellVisibility.LastCameraCellResolution),
});
pvFrame = pviewResult.PortalFrame;
clipAssembly = pviewResult.ClipAssembly;
@@ -4251,10 +4211,9 @@ public sealed class GameWindow : IDisposable
// the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player
// identical to cm; this answers whether the inputs differ sub-cm (jitter) or are
// byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled.
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null)
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled
+ && pvFrame is not null)
{
- var vp = envCellViewProj;
- char pvOutRoot = clipRoot.IsOutdoorNode ? 'Y' : 'n';
// 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood);
// player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics
// body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood
@@ -4266,11 +4225,16 @@ public sealed class GameWindow : IDisposable
// terrain triangle backface-culls and the view punches through the
// world to the clear color. Pin or refute eye-under-terrain.
float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y);
- string pvTerr = pvTerrZ is { } tz
- ? System.FormattableString.Invariant($"terrZ={tz:F3} eyeAbove={camPos.Z - tz:F3}")
- : "terrZ=n/a eyeAbove=n/a";
- Console.WriteLine(System.FormattableString.Invariant(
- $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) rawPlayer=({pvRawPlayer.X:F6},{pvRawPlayer.Y:F6},{pvRawPlayer.Z:F6}) yaw={pvYaw:F8} {pvTerr} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]"));
+ _worldRenderDiagnostics?.EmitPViewInput(
+ enabled: true,
+ pvFrame,
+ envCellViewProj,
+ clipRoot.IsOutdoorNode,
+ camPos,
+ playerViewPos,
+ pvRawPlayer,
+ pvYaw,
+ pvTerrZ);
}
sigPvFrame = pviewResult.PortalFrame;
@@ -4391,7 +4355,8 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
}
- EmitRenderSignatureIfChanged(
+ _worldRenderDiagnostics?.EmitRenderSignatureIfChanged(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled,
renderBranch,
clipRoot,
viewerRoot,
@@ -4513,34 +4478,16 @@ public sealed class GameWindow : IDisposable
// the hot path of an offline render.
if (_debugVm is not null)
{
- _lastVisibleLandblocks = visibleLandblocks;
- _lastTotalLandblocks = totalLandblocks;
-
- // Compute fly/orbit-mode camera position for the nearest-
- // object scan when not in player mode.
- System.Numerics.Vector3 nearOrigin;
- if (_playerMode && _playerController is not null)
- nearOrigin = _playerController.Position;
- else
- nearOrigin = camPos;
-
- const float playerRadius = 0.48f;
- float bestDist = float.PositiveInfinity;
- string bestLabel = "-";
- foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
- {
- float dx = obj.Position.X - nearOrigin.X;
- float dy = obj.Position.Y - nearOrigin.Y;
- float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
- if (d < bestDist)
- {
- bestDist = d;
- bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
- }
- }
- _lastColliding = bestDist < 0.05f;
- _lastNearestObjDist = bestDist < 0f ? 0f : bestDist;
- _lastNearestObjLabel = bestLabel;
+ System.Numerics.Vector3 debugNearestOrigin =
+ _playerMode && _playerController is not null
+ ? _playerController.Position
+ : camPos;
+ _debugVmRenderFacts.PublishDebugVmFacts(
+ consumerActive: true,
+ visibleLandblocks,
+ totalLandblocks,
+ debugNearestOrigin,
+ _physicsEngine.ShadowObjects.AllEntriesForDebug());
}
// K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin —
@@ -4571,15 +4518,7 @@ public sealed class GameWindow : IDisposable
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
- if (_options.RetailUi && _paperdollViewportRenderer is not null
- && _retailUiRuntime?.PaperdollViewportWidget is { Visible: true } dollWidget
- && _retailUiRuntime.InventoryFrame is { Visible: true })
- {
- if (_paperdollDollDirty && RefreshPaperdollDoll())
- _paperdollDollDirty = false;
- dollWidget.TextureHandle = _paperdollViewportRenderer.Render(
- (int)dollWidget.Width, (int)dollWidget.Height);
- }
+ _paperdollFramePresenter?.Render();
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
@@ -4593,190 +4532,31 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
- // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
- // so ImGui composites on top. ImGuiController save/restores the
- // GL state it touches (blend, scissor, VAO, shader, texture); any
- // state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
- // today) would need manual protection.
- if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
- {
- // Phase I.3 — prefer the live command bus when a live session
- // is up so panel-emitted SendChatCmd actually flows server-ward.
- // Fall back to NullCommandBus for offline / pre-connect renders.
- AcDream.UI.Abstractions.ICommandBus bus =
- _liveSessionController?.Commands
- ?? AcDream.UI.Abstractions.NullCommandBus.Instance;
- var ctx = new AcDream.UI.Abstractions.PanelContext(
- (float)deltaSeconds,
- bus);
-
- // Phase K.3 — top-of-screen menu bar. Provides discoverable
- // entries for users who don't memorize the F-keys (View →
- // Settings / Vitals / Chat / Debug). Uses ImGuiNET directly
- // here because the panel host doesn't own a menu-bar
- // surface; the abstraction (BeginMainMenuBar / BeginMenu /
- // MenuItem) exists for backend portability + tests but only
- // gets exercised here once per frame.
- if (ImGuiNET.ImGui.BeginMainMenuBar())
- {
- if (ImGuiNET.ImGui.BeginMenu("View"))
- {
- if (_settingsPanel is not null
- && ImGuiNET.ImGui.MenuItem("Settings", "F11"))
- _settingsPanel.IsVisible = !_settingsPanel.IsVisible;
- if (_vitalsPanel is not null
- && ImGuiNET.ImGui.MenuItem("Vitals"))
- _vitalsPanel.IsVisible = !_vitalsPanel.IsVisible;
- if (_chatPanel is not null
- && ImGuiNET.ImGui.MenuItem("Chat"))
- _chatPanel.IsVisible = !_chatPanel.IsVisible;
- if (_debugPanel is not null
- && ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1"))
- _debugPanel.IsVisible = !_debugPanel.IsVisible;
- ImGuiNET.ImGui.Separator();
- // L.0 Display tab: a manual reset for users whose
- // imgui.ini has saved a panel position that's now
- // off-screen (after a window shrink, monitor swap,
- // or a malformed save). Force-resets every panel
- // to its default landing position. The same code
- // path runs automatically on FramebufferResize.
- if (ImGuiNET.ImGui.MenuItem("Reset window layout"))
- ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
- ImGuiNET.ImGui.EndMenu();
- }
- // K-fix2 (2026-04-26): Camera submenu — discoverable
- // free-fly toggle for users who don't know the
- // Ctrl+Shift+F shortcut or the Debug-panel button.
- if (ImGuiNET.ImGui.BeginMenu("Camera"))
- {
- if (_cameraController is not null)
- {
- string flyLabel = _cameraController.IsFlyMode
- ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
- if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
- _playerModeController?.ToggleFlyOrChase();
- }
- // A8.F: spring-arm camera collision (live A/B toggle).
- if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "",
- AcDream.Core.Rendering.CameraDiagnostics.CollideCamera))
- AcDream.Core.Rendering.CameraDiagnostics.CollideCamera =
- !AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
- ImGuiNET.ImGui.EndMenu();
- }
- ImGuiNET.ImGui.EndMainMenuBar();
- }
-
- _panelHost.RenderAll(ctx);
- using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
- {
- _imguiBootstrap.Render();
- }
- }
+ // Devtools remain above retained gameplay UI. The focused presenter
+ // owns menu policy, panels, and ImGui draw-data upload.
+ _devToolsFramePresenter?.Render(
+ deltaSeconds,
+ _window!.Size.X,
+ _window.Size.Y);
// Explicit diagnostic captures are requested by the retained-UI
// automation tick above and executed only after every presentation
// layer has drawn into the back buffer on this render thread.
- _frameScreenshots?.CapturePending();
+ bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;
- // Update the window title with performance stats every ~0.5s.
- _perfAccum += deltaSeconds;
- _perfFrameCount++;
- if (_perfAccum >= 0.5)
- {
- double avgFrameTime = _perfAccum / _perfFrameCount * 1000.0;
- double fps = _perfFrameCount / _perfAccum;
- int entityCount = _worldState.Entities.Count;
- int animatedCount = _animatedEntities.Count;
- _lastVisibleLandblocks = visibleLandblocks;
- _lastTotalLandblocks = totalLandblocks;
-
- // Keep the developer diagnostic title independent from retail's
- // /framerate switch. That command owns the in-world SmartBox readout;
- // conflating it with window chrome was the original incomplete port.
- //
- // Also include the in-game calendar/time —
- // matches retail's @timestamp output ("Date: ,
- // PY Time: "). Uses NowTicks (server-synced
- // + wall-clock interpolation) so the user can read the same
- // fields off both acdream and retail and confirm clock parity
- // directly. Drift > 1 hour = real bug.
- double tNow = WorldTime.NowTicks;
- var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow);
- double df = WorldTime.DayFraction;
- _window!.Title =
- $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
- + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
- + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})";
- // Script automation must remain performance-neutral. Only the
- // explicit dump switch requests this allocation-heavy snapshot.
- if (_options.UiProbeDump)
- {
- (int particleSets, long particleBytes) =
- _particleRenderer?.DynamicBufferDiagnostics ?? default;
- var meshManager = _wbMeshAdapter?.MeshManager;
- var meshDiag = meshManager?.Diagnostics ?? default;
- var globalMesh = meshManager?.GlobalBuffer;
- var cpuMeshCache = _wbMeshAdapter?.CpuMeshCacheDiagnostics ?? default;
- GCMemoryInfo gcMemory = GC.GetGCMemoryInfo();
- Console.WriteLine(
- $"[gpu-stream] emit={_particleSystem?.ActiveEmitterCount ?? 0} "
- + $"particles={_particleSystem?.ActiveParticleCount ?? 0} "
- + $"bindings={_particleSink?.ActiveBindingCount ?? 0} "
- + $"wbSets={_wbDrawDispatcher?.DynamicBufferSetCount ?? 0} "
- + $"cellSets={_envCellRenderer?.DynamicBufferSetCount ?? 0} "
- + $"particleSets={particleSets}/{particleBytes}B "
- + $"uiBytes={_uiHost?.TextRenderer.DynamicBufferCapacityBytes ?? 0} "
- + $"portalBytes={_portalDepthMask?.DynamicBufferCapacityBytes ?? 0} "
- + $"clipSets={_clipFrame?.DynamicBufferSetCount ?? 0} "
- + $"terrainBuffers={_terrain?.DynamicIndirectBufferCount ?? 0} "
- + $"lightBuffers={_sceneLightingUbo?.DynamicBufferCount ?? 0} "
- + $"mesh={meshDiag.RenderData}/{meshDiag.AtlasArrays}/{meshDiag.UnusedLru} "
- + $"meshEst={meshDiag.EstimatedBytes} "
- + $"meshUpload={globalMesh?.UploadCount ?? 0}/{globalMesh?.UploadedBytes ?? 0} "
- + $"uploadFrame={_wbMeshAdapter?.LastUploadCount ?? 0}/"
- + $"{_wbMeshAdapter?.LastUploadBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastArrayAllocationBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastPlannedMipmapBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastNewArrayCount ?? 0} "
- + $"bufferFrame={_wbMeshAdapter?.LastBufferUploadBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastBufferAllocationBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastBufferCopyBytes ?? 0}/"
- + $"{_wbMeshAdapter?.LastNewBufferCount ?? 0} "
- + $"staging={_wbMeshAdapter?.StagedUploadBacklog ?? 0}/"
- + $"{_wbMeshAdapter?.StagedUploadBytes ?? 0}/"
- + $"{_wbMeshAdapter?.StagingAtHighWater ?? false}/"
- + $"{_wbMeshAdapter?.LastStaleDiscardCount ?? 0} "
- + $"cpuMesh={cpuMeshCache.Count}/{cpuMeshCache.Bytes} "
- + $"mipmapFrame={_wbMeshAdapter?.LastMipmapArrayCount ?? 0}/"
- + $"{_wbMeshAdapter?.LastMipmapBytes ?? 0} "
- + $"meshCap={globalMesh?.CapacityBytes ?? 0} "
- + $"meshPhys={globalMesh?.PhysicalCapacityBytes ?? 0}/"
- + $"{globalMesh?.IsMigrationInProgress ?? false} "
- + $"gpuTrack={AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes}/"
- + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount}/"
- + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount} "
- + $"managed={GC.GetTotalMemory(false)}/{gcMemory.TotalCommittedBytes} "
- + $"ownedTextures={_textureCache?.OwnedBindlessTextureCount ?? 0}/"
- + $"{_textureCache?.TextureOwnerCount ?? 0} "
- + $"particleTextures={_textureCache?.ActiveParticleTextureCount ?? 0}/"
- + $"{_textureCache?.ParticleTextureOwnerCount ?? 0}/"
- + $"{_textureCache?.CachedParticleTextureCount ?? 0}/"
- + $"{_textureCache?.CachedUnownedParticleTextureCount ?? 0}/"
- + $"{_textureCache?.CachedUnownedParticleTextureBytes ?? 0} "
- + $"compositeCache={_textureCache?.CachedCompositeTextureCount ?? 0}/"
- + $"{_textureCache?.CachedUnownedCompositeCount ?? 0}/"
- + $"{_textureCache?.CachedUnownedCompositeBytes ?? 0} "
- + $"compositeAtlas={_textureCache?.CompositeAtlasCount ?? 0}/"
- + $"{_textureCache?.CompositeAtlasBytes ?? 0} "
- + $"compositeUpload={_textureCache?.CompositeFrameUploadCount ?? 0}/"
- + $"{_textureCache?.CompositeFrameUploadBytes ?? 0}/"
- + $"{_wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0}");
- }
- _lastFps = fps;
- _lastFrameMs = avgFrameTime;
- _perfAccum = 0;
- _perfFrameCount = 0;
- }
+ _renderFrameDiagnostics?.Publish(
+ new AcDream.App.Rendering.RenderFrameInput(
+ deltaSeconds,
+ _window!.Size.X,
+ _window.Size.Y),
+ new AcDream.App.Rendering.RenderFrameOutcome(
+ new AcDream.App.Rendering.WorldRenderFrameOutcome(
+ visibleLandblocks,
+ totalLandblocks,
+ normalWorldDrawn),
+ new AcDream.App.Rendering.PrivatePresentationFrameOutcome(
+ portalViewportVisible,
+ screenshotCaptured)));
}
catch (Exception ex)
@@ -4805,13 +4585,16 @@ public sealed class GameWindow : IDisposable
{
var meshManager = _wbMeshAdapter?.MeshManager;
var mesh = meshManager?.Diagnostics ?? default;
+ AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot render =
+ _renderFrameDiagnostics?.Snapshot
+ ?? AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot.Initial;
GCMemoryInfo memory = GC.GetGCMemoryInfo();
return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot(
LoadedLandblocks: _worldState.LoadedLandblockIds.Count,
WorldEntities: _worldState.Entities.Count,
AnimatedEntities: _animatedEntities.Count,
- VisibleLandblocks: _lastVisibleLandblocks,
- TotalLandblocks: _lastTotalLandblocks,
+ VisibleLandblocks: render.VisibleLandblocks,
+ TotalLandblocks: render.TotalLandblocks,
LiveEntities: _liveEntities?.Count ?? 0,
MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0,
PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0,
@@ -4840,8 +4623,8 @@ public sealed class GameWindow : IDisposable
CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0,
ManagedBytes: GC.GetTotalMemory(forceFullCollection: false),
ManagedCommittedBytes: memory.TotalCommittedBytes,
- Fps: _lastFps,
- FrameMilliseconds: _lastFrameMs,
+ Fps: render.Fps,
+ FrameMilliseconds: render.FrameMilliseconds,
LastFrameProfile: _frameProfiler.LastReport);
}
@@ -4941,225 +4724,6 @@ public sealed class GameWindow : IDisposable
}
}
- private void EmitRenderSignatureIfChanged(
- string branch,
- LoadedCell? clipRoot,
- LoadedCell? viewerRoot,
- LoadedCell? playerRoot,
- uint viewerCellId,
- uint playerCellId,
- bool playerIndoorGate,
- bool cameraInsideCell,
- bool renderSkyGate,
- bool drawSkyThisFrame,
- bool terrainDrawn,
- TerrainClipMode terrainClipMode,
- bool skyDrawn,
- bool depthClear,
- bool outdoorSceneryDrawn,
- bool outdoorPortalDrawn,
- int outdoorRootObjectCount,
- int liveDynamicDrawnCount,
- string sceneParticles,
- PortalVisibilityFrame? pvFrame,
- ClipFrameAssembly? clipAssembly,
- IReadOnlySet? drawableCells,
- AcDream.App.Rendering.InteriorEntityPartition.Result? partition,
- PortalVisibilityFrame? exteriorPvFrame,
- ClipFrameAssembly? exteriorClipAssembly,
- IReadOnlySet? exteriorDrawableCells,
- AcDream.App.Rendering.InteriorEntityPartition.Result? exteriorPartition,
- System.Numerics.Vector3 camPos,
- System.Numerics.Vector3 playerViewPos)
- {
- if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
- return;
-
- _renderSignatureFrame++;
-
- bool eyeInRoot = clipRoot is not null && CellVisibility.PointInCell(camPos, clipRoot);
- bool playerInRoot = clipRoot is not null && CellVisibility.PointInCell(playerViewPos, clipRoot);
-
- var sb = new System.Text.StringBuilder(512);
- sb.Append("branch=").Append(branch);
- sb.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8"));
- sb.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8"));
- sb.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8"));
- sb.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8"));
- sb.Append(" playerCell=0x").Append(playerCellId.ToString("X8"));
- sb.Append(" gate=").Append(playerIndoorGate ? "in" : "out");
- sb.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n');
- sb.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n');
- sb.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n');
- sb.Append(" eye=").Append(FormatVecForSignature(camPos));
- sb.Append(" player=").Append(FormatVecForSignature(playerViewPos));
- sb.Append(" terrain=").Append(terrainClipMode);
- sb.Append('/').Append(terrainDrawn ? "draw" : "skip");
- sb.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n');
- sb.Append(" sky=").Append(skyDrawn ? 'Y' : 'n');
- sb.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
- sb.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
- sb.Append(" sceneParticles=").Append(sceneParticles);
-
- if (clipAssembly is not null)
- {
- sb.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
- sb.Append(" outPolys=").Append(pvFrame?.OutsideView.Polygons.Count ?? 0);
- sb.Append(" outMode=").Append(clipAssembly.TerrainMode);
- }
- else
- {
- sb.Append(" outSlices=0 outPolys=0 outMode=none");
- }
-
- sb.Append(" ids=").Append(FormatIds(pvFrame?.OrderedVisibleCells, preserveOrder: true));
- sb.Append(" draw=").Append(FormatIds(drawableCells, preserveOrder: false));
- sb.Append(" miss=").Append(FormatMissingDrawableCells(pvFrame, drawableCells));
- sb.Append(" obj=").Append(FormatPartitionCounts(partition));
- sb.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
- sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount);
- sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount);
-
- // Diagnosis 2026-06-07: draw-vs-occlude probe for the see-through residual.
- // outRoot=Y means clipRoot is the synthetic outdoor node (eye outdoors). bshell=total/withMesh
- // counts the building ModelId exterior shells queued in partition.Outdoor for this frame — the
- // GfxObj exteriors that SHOULD draw the solid walls. Correlate with ids= (the flooded interior
- // cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is
- // failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells,
- // the outdoor flood is drawing interiors over the exterior.
- sb.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n');
- if (partition is not null)
- {
- int shellTotal = 0, shellMesh = 0;
- foreach (var e in partition.OutdoorStatic)
- if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; }
- sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh);
- }
-
- if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null)
- {
- sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n');
- sb.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0);
- sb.Append(" extIds=").Append(FormatIds(exteriorPvFrame?.OrderedVisibleCells, preserveOrder: true));
- sb.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, preserveOrder: false));
- sb.Append(" extMiss=").Append(FormatMissingDrawableCells(exteriorPvFrame, exteriorDrawableCells));
- sb.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition));
- }
-
- string signature = sb.ToString();
- if (signature == _lastRenderSignature)
- {
- _renderSignatureStableFrames++;
- return;
- }
-
- Console.WriteLine(
- $"[render-sig] frame={_renderSignatureFrame} stable={_renderSignatureStableFrames} {signature}");
- _lastRenderSignature = signature;
- _renderSignatureStableFrames = 0;
- }
-
- private static string FormatVecForSignature(System.Numerics.Vector3 value)
- {
- static float Q(float v) => System.MathF.Round(v * 20f) / 20f;
- return $"({Q(value.X):F2},{Q(value.Y):F2},{Q(value.Z):F2})";
- }
-
- private static string FormatIds(IEnumerable? ids, bool preserveOrder)
- {
- if (ids is null)
- return "[]";
-
- var values = new List();
- foreach (uint id in ids)
- values.Add(id);
-
- if (!preserveOrder)
- values.Sort();
-
- var sb = new System.Text.StringBuilder(96);
- sb.Append('[');
- const int MaxIds = 12;
- for (int i = 0; i < values.Count && i < MaxIds; i++)
- {
- if (i > 0)
- sb.Append(',');
- sb.Append("0x").Append(values[i].ToString("X8"));
- }
- if (values.Count > MaxIds)
- sb.Append(",...");
- sb.Append(']');
- return sb.ToString();
- }
-
- private static string FormatMissingDrawableCells(
- PortalVisibilityFrame? pvFrame,
- IReadOnlySet? drawableCells)
- {
- if (pvFrame is null || drawableCells is null)
- return "[]";
-
- var sb = new System.Text.StringBuilder(96);
- sb.Append('[');
- int written = 0;
- const int MaxCells = 8;
- foreach (uint id in pvFrame.OrderedVisibleCells)
- {
- if (drawableCells.Contains(id))
- continue;
-
- if (written > 0)
- sb.Append(',');
- sb.Append("0x").Append(id.ToString("X8"));
- if (pvFrame.CellViews.TryGetValue(id, out var view))
- {
- sb.Append(":p").Append(view.Polygons.Count);
- if (view.IsEmpty)
- sb.Append(":empty");
- }
- else
- {
- sb.Append(":noView");
- }
-
- written++;
- if (written >= MaxCells)
- {
- sb.Append(",...");
- break;
- }
- }
-
- sb.Append(']');
- return sb.ToString();
- }
-
- private static string FormatPartitionCounts(
- AcDream.App.Rendering.InteriorEntityPartition.Result? partition)
- {
- if (partition is null)
- return "cell=[] out=0 live=0";
-
- var keys = new List(partition.ByCell.Keys);
- keys.Sort();
-
- var sb = new System.Text.StringBuilder(128);
- sb.Append("cell=[");
- const int MaxCells = 10;
- for (int i = 0; i < keys.Count && i < MaxCells; i++)
- {
- uint id = keys[i];
- if (i > 0)
- sb.Append(',');
- sb.Append("0x").Append(id.ToString("X8")).Append(':').Append(partition.ByCell[id].Count);
- }
- if (keys.Count > MaxCells)
- sb.Append(",...");
- sb.Append("] out=").Append(partition.OutdoorStatic.Count)
- .Append(" live=").Append(partition.Dynamics.Count);
- return sb.ToString();
- }
-
private void DrawRetailPViewLandscapeSlice(
AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx,
ICamera camera,
@@ -5174,8 +4738,10 @@ public sealed class GameWindow : IDisposable
{
var slice = sliceCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
- EmitClipRouteScissorProbe(scissor, slice.NdcAabb);
+ _worldRenderDiagnostics?.EmitClipRouteScissorProbe(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled,
+ scissor,
+ slice.NdcAabb);
_clipFrame!.BindTerrainClip(_gl!);
@@ -5190,18 +4756,14 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
EnableClipDistances();
- _terrainCpuStopwatch.Restart();
+ _worldRenderDiagnostics?.BeginTerrainDraw();
_terrain?.Draw(
camera,
frustum,
neverCullLandblockId: playerLb,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb);
- _terrainCpuStopwatch.Stop();
- _terrainCpuSamples[_terrainCpuSampleCursor] =
- (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);
- _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
- MaybeFlushTerrainDiag();
+ PublishTerrainDrawDiagnostics();
// T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes
// are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the
@@ -5280,39 +4842,10 @@ public sealed class GameWindow : IDisposable
// UNMATCHED attached owner ids (the portal-identification handle —
// an emitter whose owner never lands in the set draws nowhere
// indoors). Print-on-change.
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
- && _particleSystem is not null)
- {
- int matched = 0, attached = 0, unattached = 0;
- _outStageUnmatchedScratch.Clear();
- _outStageMatchedScratch.Clear();
- foreach (var (emitter, _) in _particleSystem.EnumerateLive())
- {
- if (emitter.AttachedObjectId == 0) { unattached++; continue; }
- attached++;
- if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId))
- {
- matched++;
- if (_outStageMatchedScratch.Count < 48)
- _outStageMatchedScratch.Add(emitter.AttachedObjectId);
- }
- else if (_outStageUnmatchedScratch.Count < 12)
- _outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
- }
- var unm = new System.Text.StringBuilder(96);
- foreach (uint id in _outStageUnmatchedScratch)
- unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
- var mat = new System.Text.StringBuilder(192);
- foreach (uint id in _outStageMatchedScratch)
- mat.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
- string ptSig = System.FormattableString.Invariant(
- $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]");
- if (ptSig != _lastOutStagePtSig)
- {
- _lastOutStagePtSig = ptSig;
- Console.WriteLine("[outstage-pt] " + ptSig);
- }
- }
+ _worldRenderDiagnostics?.EmitOutStageParticles(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled,
+ _particleSystem,
+ _outdoorSceneParticleEntityIds);
// #132 outdoor sibling: under an OUTDOOR root the merged building
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
@@ -5493,171 +5026,6 @@ public sealed class GameWindow : IDisposable
DisableClipDistances();
}
- // #119-residual [viewer] probe state: print-on-change signature so a
- // stable climb is silent and every flood/root flip emits exactly one line.
- private string? _lastViewerProbeSig;
- // #127: previous frame's admitted cell set — the [viewer-diff] line names
- // exactly which cells entered/left the flood when the signature changes.
- private readonly HashSet _lastViewerFloodCells = new();
- private readonly List _viewerDiffScratch = new();
-
- private void EmitRetailPViewDiagnostics(
- AcDream.App.Rendering.RetailPViewFrameResult result,
- LoadedCell clipRoot,
- uint viewerCellId,
- uint playerCellId,
- System.Numerics.Vector3 camPos,
- System.Numerics.Vector3 playerViewPos)
- {
- // #119-residual: the capture half of the tower capture→replay loop.
- // One line per change of (root, flood, outPolys, playerCell); the eye
- // at mm precision rides every line so the harness can replay the
- // exact production (eye, root) pairs. ACDREAM_PROBE_VIEWER=1.
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled)
- {
- string sig = System.FormattableString.Invariant(
- $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : "")} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}");
- if (sig != _lastViewerProbeSig)
- {
- _lastViewerProbeSig = sig;
- // fwd = camera forward (3rd view column negated) — required to
- // REPLAY a captured frame headlessly: Build's clip results
- // depend on the view-projection, not just the eye.
- var v = _cameraController?.Active.View ?? System.Numerics.Matrix4x4.Identity;
- Console.WriteLine(System.FormattableString.Invariant(
- $"[viewer] {sig} eye=({camPos.X:F3},{camPos.Y:F3},{camPos.Z:F3}) fwd=({-v.M13:F4},{-v.M23:F4},{-v.M33:F4}) viewerCell=0x{viewerCellId:X8}"));
-
- // #127 [viewer-diff]: name the cells that entered/left since the
- // last emitted signature — the bistable admission self-attributes.
- _viewerDiffScratch.Clear();
- var cur = result.PortalFrame.OrderedVisibleCells;
- var sb = new System.Text.StringBuilder(96);
- sb.Append("[viewer-diff] added=[");
- bool first = true;
- foreach (uint c in cur)
- {
- if (_lastViewerFloodCells.Contains(c)) continue;
- if (!first) sb.Append(',');
- sb.Append("0x").Append(c.ToString("X8"));
- first = false;
- }
- sb.Append("] removed=[");
- first = true;
- foreach (uint c in _lastViewerFloodCells)
- {
- bool present = false;
- for (int ci = 0; ci < cur.Count; ci++)
- if (cur[ci] == c) { present = true; break; }
- if (present) continue;
- if (!first) sb.Append(',');
- sb.Append("0x").Append(c.ToString("X8"));
- first = false;
- }
- sb.Append(']');
- Console.WriteLine(sb.ToString());
- _lastViewerFloodCells.Clear();
- foreach (uint c in cur) _lastViewerFloodCells.Add(c);
- }
- }
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
- AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
- clipRoot.CellId,
- result.PortalFrame.OrderedVisibleCells,
- result.PortalFrame.OutsideView.Polygons.Count,
- result.ClipAssembly.OutsidePlaneCount,
- result.ClipAssembly.PerCellPlaneCounts,
- result.ClipAssembly.ScissorFallbacks);
-
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
- {
- bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot);
- bool playerInRoot = CellVisibility.PointInCell(playerViewPos, clipRoot);
- Console.WriteLine(
- $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " +
- $"res={_cellVisibility.LastCameraCellResolution} " +
- $"eyeInRoot={(eyeInRoot ? "Y" : "n")} playerInRoot={(playerInRoot ? "Y" : "n")} " +
- $"eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " +
- $"player=({playerViewPos.X:F2},{playerViewPos.Y:F2},{playerViewPos.Z:F2}) " +
- $"terrain={result.ClipAssembly.TerrainMode} outVisible={result.ClipAssembly.OutdoorVisible}");
- }
- }
-
- // §4 flap apparatus (2026-06-09): GL-state tripwire. Snapshots the fixed-function
- // state entering the world passes; prints [gl-state] only when it CHANGES from the
- // previous frame. Every CPU-side input is probe-exonerated for the outdoor
- // full-world flap; this pins or refutes the leaked-GL-state family
- // (feedback_render_self_contained_gl_state — 3 prior hits). Throwaway.
- private string? _lastGlStateSig;
- private long _glStateFrame;
- private long _glStateStable;
-
- private void EmitGlStateTripwireIfChanged()
- {
- var gl = _gl;
- if (gl is null) return;
-
- _glStateFrame++;
- Span sbox = stackalloc int[4];
- Span vp = stackalloc int[4];
- gl.GetInteger(GetPName.ScissorBox, sbox);
- gl.GetInteger(GetPName.Viewport, vp);
-
- int clipBits = 0;
- for (int i = 0; i < ClipFrame.MaxPlanes; i++)
- if (gl.IsEnabled(EnableCap.ClipDistance0 + i)) clipBits |= 1 << i;
-
- var err = gl.GetError();
- // All-integer fields — culture-safe with plain interpolation.
- string sig =
- $"depth={(gl.IsEnabled(EnableCap.DepthTest) ? 1 : 0)} " +
- $"dmask={(gl.GetBoolean(GetPName.DepthWritemask) ? 1 : 0)} " +
- $"dfunc=0x{gl.GetInteger(GetPName.DepthFunc):X} " +
- $"blend={(gl.IsEnabled(EnableCap.Blend) ? 1 : 0)} " +
- $"bsrc=0x{gl.GetInteger(GetPName.BlendSrcRgb):X} bdst=0x{gl.GetInteger(GetPName.BlendDstRgb):X} " +
- $"cull={(gl.IsEnabled(EnableCap.CullFace) ? 1 : 0)} cmode=0x{gl.GetInteger(GetPName.CullFaceMode):X} " +
- $"fface=0x{gl.GetInteger(GetPName.FrontFace):X} " +
- $"scis={(gl.IsEnabled(EnableCap.ScissorTest) ? 1 : 0)} sbox=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) " +
- $"vp=({vp[0]},{vp[1]},{vp[2]},{vp[3]}) " +
- $"fbo={gl.GetInteger(GetPName.DrawFramebufferBinding)} " +
- $"a2c={(gl.IsEnabled(EnableCap.SampleAlphaToCoverage) ? 1 : 0)} " +
- $"stencil={(gl.IsEnabled(EnableCap.StencilTest) ? 1 : 0)} " +
- $"clip=0x{clipBits:X2} err=0x{(int)err:X}";
-
- if (sig == _lastGlStateSig)
- {
- _glStateStable++;
- return;
- }
-
- Console.WriteLine($"[gl-state] frame={_glStateFrame} stable={_glStateStable} {sig}");
- _lastGlStateSig = sig;
- _glStateStable = 0;
- }
-
- // §4 flap [clip-route-scis] probe (2026-06-10, throwaway): the ACTUAL GL scissor state
- // the landscape pass (sky + terrain + outdoor entities + the player) draws under, read
- // back right after BeginDoorwayScissor. The whole pass is scissored to slice.NdcAabb —
- // if the box reads doorway-sized here, the full-world flap is the scissor by
- // construction, no RenderDoc needed. Print-on-change.
- private string? _lastClipRouteScisSig;
- private long _clipRouteScisSeq;
-
- private void EmitClipRouteScissorProbe(bool applied, System.Numerics.Vector4 ndcAabb)
- {
- var gl = _gl;
- if (gl is null) return;
- Span sbox = stackalloc int[4];
- gl.GetInteger(GetPName.ScissorBox, sbox);
- bool enabled = gl.IsEnabled(EnableCap.ScissorTest);
- string sig = System.FormattableString.Invariant(
- $"applied={(applied ? 1 : 0)} scis={(enabled ? 1 : 0)} box=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})");
- _clipRouteScisSeq++;
- if (sig == _lastClipRouteScisSig)
- return;
- _lastClipRouteScisSig = sig;
- Console.WriteLine($"[clip-route-scis] n={_clipRouteScisSeq} {sig}");
- }
-
private void EnableClipDistances()
{
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
@@ -5869,33 +5237,8 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
- ///
- /// Yields the registered DebugPanel(s) so F1 can flip their
- /// visibility. Returns nothing when devtools are off.
- ///
- private IEnumerable EnumerateDebugPanel()
- {
- // The current ImGuiPanelHost only exposes Register/Unregister,
- // not enumerate. We track the DebugPanel through the VM presence
- // — the panel is registered iff _debugVm is non-null. Look it
- // up via the panel ID convention.
- // Defer the actual lookup to the panel host once it grows an
- // accessor; for now, no-op when devtools are off.
- if (_debugPanel is not null) yield return _debugPanel;
- }
-
- // Cached panel reference so EnumerateDebugPanel can return it. Set
- // in the DevToolsEnabled construction block above; null otherwise.
- private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
-
- // Cached chat-panel reference so the dispatcher's ToggleChatEntry
- // (Tab) handler can call FocusInput() programmatically. Set in the
- // DevToolsEnabled construction block; null otherwise.
- private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel;
-
- // Phase K.3 — Settings panel (click-to-rebind keymap UI). Hidden by
- // default; F11 / View → Settings toggles. Null when devtools are off.
- private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
+ // Phase K.3 settings state remains runtime-owned; the presenter owns the
+ // optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
@@ -6153,57 +5496,10 @@ public sealed class GameWindow : IDisposable
// current pos+size, which ImGuiNET doesn't expose by name.
// Force-reset is acceptable UX because resizing happens rarely
// and the user can always drag panels back where they want.
- if (DevToolsEnabled && _imguiBootstrap is not null)
- ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
- }
-
- ///
- /// L.0 Display tab: position every registered panel to its default
- /// landing spot, computed relative to the current window size so
- /// the layout adapts to any resolution. Called from:
- ///
- /// - OnFramebufferResize (cond=Always — force-reset on resize).
- /// - The View → "Reset window layout" menu item (cond=Always).
- /// - OnLoad after panel registration (cond=FirstUseEver — only
- /// applies when imgui.ini has no saved position for that
- /// panel; on subsequent launches the saved positions win).
- ///
- ///
- private void ResetPanelLayout(ImGuiNET.ImGuiCond cond)
- {
- if (_window is null) return;
- float w = _window.Size.X;
- float h = _window.Size.Y;
- // Sane minimums so the math doesn't blow up on a tiny window.
- if (w < 480) w = 480;
- if (h < 320) h = 320;
-
- // Panel positions chosen to be classic-MMO discoverable on a
- // 1280x720 window: vitals top-left under the menu bar, chat
- // bottom-left, debug top-right, settings centered. All sizes
- // are reasonable defaults the user can resize from.
- SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f),
- new System.Numerics.Vector2(220f, 110f), cond);
- SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f),
- new System.Numerics.Vector2(450f, 300f), cond);
- SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f),
- new System.Numerics.Vector2(370f, 520f), cond);
- SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f),
- new System.Numerics.Vector2(700f, 500f), cond);
- }
-
- private static void SetPanelLayout(
- string? title,
- System.Numerics.Vector2 pos,
- System.Numerics.Vector2 size,
- ImGuiNET.ImGuiCond cond)
- {
- if (string.IsNullOrEmpty(title)) return;
- // SetWindowPos/SetWindowSize by name work even when the window
- // has never been Begin'd — ImGui stores the value for next
- // appearance.
- ImGuiNET.ImGui.SetWindowPos(title, pos, cond);
- ImGuiNET.ImGui.SetWindowSize(title, size, cond);
+ _devToolsFramePresenter?.ResetLayout(
+ newSize.X,
+ newSize.Y,
+ AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always);
}
///
@@ -6280,9 +5576,6 @@ public sealed class GameWindow : IDisposable
&& width > 0
&& height > 0;
}
- // Vitals panel reference cached for the View menu's toggle entry.
- private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
-
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
@@ -6386,11 +5679,7 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
- foreach (var panel in EnumerateDebugPanel())
- {
- panel.IsVisible = !panel.IsVisible;
- _debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
- }
+ _devToolsFramePresenter?.ToggleDebugPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
@@ -6434,19 +5723,18 @@ public sealed class GameWindow : IDisposable
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
// sets a one-shot flag that emits SetKeyboardFocusHere on
- // the next render. No-op when devtools/_chatPanel is null
+ // the next render. No-op when the devtools presenter is absent
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
- _chatPanel?.FocusInput();
+ _devToolsFramePresenter?.FocusChatInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
// K.3: F11 toggles the Settings panel. Null-safe vs.
// devtools-off / panel-not-registered — the [input] log
// line above still records the press regardless.
- if (_settingsPanel is not null)
- _settingsPanel.IsVisible = !_settingsPanel.IsVisible;
+ _devToolsFramePresenter?.ToggleSettingsPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
@@ -6687,33 +5975,27 @@ public sealed class GameWindow : IDisposable
}
}
- private void MaybeFlushTerrainDiag()
+ private void PublishTerrainDrawDiagnostics()
{
- if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
+ long now = _frameDiag ? Environment.TickCount64 : 0L;
+ _worldRenderDiagnostics?.EndTerrainDraw();
+ if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000)
return;
- long now = Environment.TickCount64;
- if (now - _terrainLastDiagTick <= 5000) return;
+ // Preserve the original one-timestamp diagnostic transaction. A
+ // failure in either write leaves the shared cadence uncommitted, so
+ // the next terrain draw retries both TERRAIN and FRAME diagnostics.
+ _worldRenderDiagnostics?.PublishTerrainDiagnostics(
+ new AcDream.App.Rendering.TerrainRenderDiagnosticFacts(
+ _terrain?.VisibleSlots ?? 0,
+ _terrain?.LoadedSlots ?? 0,
+ _terrain?.CapacitySlots ?? 0));
+ PublishFrameDiagnostics();
+ _frameDiagLastPublicationMilliseconds = now;
+ }
- // Samples are stored as microseconds × 100 (so 1.23 µs becomes 123 long).
- long cpuMedHundredthsUs = TerrainDiagMedianMicros(_terrainCpuSamples);
- long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples);
- double cpuMedUs = cpuMedHundredthsUs / 100.0;
- double cpuP95Us = cpuP95HundredthsUs / 100.0;
- // A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget
- // (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
- const double TerrainBudgetUs = 1000.0;
- string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : "";
- Console.WriteLine(
- $"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
- $"draws={_terrain?.VisibleSlots ?? 0}/frame " +
- $"visible={_terrain?.VisibleSlots ?? 0} " +
- $"loaded={_terrain?.LoadedSlots ?? 0} " +
- $"capacity={_terrain?.CapacitySlots ?? 0}");
-
- // Publication metrics are cumulative typed snapshots from the focused
- // owners. The only GameWindow-local distribution left is the render-
- // thread entity upload drain.
+ private void PublishFrameDiagnostics()
+ {
AcDream.App.Streaming.LandblockPresentationDiagnostics presentation =
_landblockPresentationPipeline?.Diagnostics ?? default;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render =
@@ -6724,10 +6006,12 @@ public sealed class GameWindow : IDisposable
presentation.Statics;
double ticksToMicros =
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
- double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
- double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
+ AcDream.App.Rendering.RollingTimingPercentiles upload =
+ _entityUploadTiming.Snapshot();
+ double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0;
+ double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
- Console.WriteLine(
+ _renderDiagnosticLog.WriteLine(
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " +
$"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " +
$"begin={render.BeginPublishTicks * ticksToMicros:F1} " +
@@ -6737,53 +6021,12 @@ public sealed class GameWindow : IDisposable
$"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " +
$"static={statics.BeginCount}/{statics.CompleteCount}" +
$"(active={statics.ActiveEntityCount}) " +
- $"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
+ $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " +
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
$"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" +
$"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " +
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
$"resident={_worldState.Entities.Count} walked={walked}");
-
- _terrainLastDiagTick = now;
- }
-
- private static long TerrainDiagMedianMicros(long[] samples)
- {
- var copy = (long[])samples.Clone();
- Array.Sort(copy);
- int nz = 0;
- foreach (var v in copy) if (v > 0) nz++;
- if (nz == 0) return 0;
- // Sorted ascending: zero-padding at the front, samples at the back.
- // Median of nz samples is the middle of the last nz entries; using
- // (nz - 1) / 2 from the end keeps the offset >= 0 for all nz >= 1
- // (the original nz / 2 form underflowed to copy.Length on first
- // diag-flush when only 1 sample had been recorded).
- return copy[copy.Length - 1 - (nz - 1) / 2];
- }
-
- private static long TerrainDiagPercentile95Micros(long[] samples)
- {
- var copy = (long[])samples.Clone();
- Array.Sort(copy);
- int nz = 0;
- foreach (var v in copy) if (v > 0) nz++;
- if (nz == 0) return 0;
- // 95th percentile = upper end of the sorted samples; clamp the
- // offset to stay inside the populated tail when nz < 20.
- int offset = (int)((nz - 1) * 0.05);
- return copy[copy.Length - 1 - offset];
- }
-
- /// [FRAME-DIAG] helper: convert a
- /// timestamp-tick delta to the microseconds×100 fixed-point used by the rolling
- /// sample rings and store it (zeros are ignored by the median/p95 helpers, so an
- /// idle frame's 0-sample doesn't dilute the non-zero cost distribution).
- private static void FrameDiagPush(long[] samples, ref int cursor, long ticks)
- {
- // µs×100 = ticks × 1e8 / Stopwatch.Frequency.
- samples[cursor] = (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency);
- cursor = (cursor + 1) % samples.Length;
}
/// A.5 T22: parse a float environment variable, returning
@@ -6922,6 +6165,7 @@ public sealed class GameWindow : IDisposable
{
_paperdollViewportRenderer?.Dispose();
_paperdollViewportRenderer = null;
+ _paperdollFramePresenter = null;
}),
new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()),
new("environment cells", () => _envCellRenderer?.Dispose()),
diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
new file mode 100644
index 00000000..1f8dc509
--- /dev/null
+++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs
@@ -0,0 +1,309 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.UI;
+using AcDream.App.World;
+using AcDream.Content;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter;
+
+namespace AcDream.App.Rendering;
+
+internal interface IPaperdollDollRenderer
+{
+ void SetDoll(WorldEntity? doll);
+
+ uint Render(int width, int height);
+}
+
+internal interface IPaperdollFrameView
+{
+ bool TryGetVisibleSize(out int width, out int height);
+
+ void SetTextureHandle(uint textureHandle);
+}
+
+internal interface IPaperdollInventoryVisibility
+{
+ bool IsVisible { get; }
+}
+
+internal interface IPaperdollDollFactory
+{
+ bool TryBuild(out WorldEntity? doll);
+}
+
+internal interface IPaperdollEntityLookup
+{
+ bool TryGet(uint serverGuid, out WorldEntity player);
+}
+
+internal interface IPaperdollPoseApplicator
+{
+ void Apply(WorldEntity doll, uint setupId);
+}
+
+///
+/// Owns paperdoll dirty/rebuild state and the private FBO presentation edge.
+/// The GL renderer remains a borrowed resource disposed by the existing window
+/// shutdown transaction.
+///
+internal sealed class PaperdollFramePresenter
+{
+ private readonly IPaperdollDollRenderer _renderer;
+ private readonly IPaperdollFrameView _view;
+ private readonly IPaperdollDollFactory _factory;
+ private bool _dirty = true;
+
+ public PaperdollFramePresenter(
+ IPaperdollDollRenderer renderer,
+ IPaperdollFrameView view,
+ IPaperdollDollFactory factory)
+ {
+ _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
+ _view = view ?? throw new ArgumentNullException(nameof(view));
+ _factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ }
+
+ internal bool IsDirty => _dirty;
+
+ public void MarkDirty() => _dirty = true;
+
+ public void Render()
+ {
+ if (!_view.TryGetVisibleSize(out int width, out int height))
+ return;
+
+ if (_dirty)
+ {
+ if (_factory.TryBuild(out WorldEntity? doll))
+ {
+ _renderer.SetDoll(doll);
+ _dirty = false;
+ }
+ else
+ {
+ // Retail's private viewport remains present while the live
+ // player projection is still materializing. Keep the dirty
+ // edge armed and clear any doll from the prior session.
+ _renderer.SetDoll(null);
+ }
+ }
+
+ _view.SetTextureHandle(_renderer.Render(width, height));
+ }
+}
+
+/// Retained-UI visibility and texture publication for the doll view.
+internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
+{
+ private readonly UiViewport _viewport;
+ private readonly IPaperdollInventoryVisibility _inventory;
+
+ public RetailPaperdollFrameView(
+ UiViewport viewport,
+ IPaperdollInventoryVisibility inventory)
+ {
+ _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
+ _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
+ }
+
+ public bool TryGetVisibleSize(out int width, out int height)
+ {
+ width = 0;
+ height = 0;
+ if (!_viewport.Visible || !_inventory.IsVisible)
+ {
+ return false;
+ }
+
+ width = (int)_viewport.Width;
+ height = (int)_viewport.Height;
+ return true;
+ }
+
+ public void SetTextureHandle(uint textureHandle) =>
+ _viewport.TextureHandle = textureHandle;
+}
+
+/// Narrow visibility adapter for the paperdoll's inventory host.
+internal sealed class PaperdollInventoryVisibility : IPaperdollInventoryVisibility
+{
+ private readonly UiElement _inventoryFrame;
+
+ public PaperdollInventoryVisibility(UiElement inventoryFrame)
+ {
+ _inventoryFrame = inventoryFrame
+ ?? throw new ArgumentNullException(nameof(inventoryFrame));
+ }
+
+ public bool IsVisible => _inventoryFrame.Visible;
+}
+
+/// Canonical live-entity lookup used by the paperdoll factory.
+internal sealed class LivePaperdollEntityLookup : IPaperdollEntityLookup
+{
+ private readonly LiveEntityRuntime _liveEntities;
+
+ public LivePaperdollEntityLookup(LiveEntityRuntime liveEntities)
+ {
+ _liveEntities = liveEntities
+ ?? throw new ArgumentNullException(nameof(liveEntities));
+ }
+
+ public bool TryGet(uint serverGuid, out WorldEntity player) =>
+ _liveEntities.TryGetWorldEntity(serverGuid, out player);
+}
+
+///
+/// Builds the static retail paperdoll clone from the canonical live player
+/// projection and applies the DAT-defined held pose.
+///
+internal sealed class RetailPaperdollDollFactory : IPaperdollDollFactory
+{
+ private readonly IPaperdollEntityLookup _entities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly IPaperdollPoseApplicator _pose;
+
+ public RetailPaperdollDollFactory(
+ IPaperdollEntityLookup entities,
+ ILocalPlayerIdentitySource identity,
+ IPaperdollPoseApplicator pose)
+ {
+ _entities = entities ?? throw new ArgumentNullException(nameof(entities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _pose = pose ?? throw new ArgumentNullException(nameof(pose));
+ }
+
+ public bool TryBuild(out WorldEntity? doll)
+ {
+ doll = null;
+ if (!_entities.TryGet(_identity.ServerGuid, out WorldEntity player)
+ || player.MeshRefs.Count == 0)
+ {
+ return false;
+ }
+
+ uint? basePalette = null;
+ List<(uint, byte, byte)>? subPalettes = null;
+ if (player.PaletteOverride is { } palette)
+ {
+ basePalette = palette.BasePaletteId;
+ subPalettes = new List<(uint, byte, byte)>();
+ foreach (var range in palette.SubPalettes)
+ {
+ subPalettes.Add((
+ range.SubPaletteId,
+ range.Offset,
+ range.Length));
+ }
+ }
+
+ List<(byte, uint)>? partOverrides = null;
+ if (player.PartOverrides.Count > 0)
+ {
+ partOverrides = new List<(byte, uint)>(player.PartOverrides.Count);
+ foreach (var part in player.PartOverrides)
+ partOverrides.Add((part.PartIndex, part.GfxObjId));
+ }
+
+ doll = DollEntityBuilder.Build(
+ player.SourceGfxObjOrSetupId,
+ new List(player.MeshRefs),
+ basePalette,
+ subPalettes,
+ partOverrides);
+ _pose.Apply(doll, player.SourceGfxObjOrSetupId);
+ return true;
+ }
+}
+
+/// Applies retail's DAT-defined settled paperdoll stance.
+internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
+{
+ private readonly IDatReaderWriter _dats;
+ private readonly IAnimationLoader _animations;
+ private readonly object _datLock;
+
+ public RetailPaperdollPoseApplicator(
+ IDatReaderWriter dats,
+ IAnimationLoader animations,
+ object datLock)
+ {
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
+ }
+
+ ///
+ /// Retail gmPaperDollUI resolves its held pose with
+ /// DBCache::GetDIDFromEnumStatic(0x10000005, 7). The master map
+ /// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
+ /// Animation DID.
+ ///
+ private uint ResolvePoseDid()
+ {
+ uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
+ if (masterDid == 0
+ || !_dats.Portal.TryGet(
+ masterDid,
+ out var master)
+ || !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
+ || !_dats.Portal.TryGet(
+ subDid,
+ out var sub))
+ {
+ return 0u;
+ }
+
+ return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did)
+ ? did
+ : 0u;
+ }
+
+ public void Apply(WorldEntity doll, uint setupId)
+ {
+ DatReaderWriter.DBObjs.Animation? animation;
+ DatReaderWriter.DBObjs.Setup? setup;
+ lock (_datLock)
+ {
+ uint poseDid = ResolvePoseDid();
+ if ((poseDid >> 24) != 0x03u)
+ return;
+
+ animation = _animations.LoadAnimation(poseDid);
+ setup = _dats.Get(setupId);
+ }
+ if (animation is null || setup is null || animation.PartFrames.Count == 0)
+ return;
+
+ // RedressCreature @ 0x004A3C22 installs the pose with zero frame rate
+ // and holds the settled final frame.
+ var frame = animation.PartFrames[^1];
+ var reposed = new List(doll.MeshRefs.Count);
+ for (int index = 0; index < doll.MeshRefs.Count; index++)
+ {
+ Vector3 scale = index < setup.DefaultScale.Count
+ ? setup.DefaultScale[index]
+ : Vector3.One;
+ Vector3 origin = Vector3.Zero;
+ Quaternion orientation = Quaternion.Identity;
+ if (index < frame.Frames.Count)
+ {
+ origin = frame.Frames[index].Origin;
+ orientation = frame.Frames[index].Orientation;
+ }
+
+ Matrix4x4 transform = Matrix4x4.CreateScale(scale)
+ * Matrix4x4.CreateFromQuaternion(orientation)
+ * Matrix4x4.CreateTranslation(origin);
+ MeshRef source = doll.MeshRefs[index];
+ reposed.Add(new MeshRef(source.GfxObjId, transform)
+ {
+ SurfaceOverrides = source.SurfaceOverrides,
+ });
+ }
+
+ doll.MeshRefs = reposed;
+ }
+}
diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
index d432eba7..258d79bb 100644
--- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
@@ -12,22 +12,24 @@ namespace AcDream.App.Rendering;
///
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
/// CreatureMode::Render). Draws ONE re-dressed player clone (a ,
-/// built by + wired by GameWindow) into a private off-screen
+/// built by + wired by ) into a private off-screen
/// framebuffer with a fixed + one distant light, then hands the color
/// texture to the widget which blits it as a normal 2-D sprite.
///
/// The whole 3-D pass is sealed in a so it can't disturb the
/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the
-/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's
+/// per-frame pre-UI hook, gated on inventory-open ∧ doll-view — NOT from the widget's
/// 2-D OnDraw.
///
/// Reuses the existing + the player's already-resolved,
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
/// needed. With frustum: null the doll's synthetic landblock is always "visible" and the
-/// entity is walked from entry.Entities and drawn from its current MeshRefs — a STATIC pose
-/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.
+/// entity is walked from entry.Entities and drawn from its current MeshRefs — a static held pose.
///
-public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable
+public sealed unsafe class PaperdollViewportRenderer :
+ IUiViewportRenderer,
+ IPaperdollDollRenderer,
+ IDisposable
{
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
@@ -42,8 +44,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
private int _fbW;
private int _fbH;
- // The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its
- // MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw.
+ // The static held-pose doll. null = nothing to draw.
private WorldEntity? _doll;
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
@@ -70,7 +71,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
DollEntityBuilder.DollRenderId);
}
- /// Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.
+ /// Set (or clear) the doll entity built from the live player projection.
public void SetDoll(WorldEntity? doll)
{
if (ReferenceEquals(_doll, doll))
diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
new file mode 100644
index 00000000..49cdcbdc
--- /dev/null
+++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
@@ -0,0 +1,168 @@
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+using Silk.NET.Windowing;
+
+namespace AcDream.App.Rendering;
+
+internal sealed class RuntimeRenderFrameTitleFactsSource : IRenderFrameTitleFactsSource
+{
+ private readonly GpuWorldState _world;
+ private readonly LiveEntityAnimationRuntimeView _animations;
+ private readonly WorldTimeService _time;
+
+ public RuntimeRenderFrameTitleFactsSource(
+ GpuWorldState world,
+ LiveEntityAnimationRuntimeView animations,
+ WorldTimeService time)
+ {
+ _world = world ?? throw new ArgumentNullException(nameof(world));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _time = time ?? throw new ArgumentNullException(nameof(time));
+ }
+
+ public RenderFrameTitleFacts Capture() => new(
+ _world.Entities.Count,
+ _animations.Count,
+ DerethDateTime.ToCalendar(_time.NowTicks),
+ _time.DayFraction);
+}
+
+internal sealed class SilkRenderFrameTitleSink : IRenderFrameTitleSink
+{
+ private readonly IWindow _window;
+
+ public SilkRenderFrameTitleSink(IWindow window) =>
+ _window = window ?? throw new ArgumentNullException(nameof(window));
+
+ public void SetTitle(string title) => _window.Title = title;
+}
+
+///
+/// Read-only sampler for the explicit, low-frequency GPU-stream diagnostic.
+/// It borrows canonical owners and returns one immutable value snapshot.
+///
+internal sealed class RuntimeRenderFrameResourceDiagnosticsSource :
+ IRenderFrameResourceDiagnosticsSource
+{
+ private readonly ParticleSystem? _particles;
+ private readonly ParticleHookSink? _particleBindings;
+ private readonly WbDrawDispatcher? _worldDispatcher;
+ private readonly Wb.EnvCellRenderer? _environmentCells;
+ private readonly ParticleRenderer? _particleRenderer;
+ private readonly TextRenderer? _uiTextRenderer;
+ private readonly PortalDepthMaskRenderer? _portalDepthMask;
+ private readonly ClipFrame? _clipFrame;
+ private readonly TerrainModernRenderer? _terrain;
+ private readonly SceneLightingUboBinding? _lighting;
+ private readonly WbMeshAdapter? _meshes;
+ private readonly TextureCache? _textures;
+
+ public RuntimeRenderFrameResourceDiagnosticsSource(
+ ParticleSystem? particles,
+ ParticleHookSink? particleBindings,
+ WbDrawDispatcher? worldDispatcher,
+ Wb.EnvCellRenderer? environmentCells,
+ ParticleRenderer? particleRenderer,
+ TextRenderer? uiTextRenderer,
+ PortalDepthMaskRenderer? portalDepthMask,
+ ClipFrame? clipFrame,
+ TerrainModernRenderer? terrain,
+ SceneLightingUboBinding? lighting,
+ WbMeshAdapter? meshes,
+ TextureCache? textures)
+ {
+ _particles = particles;
+ _particleBindings = particleBindings;
+ _worldDispatcher = worldDispatcher;
+ _environmentCells = environmentCells;
+ _particleRenderer = particleRenderer;
+ _uiTextRenderer = uiTextRenderer;
+ _portalDepthMask = portalDepthMask;
+ _clipFrame = clipFrame;
+ _terrain = terrain;
+ _lighting = lighting;
+ _meshes = meshes;
+ _textures = textures;
+ }
+
+ public RenderFrameResourceDiagnosticsSnapshot Capture()
+ {
+ (int particleSets, long particleBytes) =
+ _particleRenderer?.DynamicBufferDiagnostics ?? default;
+ var meshManager = _meshes?.MeshManager;
+ var mesh = meshManager?.Diagnostics ?? default;
+ var globalMesh = meshManager?.GlobalBuffer;
+ var cpuMesh = _meshes?.CpuMeshCacheDiagnostics ?? default;
+ GCMemoryInfo gc = GC.GetGCMemoryInfo();
+
+ return new RenderFrameResourceDiagnosticsSnapshot(
+ new VfxStreamResourceDiagnostics(
+ _particles?.ActiveEmitterCount ?? 0,
+ _particles?.ActiveParticleCount ?? 0,
+ _particleBindings?.ActiveBindingCount ?? 0),
+ new DynamicBufferResourceDiagnostics(
+ _worldDispatcher?.DynamicBufferSetCount ?? 0,
+ _environmentCells?.DynamicBufferSetCount ?? 0,
+ particleSets,
+ particleBytes,
+ _uiTextRenderer?.DynamicBufferCapacityBytes ?? 0,
+ _portalDepthMask?.DynamicBufferCapacityBytes ?? 0,
+ _clipFrame?.DynamicBufferSetCount ?? 0,
+ _terrain?.DynamicIndirectBufferCount ?? 0,
+ _lighting?.DynamicBufferCount ?? 0),
+ new MeshStreamResourceDiagnostics(
+ mesh.RenderData,
+ mesh.AtlasArrays,
+ mesh.UnusedLru,
+ mesh.EstimatedBytes,
+ globalMesh?.UploadCount ?? 0,
+ globalMesh?.UploadedBytes ?? 0,
+ _meshes?.LastUploadCount ?? 0,
+ _meshes?.LastUploadBytes ?? 0,
+ _meshes?.LastArrayAllocationBytes ?? 0,
+ _meshes?.LastPlannedMipmapBytes ?? 0,
+ _meshes?.LastNewArrayCount ?? 0,
+ _meshes?.LastBufferUploadBytes ?? 0,
+ _meshes?.LastBufferAllocationBytes ?? 0,
+ _meshes?.LastBufferCopyBytes ?? 0,
+ _meshes?.LastNewBufferCount ?? 0,
+ _meshes?.StagedUploadBacklog ?? 0,
+ _meshes?.StagedUploadBytes ?? 0,
+ _meshes?.StagingAtHighWater ?? false,
+ _meshes?.LastStaleDiscardCount ?? 0,
+ cpuMesh.Count,
+ cpuMesh.Bytes,
+ _meshes?.LastMipmapArrayCount ?? 0,
+ _meshes?.LastMipmapBytes ?? 0,
+ globalMesh?.CapacityBytes ?? 0,
+ globalMesh?.PhysicalCapacityBytes ?? 0,
+ globalMesh?.IsMigrationInProgress ?? false),
+ new TextureStreamResourceDiagnostics(
+ _textures?.OwnedBindlessTextureCount ?? 0,
+ _textures?.TextureOwnerCount ?? 0,
+ _textures?.ActiveParticleTextureCount ?? 0,
+ _textures?.ParticleTextureOwnerCount ?? 0,
+ _textures?.CachedParticleTextureCount ?? 0,
+ _textures?.CachedUnownedParticleTextureCount ?? 0,
+ _textures?.CachedUnownedParticleTextureBytes ?? 0,
+ _textures?.CachedCompositeTextureCount ?? 0,
+ _textures?.CachedUnownedCompositeCount ?? 0,
+ _textures?.CachedUnownedCompositeBytes ?? 0,
+ _textures?.CompositeAtlasCount ?? 0,
+ _textures?.CompositeAtlasBytes ?? 0,
+ _textures?.CompositeFrameUploadCount ?? 0,
+ _textures?.CompositeFrameUploadBytes ?? 0,
+ _worldDispatcher?.LastCompositeWarmupPendingCount ?? 0),
+ new ProcessResourceDiagnostics(
+ GC.GetTotalMemory(false),
+ gc.TotalCommittedBytes,
+ GpuMemoryTracker.AllocatedBytes,
+ GpuMemoryTracker.BufferCount,
+ GpuMemoryTracker.TextureCount));
+ }
+}
diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
new file mode 100644
index 00000000..bb78cc1c
--- /dev/null
+++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
@@ -0,0 +1,269 @@
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+/// Current values published by the successful render-frame cadence.
+internal readonly record struct RenderFrameDiagnosticsSnapshot(
+ double Fps,
+ double FrameMilliseconds,
+ int VisibleLandblocks,
+ int TotalLandblocks,
+ int EntityCount,
+ int AnimatedEntityCount)
+{
+ public static RenderFrameDiagnosticsSnapshot Initial => new(
+ Fps: 60.0,
+ FrameMilliseconds: 16.7,
+ VisibleLandblocks: 0,
+ TotalLandblocks: 0,
+ EntityCount: 0,
+ AnimatedEntityCount: 0);
+}
+
+/// Non-render facts sampled only when the title cadence publishes.
+internal readonly record struct RenderFrameTitleFacts(
+ int EntityCount,
+ int AnimatedEntityCount,
+ DerethDateTime.Calendar Calendar,
+ double DayFraction);
+
+internal interface IRenderFrameTitleFactsSource
+{
+ RenderFrameTitleFacts Capture();
+}
+
+/// Narrow host seam; diagnostics can publish chrome without retaining a window.
+internal interface IRenderFrameTitleSink
+{
+ void SetTitle(string title);
+}
+
+internal interface IRenderFrameDiagnosticLog
+{
+ void WriteLine(string message);
+}
+
+internal sealed class ConsoleRenderFrameDiagnosticLog : IRenderFrameDiagnosticLog
+{
+ public void WriteLine(string message) => Console.WriteLine(message);
+}
+
+internal interface IRenderFrameResourceDiagnosticsSource
+{
+ RenderFrameResourceDiagnosticsSnapshot Capture();
+}
+
+internal readonly record struct VfxStreamResourceDiagnostics(
+ int ActiveEmitters,
+ int ActiveParticles,
+ int ActiveBindings);
+
+internal readonly record struct DynamicBufferResourceDiagnostics(
+ int WorldBufferSets,
+ int EnvCellBufferSets,
+ int ParticleBufferSets,
+ long ParticleBufferBytes,
+ long UiTextBufferBytes,
+ long PortalDepthMaskBufferBytes,
+ int ClipBufferSets,
+ int TerrainIndirectBuffers,
+ int SceneLightingBuffers);
+
+internal readonly record struct MeshStreamResourceDiagnostics(
+ int RenderData,
+ int AtlasArrays,
+ int UnusedLru,
+ long EstimatedBytes,
+ long GlobalUploadCount,
+ long GlobalUploadedBytes,
+ int FrameUploadCount,
+ long FrameUploadBytes,
+ long FrameArrayAllocationBytes,
+ long FramePlannedMipmapBytes,
+ int FrameNewArrayCount,
+ long FrameBufferUploadBytes,
+ long FrameBufferAllocationBytes,
+ long FrameBufferCopyBytes,
+ int FrameNewBufferCount,
+ int StagedUploadBacklog,
+ long StagedUploadBytes,
+ bool StagingAtHighWater,
+ int FrameStaleDiscardCount,
+ int CpuMeshCacheCount,
+ long CpuMeshCacheBytes,
+ int FrameMipmapArrayCount,
+ long FrameMipmapBytes,
+ long GlobalCapacityBytes,
+ long GlobalPhysicalCapacityBytes,
+ bool GlobalMigrationInProgress);
+
+internal readonly record struct TextureStreamResourceDiagnostics(
+ int OwnedBindlessTextures,
+ int TextureOwners,
+ int ActiveParticleTextures,
+ int ParticleTextureOwners,
+ int CachedParticleTextures,
+ int CachedUnownedParticleTextures,
+ long CachedUnownedParticleTextureBytes,
+ int CachedCompositeTextures,
+ int CachedUnownedComposites,
+ long CachedUnownedCompositeBytes,
+ int CompositeAtlases,
+ long CompositeAtlasBytes,
+ int FrameCompositeUploadCount,
+ long FrameCompositeUploadBytes,
+ int CompositeWarmupPending);
+
+internal readonly record struct ProcessResourceDiagnostics(
+ long ManagedBytes,
+ long ManagedCommittedBytes,
+ long TrackedGpuBytes,
+ int TrackedGpuBuffers,
+ int TrackedGpuTextures);
+
+///
+/// Immutable resource facts captured only when explicit UI-probe dumping is enabled.
+/// Grouping keeps the diagnostics controller independent from every canonical renderer,
+/// VFX, mesh, texture, and process owner used to produce the values.
+///
+internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
+ VfxStreamResourceDiagnostics Vfx,
+ DynamicBufferResourceDiagnostics DynamicBuffers,
+ MeshStreamResourceDiagnostics Mesh,
+ TextureStreamResourceDiagnostics Textures,
+ ProcessResourceDiagnostics Process);
+
+///
+/// Publishes post-presentation frame statistics. The caller invokes this only after
+/// screenshot capture, preserving the accepted screenshot-before-title/resource order.
+///
+internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase
+{
+ internal const double PublicationIntervalSeconds = 0.5;
+
+ private readonly IRenderFrameTitleFactsSource _titleFacts;
+ private readonly IRenderFrameTitleSink _titleSink;
+ private readonly IRenderFrameResourceDiagnosticsSource? _resources;
+ private readonly IRenderFrameDiagnosticLog _log;
+ private readonly bool _publishResourceDiagnostics;
+
+ private double _elapsedSeconds;
+ private int _frameCount;
+
+ public RenderFrameDiagnosticsSnapshot Snapshot { get; private set; } =
+ RenderFrameDiagnosticsSnapshot.Initial;
+
+ public RenderFrameDiagnosticsController(
+ IRenderFrameTitleFactsSource titleFacts,
+ IRenderFrameTitleSink titleSink,
+ IRenderFrameDiagnosticLog log,
+ bool publishResourceDiagnostics,
+ IRenderFrameResourceDiagnosticsSource? resources = null)
+ {
+ _titleFacts = titleFacts ?? throw new ArgumentNullException(nameof(titleFacts));
+ _titleSink = titleSink ?? throw new ArgumentNullException(nameof(titleSink));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ _publishResourceDiagnostics = publishResourceDiagnostics;
+ _resources = publishResourceDiagnostics
+ ? resources ?? throw new ArgumentNullException(nameof(resources))
+ : resources;
+ }
+
+ public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
+ {
+ _elapsedSeconds += input.DeltaSeconds;
+ _frameCount++;
+ if (_elapsedSeconds < PublicationIntervalSeconds)
+ return;
+
+ double averageFrameMilliseconds = _elapsedSeconds / _frameCount * 1000.0;
+ double fps = _frameCount / _elapsedSeconds;
+ RenderFrameTitleFacts facts = _titleFacts.Capture();
+
+ // Keep external publication ahead of the cached values and accumulator reset.
+ // If either sink fails, the controller retains the accumulated window exactly as
+ // the previous GameWindow body did and a later successful frame can retry it.
+ _titleSink.SetTitle(FormatTitle(
+ fps,
+ averageFrameMilliseconds,
+ outcome.World.VisibleLandblocks,
+ outcome.World.TotalLandblocks,
+ facts));
+
+ if (_publishResourceDiagnostics)
+ {
+ RenderFrameResourceDiagnosticsSnapshot resources = _resources!.Capture();
+ _log.WriteLine(FormatGpuStream(resources));
+ }
+
+ Snapshot = new RenderFrameDiagnosticsSnapshot(
+ fps,
+ averageFrameMilliseconds,
+ outcome.World.VisibleLandblocks,
+ outcome.World.TotalLandblocks,
+ facts.EntityCount,
+ facts.AnimatedEntityCount);
+ _elapsedSeconds = 0;
+ _frameCount = 0;
+ }
+
+ internal static string FormatTitle(
+ double fps,
+ double averageFrameMilliseconds,
+ int visibleLandblocks,
+ int totalLandblocks,
+ RenderFrameTitleFacts facts) =>
+ $"acdream | {fps:F0} fps | {averageFrameMilliseconds:F1} ms | "
+ + $"lb {visibleLandblocks}/{totalLandblocks} | "
+ + $"ent {facts.EntityCount}/anim {facts.AnimatedEntityCount} | "
+ + $"PY{facts.Calendar.Year} {facts.Calendar.Month} {facts.Calendar.Day} "
+ + $"{facts.Calendar.Hour} (df={facts.DayFraction:F4})";
+
+ internal static string FormatGpuStream(RenderFrameResourceDiagnosticsSnapshot snapshot)
+ {
+ VfxStreamResourceDiagnostics vfx = snapshot.Vfx;
+ DynamicBufferResourceDiagnostics buffers = snapshot.DynamicBuffers;
+ MeshStreamResourceDiagnostics mesh = snapshot.Mesh;
+ TextureStreamResourceDiagnostics textures = snapshot.Textures;
+ ProcessResourceDiagnostics process = snapshot.Process;
+ return
+ $"[gpu-stream] emit={vfx.ActiveEmitters} "
+ + $"particles={vfx.ActiveParticles} "
+ + $"bindings={vfx.ActiveBindings} "
+ + $"wbSets={buffers.WorldBufferSets} "
+ + $"cellSets={buffers.EnvCellBufferSets} "
+ + $"particleSets={buffers.ParticleBufferSets}/{buffers.ParticleBufferBytes}B "
+ + $"uiBytes={buffers.UiTextBufferBytes} "
+ + $"portalBytes={buffers.PortalDepthMaskBufferBytes} "
+ + $"clipSets={buffers.ClipBufferSets} "
+ + $"terrainBuffers={buffers.TerrainIndirectBuffers} "
+ + $"lightBuffers={buffers.SceneLightingBuffers} "
+ + $"mesh={mesh.RenderData}/{mesh.AtlasArrays}/{mesh.UnusedLru} "
+ + $"meshEst={mesh.EstimatedBytes} "
+ + $"meshUpload={mesh.GlobalUploadCount}/{mesh.GlobalUploadedBytes} "
+ + $"uploadFrame={mesh.FrameUploadCount}/{mesh.FrameUploadBytes}/"
+ + $"{mesh.FrameArrayAllocationBytes}/{mesh.FramePlannedMipmapBytes}/"
+ + $"{mesh.FrameNewArrayCount} "
+ + $"bufferFrame={mesh.FrameBufferUploadBytes}/{mesh.FrameBufferAllocationBytes}/"
+ + $"{mesh.FrameBufferCopyBytes}/{mesh.FrameNewBufferCount} "
+ + $"staging={mesh.StagedUploadBacklog}/{mesh.StagedUploadBytes}/"
+ + $"{mesh.StagingAtHighWater}/{mesh.FrameStaleDiscardCount} "
+ + $"cpuMesh={mesh.CpuMeshCacheCount}/{mesh.CpuMeshCacheBytes} "
+ + $"mipmapFrame={mesh.FrameMipmapArrayCount}/{mesh.FrameMipmapBytes} "
+ + $"meshCap={mesh.GlobalCapacityBytes} "
+ + $"meshPhys={mesh.GlobalPhysicalCapacityBytes}/{mesh.GlobalMigrationInProgress} "
+ + $"gpuTrack={process.TrackedGpuBytes}/{process.TrackedGpuBuffers}/"
+ + $"{process.TrackedGpuTextures} "
+ + $"managed={process.ManagedBytes}/{process.ManagedCommittedBytes} "
+ + $"ownedTextures={textures.OwnedBindlessTextures}/{textures.TextureOwners} "
+ + $"particleTextures={textures.ActiveParticleTextures}/"
+ + $"{textures.ParticleTextureOwners}/{textures.CachedParticleTextures}/"
+ + $"{textures.CachedUnownedParticleTextures}/"
+ + $"{textures.CachedUnownedParticleTextureBytes} "
+ + $"compositeCache={textures.CachedCompositeTextures}/"
+ + $"{textures.CachedUnownedComposites}/{textures.CachedUnownedCompositeBytes} "
+ + $"compositeAtlas={textures.CompositeAtlases}/{textures.CompositeAtlasBytes} "
+ + $"compositeUpload={textures.FrameCompositeUploadCount}/"
+ + $"{textures.FrameCompositeUploadBytes}/{textures.CompositeWarmupPending}";
+ }
+}
diff --git a/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs b/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs
new file mode 100644
index 00000000..c8eb62ce
--- /dev/null
+++ b/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs
@@ -0,0 +1,54 @@
+namespace AcDream.App.Rendering;
+
+internal readonly record struct RollingTimingPercentiles(
+ long MedianHundredthsMicroseconds,
+ long Percentile95HundredthsMicroseconds);
+
+///
+/// Fixed-capacity rolling timing distribution. Zero and negative values remain
+/// empty padding and do not dilute the diagnostic percentiles.
+///
+internal sealed class RollingTimingSampleWindow
+{
+ private readonly long[] _samples;
+ private int _cursor;
+
+ public RollingTimingSampleWindow(int capacity)
+ {
+ if (capacity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(capacity));
+ _samples = new long[capacity];
+ }
+
+ public int Capacity => _samples.Length;
+
+ public void PushHundredthsMicroseconds(long sample)
+ {
+ _samples[_cursor] = sample;
+ _cursor = (_cursor + 1) % _samples.Length;
+ }
+
+ public void PushStopwatchTicks(long ticks) =>
+ PushHundredthsMicroseconds(
+ (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency));
+
+ public RollingTimingPercentiles Snapshot()
+ {
+ var copy = (long[])_samples.Clone();
+ Array.Sort(copy);
+ int populated = 0;
+ foreach (long sample in copy)
+ {
+ if (sample > 0)
+ populated++;
+ }
+
+ if (populated == 0)
+ return default;
+
+ long median = copy[copy.Length - 1 - (populated - 1) / 2];
+ int p95Offset = (int)((populated - 1) * 0.05);
+ long percentile95 = copy[copy.Length - 1 - p95Offset];
+ return new RollingTimingPercentiles(median, percentile95);
+ }
+}
diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
new file mode 100644
index 00000000..57da6334
--- /dev/null
+++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
@@ -0,0 +1,593 @@
+using System.Numerics;
+using System.Diagnostics;
+using System.Text;
+using AcDream.Core.Vfx;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct IntRenderRectangle(int X, int Y, int Width, int Height);
+
+internal readonly record struct RenderGlStateSnapshot(
+ bool DepthTest,
+ bool DepthWrite,
+ int DepthFunction,
+ bool Blend,
+ int BlendSource,
+ int BlendDestination,
+ bool CullFace,
+ int CullMode,
+ int FrontFace,
+ bool Scissor,
+ IntRenderRectangle ScissorBox,
+ IntRenderRectangle Viewport,
+ int DrawFramebuffer,
+ bool AlphaToCoverage,
+ bool Stencil,
+ int ClipBits,
+ int Error);
+
+internal readonly record struct RenderGlScissorSnapshot(
+ bool Enabled,
+ IntRenderRectangle Box);
+
+internal readonly record struct TerrainRenderDiagnosticFacts(
+ int VisibleSlots,
+ int LoadedSlots,
+ int CapacitySlots);
+
+internal interface IRenderGlStateReader
+{
+ RenderGlStateSnapshot CaptureState();
+
+ RenderGlScissorSnapshot CaptureScissor();
+}
+
+/// Render-thread GL state reader used only by explicitly enabled probes.
+internal sealed class SilkRenderGlStateReader : IRenderGlStateReader
+{
+ private readonly GL _gl;
+
+ public SilkRenderGlStateReader(GL gl) =>
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+
+ public RenderGlStateSnapshot CaptureState()
+ {
+ Span scissor = stackalloc int[4];
+ Span viewport = stackalloc int[4];
+ _gl.GetInteger(GetPName.ScissorBox, scissor);
+ _gl.GetInteger(GetPName.Viewport, viewport);
+
+ int clipBits = 0;
+ for (int index = 0; index < ClipFrame.MaxPlanes; index++)
+ {
+ if (_gl.IsEnabled(EnableCap.ClipDistance0 + index))
+ clipBits |= 1 << index;
+ }
+
+ // Preserve the old tripwire boundary: consume the error that existed
+ // after the scissor/viewport/clip reads, before any of the state reads
+ // below can produce a probe-owned error.
+ int error = (int)_gl.GetError();
+
+ return new RenderGlStateSnapshot(
+ _gl.IsEnabled(EnableCap.DepthTest),
+ _gl.GetBoolean(GetPName.DepthWritemask),
+ _gl.GetInteger(GetPName.DepthFunc),
+ _gl.IsEnabled(EnableCap.Blend),
+ _gl.GetInteger(GetPName.BlendSrcRgb),
+ _gl.GetInteger(GetPName.BlendDstRgb),
+ _gl.IsEnabled(EnableCap.CullFace),
+ _gl.GetInteger(GetPName.CullFaceMode),
+ _gl.GetInteger(GetPName.FrontFace),
+ _gl.IsEnabled(EnableCap.ScissorTest),
+ new IntRenderRectangle(scissor[0], scissor[1], scissor[2], scissor[3]),
+ new IntRenderRectangle(viewport[0], viewport[1], viewport[2], viewport[3]),
+ _gl.GetInteger(GetPName.DrawFramebufferBinding),
+ _gl.IsEnabled(EnableCap.SampleAlphaToCoverage),
+ _gl.IsEnabled(EnableCap.StencilTest),
+ clipBits,
+ error);
+ }
+
+ public RenderGlScissorSnapshot CaptureScissor()
+ {
+ Span box = stackalloc int[4];
+ _gl.GetInteger(GetPName.ScissorBox, box);
+ return new RenderGlScissorSnapshot(
+ _gl.IsEnabled(EnableCap.ScissorTest),
+ new IntRenderRectangle(box[0], box[1], box[2], box[3]));
+ }
+}
+
+///
+/// Owns print-on-change world-render probes and their reusable scratch. Inputs
+/// are borrowed for one call; the owner retains only copied signatures and IDs.
+///
+internal sealed class WorldRenderDiagnostics
+{
+ private readonly IRenderGlStateReader _gl;
+ private readonly IRenderFrameDiagnosticLog _log;
+ private readonly HashSet _lastViewerFloodCells = [];
+ private readonly HashSet _outStageUnmatched = [];
+ private readonly HashSet _outStageMatched = [];
+ private readonly Stopwatch _terrainStopwatch = new();
+ private readonly RollingTimingSampleWindow _terrainSamples = new(256);
+ private string? _lastRenderSignature;
+ private int _renderSignatureFrame;
+ private int _renderSignatureStableFrames;
+ private string? _lastViewerSignature;
+ private string? _lastGlStateSignature;
+ private long _glStateFrame;
+ private long _glStateStableFrames;
+ private string? _lastScissorSignature;
+ private long _scissorSequence;
+ private string? _lastOutStageSignature;
+
+ public WorldRenderDiagnostics(
+ IRenderGlStateReader gl,
+ IRenderFrameDiagnosticLog log)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ }
+
+ public void BeginTerrainDraw() => _terrainStopwatch.Restart();
+
+ public void EndTerrainDraw()
+ {
+ _terrainStopwatch.Stop();
+ _terrainSamples.PushHundredthsMicroseconds(
+ (long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
+ }
+
+ public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts)
+ {
+ RollingTimingPercentiles timing = _terrainSamples.Snapshot();
+ double medianMicroseconds = timing.MedianHundredthsMicroseconds / 100.0;
+ double p95Microseconds = timing.Percentile95HundredthsMicroseconds / 100.0;
+ string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty;
+ _log.WriteLine(
+ $"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/"
+ + $"{p95Microseconds:F2}p95 draws={facts.VisibleSlots}/frame "
+ + $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
+ + $"capacity={facts.CapacitySlots}");
+ }
+
+ public void EmitGlStateTripwireIfChanged(bool enabled)
+ {
+ if (!enabled)
+ return;
+
+ _glStateFrame++;
+ string signature = FormatGlState(_gl.CaptureState());
+ if (signature == _lastGlStateSignature)
+ {
+ _glStateStableFrames++;
+ return;
+ }
+
+ _log.WriteLine(
+ $"[gl-state] frame={_glStateFrame} stable={_glStateStableFrames} {signature}");
+ _lastGlStateSignature = signature;
+ _glStateStableFrames = 0;
+ }
+
+ public void EmitClipRouteScissorProbe(
+ bool enabled,
+ bool applied,
+ Vector4 ndcAabb)
+ {
+ if (!enabled)
+ return;
+
+ RenderGlScissorSnapshot snapshot = _gl.CaptureScissor();
+ string signature = FormattableString.Invariant(
+ $"applied={(applied ? 1 : 0)} scis={(snapshot.Enabled ? 1 : 0)} box=({snapshot.Box.X},{snapshot.Box.Y},{snapshot.Box.Width},{snapshot.Box.Height}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})");
+ _scissorSequence++;
+ if (signature == _lastScissorSignature)
+ return;
+
+ _lastScissorSignature = signature;
+ _log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}");
+ }
+
+ public void EmitPViewInput(
+ bool enabled,
+ PortalVisibilityFrame portalFrame,
+ Matrix4x4 viewProjection,
+ bool outdoorRoot,
+ Vector3 eye,
+ Vector3 player,
+ Vector3 rawPlayer,
+ float yaw,
+ float? terrainHeight)
+ {
+ if (!enabled)
+ return;
+
+ string terrain = terrainHeight is { } height
+ ? FormattableString.Invariant(
+ $"terrZ={height:F3} eyeAbove={eye.Z - height:F3}")
+ : "terrZ=n/a eyeAbove=n/a";
+ char root = outdoorRoot ? 'Y' : 'n';
+ Matrix4x4 vp = viewProjection;
+ _log.WriteLine(FormattableString.Invariant(
+ $"[pv-input] outRoot={root} flood={portalFrame.OrderedVisibleCells.Count} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]"));
+ }
+
+ public void EmitOutStageParticles(
+ bool enabled,
+ ParticleSystem? particles,
+ IReadOnlySet ownerIds)
+ {
+ if (!enabled || particles is null)
+ return;
+
+ int matched = 0;
+ int attached = 0;
+ int unattached = 0;
+ _outStageUnmatched.Clear();
+ _outStageMatched.Clear();
+ foreach (var (emitter, _) in particles.EnumerateLive())
+ {
+ if (emitter.AttachedObjectId == 0)
+ {
+ unattached++;
+ continue;
+ }
+
+ attached++;
+ if (ownerIds.Contains(emitter.AttachedObjectId))
+ {
+ matched++;
+ if (_outStageMatched.Count < 48)
+ _outStageMatched.Add(emitter.AttachedObjectId);
+ }
+ else if (_outStageUnmatched.Count < 12)
+ {
+ _outStageUnmatched.Add(emitter.AttachedObjectId);
+ }
+ }
+
+ var unmatched = new StringBuilder(96);
+ foreach (uint id in _outStageUnmatched)
+ unmatched.Append(FormattableString.Invariant($" 0x{id:X8}"));
+ var matchedIds = new StringBuilder(192);
+ foreach (uint id in _outStageMatched)
+ matchedIds.Append(FormattableString.Invariant($" 0x{id:X8}"));
+ string signature = FormattableString.Invariant(
+ $"ids={ownerIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{matchedIds}] unmatchedIds=[{unmatched}]");
+ if (signature == _lastOutStageSignature)
+ return;
+
+ _lastOutStageSignature = signature;
+ _log.WriteLine("[outstage-pt] " + signature);
+ }
+
+ public void EmitRetailPViewDiagnostics(
+ bool viewerEnabled,
+ bool visibilityEnabled,
+ bool flapEnabled,
+ RetailPViewFrameResult result,
+ LoadedCell clipRoot,
+ uint viewerCellId,
+ uint playerCellId,
+ Vector3 cameraPosition,
+ Vector3 playerPosition,
+ Matrix4x4 cameraView,
+ CameraCellResolution cameraCellResolution)
+ {
+ if (viewerEnabled)
+ {
+ string signature = FormattableString.Invariant(
+ $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : string.Empty)} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}");
+ if (signature != _lastViewerSignature)
+ {
+ _lastViewerSignature = signature;
+ _log.WriteLine(FormattableString.Invariant(
+ $"[viewer] {signature} eye=({cameraPosition.X:F3},{cameraPosition.Y:F3},{cameraPosition.Z:F3}) fwd=({-cameraView.M13:F4},{-cameraView.M23:F4},{-cameraView.M33:F4}) viewerCell=0x{viewerCellId:X8}"));
+ EmitViewerDiff(result.PortalFrame.OrderedVisibleCells);
+ }
+ }
+
+ if (visibilityEnabled)
+ {
+ AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
+ clipRoot.CellId,
+ result.PortalFrame.OrderedVisibleCells,
+ result.PortalFrame.OutsideView.Polygons.Count,
+ result.ClipAssembly.OutsidePlaneCount,
+ result.ClipAssembly.PerCellPlaneCounts,
+ result.ClipAssembly.ScissorFallbacks);
+ }
+
+ if (flapEnabled)
+ {
+ bool eyeInRoot = CellVisibility.PointInCell(cameraPosition, clipRoot);
+ bool playerInRoot = CellVisibility.PointInCell(playerPosition, clipRoot);
+ _log.WriteLine(
+ $"[flap-cam] root=0x{clipRoot.CellId:X8} "
+ + $"viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} "
+ + $"res={cameraCellResolution} "
+ + $"eyeInRoot={(eyeInRoot ? "Y" : "n")} "
+ + $"playerInRoot={(playerInRoot ? "Y" : "n")} "
+ + $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) "
+ + $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) "
+ + $"terrain={result.ClipAssembly.TerrainMode} "
+ + $"outVisible={result.ClipAssembly.OutdoorVisible}");
+ }
+ }
+
+ public void EmitRenderSignatureIfChanged(
+ bool enabled,
+ string branch,
+ LoadedCell? clipRoot,
+ LoadedCell? viewerRoot,
+ LoadedCell? playerRoot,
+ uint viewerCellId,
+ uint playerCellId,
+ bool playerIndoorGate,
+ bool cameraInsideCell,
+ bool renderSkyGate,
+ bool drawSkyThisFrame,
+ bool terrainDrawn,
+ TerrainClipMode terrainClipMode,
+ bool skyDrawn,
+ bool depthClear,
+ bool outdoorSceneryDrawn,
+ bool outdoorPortalDrawn,
+ int outdoorRootObjectCount,
+ int liveDynamicDrawnCount,
+ string sceneParticles,
+ PortalVisibilityFrame? portalFrame,
+ ClipFrameAssembly? clipAssembly,
+ IReadOnlySet? drawableCells,
+ InteriorEntityPartition.Result? partition,
+ PortalVisibilityFrame? exteriorPortalFrame,
+ ClipFrameAssembly? exteriorClipAssembly,
+ IReadOnlySet? exteriorDrawableCells,
+ InteriorEntityPartition.Result? exteriorPartition,
+ Vector3 cameraPosition,
+ Vector3 playerPosition)
+ {
+ if (!enabled)
+ return;
+
+ _renderSignatureFrame++;
+ bool eyeInRoot = clipRoot is not null
+ && CellVisibility.PointInCell(cameraPosition, clipRoot);
+ bool playerInRoot = clipRoot is not null
+ && CellVisibility.PointInCell(playerPosition, clipRoot);
+
+ var text = new StringBuilder(512);
+ text.Append("branch=").Append(branch);
+ text.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8"));
+ text.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8"));
+ text.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8"));
+ text.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8"));
+ text.Append(" playerCell=0x").Append(playerCellId.ToString("X8"));
+ text.Append(" gate=").Append(playerIndoorGate ? "in" : "out");
+ text.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n');
+ text.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n');
+ text.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n');
+ text.Append(" eye=").Append(FormatVector(cameraPosition));
+ text.Append(" player=").Append(FormatVector(playerPosition));
+ text.Append(" terrain=").Append(terrainClipMode);
+ text.Append('/').Append(terrainDrawn ? "draw" : "skip");
+ text.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n');
+ text.Append(" sky=").Append(skyDrawn ? 'Y' : 'n');
+ text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
+ text.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
+ text.Append(" sceneParticles=").Append(sceneParticles);
+ if (clipAssembly is not null)
+ {
+ text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
+ text.Append(" outPolys=").Append(portalFrame?.OutsideView.Polygons.Count ?? 0);
+ text.Append(" outMode=").Append(clipAssembly.TerrainMode);
+ }
+ else
+ {
+ text.Append(" outSlices=0 outPolys=0 outMode=none");
+ }
+
+ text.Append(" ids=").Append(FormatIds(portalFrame?.OrderedVisibleCells, true));
+ text.Append(" draw=").Append(FormatIds(drawableCells, false));
+ text.Append(" miss=").Append(FormatMissingDrawableCells(portalFrame, drawableCells));
+ text.Append(" obj=").Append(FormatPartitionCounts(partition));
+ text.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
+ text.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount);
+ text.Append(" liveDynDraw=").Append(liveDynamicDrawnCount);
+ text.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n');
+ if (partition is not null)
+ {
+ int totalShells = 0;
+ int shellsWithMeshes = 0;
+ foreach (var entity in partition.OutdoorStatic)
+ {
+ if (!entity.IsBuildingShell)
+ continue;
+ totalShells++;
+ if (entity.MeshRefs.Count > 0)
+ shellsWithMeshes++;
+ }
+ text.Append(" bshell=").Append(totalShells).Append('/').Append(shellsWithMeshes);
+ }
+
+ if (outdoorPortalDrawn || exteriorPortalFrame is not null
+ || exteriorClipAssembly is not null)
+ {
+ text.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n');
+ text.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0);
+ text.Append(" extIds=").Append(FormatIds(
+ exteriorPortalFrame?.OrderedVisibleCells,
+ true));
+ text.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, false));
+ text.Append(" extMiss=").Append(FormatMissingDrawableCells(
+ exteriorPortalFrame,
+ exteriorDrawableCells));
+ text.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition));
+ }
+
+ string signature = text.ToString();
+ if (signature == _lastRenderSignature)
+ {
+ _renderSignatureStableFrames++;
+ return;
+ }
+
+ _log.WriteLine(
+ $"[render-sig] frame={_renderSignatureFrame} "
+ + $"stable={_renderSignatureStableFrames} {signature}");
+ _lastRenderSignature = signature;
+ _renderSignatureStableFrames = 0;
+ }
+
+ internal static string FormatGlState(RenderGlStateSnapshot state) =>
+ $"depth={(state.DepthTest ? 1 : 0)} "
+ + $"dmask={(state.DepthWrite ? 1 : 0)} "
+ + $"dfunc=0x{state.DepthFunction:X} "
+ + $"blend={(state.Blend ? 1 : 0)} "
+ + $"bsrc=0x{state.BlendSource:X} bdst=0x{state.BlendDestination:X} "
+ + $"cull={(state.CullFace ? 1 : 0)} cmode=0x{state.CullMode:X} "
+ + $"fface=0x{state.FrontFace:X} "
+ + $"scis={(state.Scissor ? 1 : 0)} "
+ + $"sbox=({state.ScissorBox.X},{state.ScissorBox.Y},"
+ + $"{state.ScissorBox.Width},{state.ScissorBox.Height}) "
+ + $"vp=({state.Viewport.X},{state.Viewport.Y},"
+ + $"{state.Viewport.Width},{state.Viewport.Height}) "
+ + $"fbo={state.DrawFramebuffer} "
+ + $"a2c={(state.AlphaToCoverage ? 1 : 0)} "
+ + $"stencil={(state.Stencil ? 1 : 0)} "
+ + $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}";
+
+ private void EmitViewerDiff(IReadOnlyList current)
+ {
+ var text = new StringBuilder(96);
+ text.Append("[viewer-diff] added=[");
+ bool first = true;
+ foreach (uint cell in current)
+ {
+ if (_lastViewerFloodCells.Contains(cell))
+ continue;
+ if (!first)
+ text.Append(',');
+ text.Append("0x").Append(cell.ToString("X8"));
+ first = false;
+ }
+
+ text.Append("] removed=[");
+ first = true;
+ foreach (uint cell in _lastViewerFloodCells)
+ {
+ bool present = false;
+ for (int index = 0; index < current.Count; index++)
+ {
+ if (current[index] == cell)
+ {
+ present = true;
+ break;
+ }
+ }
+ if (present)
+ continue;
+ if (!first)
+ text.Append(',');
+ text.Append("0x").Append(cell.ToString("X8"));
+ first = false;
+ }
+ text.Append(']');
+ _log.WriteLine(text.ToString());
+
+ _lastViewerFloodCells.Clear();
+ foreach (uint cell in current)
+ _lastViewerFloodCells.Add(cell);
+ }
+
+ private static string FormatVector(Vector3 value)
+ {
+ static float Quantize(float component) => MathF.Round(component * 20f) / 20f;
+ return $"({Quantize(value.X):F2},{Quantize(value.Y):F2},{Quantize(value.Z):F2})";
+ }
+
+ private static string FormatIds(IEnumerable? ids, bool preserveOrder)
+ {
+ if (ids is null)
+ return "[]";
+
+ var values = new List(ids);
+ if (!preserveOrder)
+ values.Sort();
+ var text = new StringBuilder(96).Append('[');
+ const int MaximumIds = 12;
+ for (int index = 0; index < values.Count && index < MaximumIds; index++)
+ {
+ if (index > 0)
+ text.Append(',');
+ text.Append("0x").Append(values[index].ToString("X8"));
+ }
+ if (values.Count > MaximumIds)
+ text.Append(",...");
+ return text.Append(']').ToString();
+ }
+
+ private static string FormatMissingDrawableCells(
+ PortalVisibilityFrame? portalFrame,
+ IReadOnlySet? drawableCells)
+ {
+ if (portalFrame is null || drawableCells is null)
+ return "[]";
+
+ var text = new StringBuilder(96).Append('[');
+ int written = 0;
+ const int MaximumCells = 8;
+ foreach (uint id in portalFrame.OrderedVisibleCells)
+ {
+ if (drawableCells.Contains(id))
+ continue;
+ if (written > 0)
+ text.Append(',');
+ text.Append("0x").Append(id.ToString("X8"));
+ if (portalFrame.CellViews.TryGetValue(id, out var view))
+ {
+ text.Append(":p").Append(view.Polygons.Count);
+ if (view.IsEmpty)
+ text.Append(":empty");
+ }
+ else
+ {
+ text.Append(":noView");
+ }
+ if (++written >= MaximumCells)
+ {
+ text.Append(",...");
+ break;
+ }
+ }
+ return text.Append(']').ToString();
+ }
+
+ private static string FormatPartitionCounts(InteriorEntityPartition.Result? partition)
+ {
+ if (partition is null)
+ return "cell=[] out=0 live=0";
+
+ var keys = new List(partition.ByCell.Keys);
+ keys.Sort();
+ var text = new StringBuilder(128).Append("cell=[");
+ const int MaximumCells = 10;
+ for (int index = 0; index < keys.Count && index < MaximumCells; index++)
+ {
+ uint id = keys[index];
+ if (index > 0)
+ text.Append(',');
+ text.Append("0x").Append(id.ToString("X8"))
+ .Append(':').Append(partition.ByCell[id].Count);
+ }
+ if (keys.Count > MaximumCells)
+ text.Append(",...");
+ return text.Append("] out=").Append(partition.OutdoorStatic.Count)
+ .Append(" live=").Append(partition.Dynamics.Count)
+ .ToString();
+ }
+}
diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
index 72a1842d..09f7b590 100644
--- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
+++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs
@@ -29,7 +29,7 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(controller.TryRequest("login_stable", out string error), error);
Assert.False(controller.IsComplete("login_stable"));
- controller.CapturePending();
+ Assert.True(controller.CapturePending());
Assert.True(controller.IsComplete("login_stable"));
string path = Path.Combine(directory, "login_stable.png");
@@ -48,6 +48,29 @@ public sealed class WorldLifecycleAutomationControllerTests
}
}
+ [Fact]
+ public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
+ {
+ string directory = NewDirectory();
+ var controller = new FrameScreenshotController(
+ () => (0, 0),
+ (_, _) => [],
+ directory);
+
+ try
+ {
+ Assert.False(controller.CapturePending());
+ Assert.True(controller.TryRequest("bad_frame", out string error), error);
+ Assert.False(controller.CapturePending());
+ Assert.False(controller.IsComplete("bad_frame"));
+ }
+ finally
+ {
+ if (Directory.Exists(directory))
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
[Theory]
[InlineData("")]
[InlineData("../escape")]
diff --git a/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs b/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs
new file mode 100644
index 00000000..dc07f0e7
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs
@@ -0,0 +1,167 @@
+using System.Numerics;
+using System.Reflection;
+using AcDream.App.Rendering;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class DebugVmRenderFactsPublisherTests
+{
+ [Fact]
+ public void Defaults_MatchThePriorGameWindowCache()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+
+ Assert.Equal(DebugVmRenderFacts.Initial, publisher.DebugVmFacts);
+ Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance);
+ Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel);
+ Assert.False(publisher.DebugVmFacts.Colliding);
+ }
+
+ [Fact]
+ public void InactiveConsumer_DoesNotEnumerateOrReplacePriorFacts()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+ publisher.PublishDebugVmFacts(
+ true,
+ 3,
+ 8,
+ Vector3.Zero,
+ [Entry(1, new Vector3(5, 0, 100), radius: 1)]);
+ DebugVmRenderFacts prior = publisher.DebugVmFacts;
+ var shadows = new CountingEnumerable([Entry(2, Vector3.One, 1)]);
+
+ publisher.PublishDebugVmFacts(
+ false,
+ 99,
+ 100,
+ new Vector3(90, 90, 90),
+ shadows);
+
+ Assert.Equal(0, shadows.EnumerationCount);
+ Assert.Equal(prior, publisher.DebugVmFacts);
+ }
+
+ [Fact]
+ public void Publish_UsesHorizontalDistanceAndSubtractsBothRadii()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+ var origin = new Vector3(10, 20, -500);
+
+ publisher.PublishDebugVmFacts(
+ true,
+ 7,
+ 11,
+ origin,
+ [
+ Entry(0xA, new Vector3(16, 28, 500), radius: 1.52f),
+ Entry(0xB, new Vector3(30, 20, -500), radius: 2f),
+ ]);
+
+ DebugVmRenderFacts facts = publisher.DebugVmFacts;
+ Assert.Equal(7, facts.VisibleLandblocks);
+ Assert.Equal(11, facts.TotalLandblocks);
+ Assert.Equal(8.0f, facts.NearestObjectDistance, precision: 5);
+ Assert.Equal("0x0000000A Cylinder", facts.NearestObjectLabel);
+ Assert.False(facts.Colliding);
+ }
+
+ [Fact]
+ public void Publish_ClampsOverlapToZeroAndUsesUnclampedDistanceForCollision()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+
+ publisher.PublishDebugVmFacts(
+ true,
+ 1,
+ 2,
+ Vector3.Zero,
+ [Entry(0x1234, new Vector3(1, 0, 200), radius: 0.6f)]);
+
+ Assert.Equal(0f, publisher.DebugVmFacts.NearestObjectDistance);
+ Assert.True(publisher.DebugVmFacts.Colliding);
+ Assert.Equal("0x00001234 Cylinder", publisher.DebugVmFacts.NearestObjectLabel);
+ }
+
+ [Fact]
+ public void Publish_DistanceAboveContactThresholdIsClearAndTieKeepsFirstEntry()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+ float centerDistance = 1f + DebugVmRenderFactsPublisher.PlayerCollisionRadius
+ + DebugVmRenderFactsPublisher.ContactThreshold + 0.001f;
+
+ publisher.PublishDebugVmFacts(
+ true,
+ 0,
+ 0,
+ Vector3.Zero,
+ [
+ Entry(0x11, new Vector3(centerDistance, 0, 0), 1f),
+ Entry(0x22, new Vector3(0, centerDistance, 0), 1f),
+ ]);
+
+ Assert.True(publisher.DebugVmFacts.NearestObjectDistance
+ > DebugVmRenderFactsPublisher.ContactThreshold);
+ Assert.False(publisher.DebugVmFacts.Colliding);
+ Assert.Equal("0x00000011 Cylinder", publisher.DebugVmFacts.NearestObjectLabel);
+ }
+
+ [Fact]
+ public void Publish_EmptySequenceRestoresTheEmptyNearestFacts()
+ {
+ var publisher = new DebugVmRenderFactsPublisher();
+ publisher.PublishDebugVmFacts(
+ true,
+ 1,
+ 1,
+ Vector3.Zero,
+ [Entry(1, Vector3.Zero, 1)]);
+
+ publisher.PublishDebugVmFacts(true, 4, 9, Vector3.Zero, []);
+
+ Assert.Equal(4, publisher.DebugVmFacts.VisibleLandblocks);
+ Assert.Equal(9, publisher.DebugVmFacts.TotalLandblocks);
+ Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance);
+ Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel);
+ Assert.False(publisher.DebugVmFacts.Colliding);
+ }
+
+ [Fact]
+ public void Publisher_RetainsNoRuntimeOwnerDelegateOrBorrowedSequence()
+ {
+ FieldInfo[] fields = typeof(DebugVmRenderFactsPublisher).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.Single(fields);
+ Assert.Equal(typeof(DebugVmRenderFacts), fields[0].FieldType);
+ Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
+ Assert.DoesNotContain(fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ Assert.DoesNotContain(fields, field =>
+ typeof(IEnumerable).IsAssignableFrom(field.FieldType));
+ Assert.True(typeof(IDebugVmRenderFactsSource).IsAssignableFrom(
+ typeof(DebugVmRenderFactsPublisher)));
+ }
+
+ private static ShadowEntry Entry(uint id, Vector3 position, float radius) => new(
+ EntityId: id,
+ GfxObjId: 0,
+ Position: position,
+ Rotation: Quaternion.Identity,
+ Radius: radius,
+ CollisionType: ShadowCollisionType.Cylinder);
+
+ private sealed class CountingEnumerable(IReadOnlyList entries)
+ : IEnumerable
+ {
+ public int EnumerationCount { get; private set; }
+
+ public IEnumerator GetEnumerator()
+ {
+ EnumerationCount++;
+ return entries.GetEnumerator();
+ }
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
+ GetEnumerator();
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
new file mode 100644
index 00000000..b28279a3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
@@ -0,0 +1,333 @@
+using System.Numerics;
+using System.Reflection;
+using AcDream.App.Diagnostics;
+using AcDream.App.Rendering;
+using AcDream.UI.Abstractions;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class DevToolsFramePresenterTests
+{
+ [Fact]
+ public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
+ {
+ var calls = new List();
+ 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.Render(0.1, 800, 600);
+ commands.Current = second;
+ 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.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.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.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 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.False(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 calls) : IDevToolsFrameBackend
+ {
+ public HashSet OpenMenus { get; } = [];
+ public HashSet ClickedItems { get; } = [];
+ public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
+ public List Contexts { get; } = [];
+ public List Layouts { get; } = [];
+
+ public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}");
+
+ 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() => calls.Add("draw-data");
+
+ 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 command) where T : notnull
+ {
+ }
+ }
+
+ private sealed class RecordingPanels : IDevToolsPanelSet
+ {
+ private readonly HashSet _visible = [];
+
+ public bool HasSettings { get; init; } = true;
+ public List Toggles { get; } = [];
+ public List 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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs
new file mode 100644
index 00000000..fc01780b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs
@@ -0,0 +1,167 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class GameWindowRenderLeafCompositionTests
+{
+ [Fact]
+ public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder()
+ {
+ string source = GameWindowSource();
+
+ AssertAppearsInOrder(
+ source,
+ "_localPlayerTeleport?.DrawPortalViewport(",
+ "_paperdollFramePresenter?.Render();",
+ "_retailUiRuntime.Tick(deltaSeconds);",
+ "_devToolsFramePresenter?.Render(",
+ "_frameScreenshots?.CapturePending() == true;",
+ "_renderFrameDiagnostics?.Publish(");
+ }
+
+ [Fact]
+ public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce()
+ {
+ string source = GameWindowSource();
+
+ AssertAppearsInOrder(
+ source,
+ "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
+ "Weather.Tick(nowSeconds: _weatherAccum",
+ "_retailSelectionScene?.BeginFrame();");
+ Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame("));
+ }
+
+ [Fact]
+ public void ProductionComposition_RemovesLegacyLeafOwnership()
+ {
+ string source = GameWindowSource();
+
+ string[] removed =
+ [
+ "_imguiBootstrap",
+ "_panelHost",
+ "_paperdollDollDirty",
+ "RefreshPaperdollDoll",
+ "ApplyPaperdollPose",
+ "ResolvePaperdollPoseDid",
+ "EnumerateDebugPanel",
+ "ResetPanelLayout",
+ "SetPanelLayout",
+ "_lastRenderSignature",
+ "private void EmitRenderSignatureIfChanged(",
+ "private void EmitRetailPViewDiagnostics(",
+ "EmitGlStateTripwireIfChanged();",
+ "EmitClipRouteScissorProbe(scissor",
+ "_lastVisibleLandblocks",
+ "_perfAccum",
+ ];
+ foreach (string identifier in removed)
+ Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
+
+ Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
+ Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
+ Assert.Contains("_inputCapture.WantCaptureMouse", source);
+ }
+
+ [Fact]
+ public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
+ {
+ string source = GameWindowSource();
+
+ AssertAppearsInOrder(
+ source,
+ "new ResourceShutdownStage(\"submitted GPU work\"",
+ "new ResourceShutdownStage(\"render frontends\"",
+ "new(\"portal tunnel\"",
+ "new(\"paperdoll viewport\"",
+ "new ResourceShutdownStage(\"OpenGL context\"");
+ Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
+ }
+
+ [Fact]
+ public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts()
+ {
+ string source = GameWindowSource();
+
+ Assert.Contains("bool normalWorldDrawn = false;", source);
+ AssertAppearsInOrder(
+ source,
+ "if (IsLiveModeWaitingForLogin)",
+ "goto SkipWorldGeometry;",
+ "normalWorldDrawn = true;",
+ "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
+ "normalWorldDrawn),",
+ "screenshotCaptured)));");
+ }
+
+ [Fact]
+ public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface()
+ {
+ string source = GameWindowSource();
+
+ AssertAppearsInOrder(
+ source,
+ "PaperdollViewportWidget is { } paperdollViewport",
+ "InventoryFrame is { } paperdollInventoryFrame",
+ "new AcDream.App.Rendering.PaperdollFramePresenter(");
+ Assert.DoesNotContain("Paperdoll inventory frame is required.", source);
+ }
+
+ [Fact]
+ public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites()
+ {
+ string source = GameWindowSource();
+
+ AssertAppearsInOrder(
+ source,
+ "_worldRenderDiagnostics?.PublishTerrainDiagnostics(",
+ "PublishFrameDiagnostics();",
+ "_frameDiagLastPublicationMilliseconds = now;");
+ }
+
+ private static string GameWindowSource() => File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "GameWindow.cs"));
+
+ private static void AssertAppearsInOrder(string source, params string[] needles)
+ {
+ int cursor = -1;
+ foreach (string needle in needles)
+ {
+ int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
+ Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
+ Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
+ cursor = next;
+ }
+ }
+
+ private static int CountOccurrences(string source, string needle)
+ {
+ int count = 0;
+ int cursor = 0;
+ while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ cursor += needle.Length;
+ }
+
+ return count;
+ }
+
+ 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.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
new file mode 100644
index 00000000..87dc0270
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
@@ -0,0 +1,256 @@
+using System.Numerics;
+using System.Reflection;
+using AcDream.App.Rendering;
+using AcDream.App.Input;
+using AcDream.Core.World;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class PaperdollFramePresenterTests
+{
+ [Fact]
+ public void HiddenView_DoesNotBuildOrRender()
+ {
+ var renderer = new RecordingRenderer();
+ var view = new RecordingView { Visible = false };
+ var factory = new RecordingFactory();
+ var presenter = new PaperdollFramePresenter(renderer, view, factory);
+
+ presenter.Render();
+
+ Assert.True(presenter.IsDirty);
+ Assert.Equal(0, factory.BuildCount);
+ Assert.Equal(0, renderer.RenderCount);
+ Assert.Empty(view.TextureHandles);
+ }
+
+ [Fact]
+ public void FirstVisibleFrame_BuildsThenRendersAndPublishesTexture()
+ {
+ var doll = CreateDoll();
+ var renderer = new RecordingRenderer { TextureHandle = 91u };
+ var view = new RecordingView { Width = 240, Height = 320 };
+ var factory = new RecordingFactory { Doll = doll };
+ var presenter = new PaperdollFramePresenter(renderer, view, factory);
+
+ presenter.Render();
+
+ Assert.False(presenter.IsDirty);
+ Assert.Equal(1, factory.BuildCount);
+ Assert.Same(doll, renderer.Dolls.Single());
+ Assert.Equal([(240, 320)], renderer.RenderSizes);
+ Assert.Equal([91u], view.TextureHandles);
+ }
+
+ [Fact]
+ public void CleanFrame_ReusesDollUntilMarkedDirty()
+ {
+ var renderer = new RecordingRenderer();
+ var view = new RecordingView();
+ var factory = new RecordingFactory
+ {
+ Doll = CreateDoll(),
+ };
+ var presenter = new PaperdollFramePresenter(renderer, view, factory);
+
+ presenter.Render();
+ presenter.Render();
+ presenter.MarkDirty();
+ presenter.Render();
+
+ Assert.Equal(2, factory.BuildCount);
+ Assert.Equal(2, renderer.Dolls.Count);
+ Assert.Equal(3, renderer.RenderCount);
+ Assert.False(presenter.IsDirty);
+ }
+
+ private static WorldEntity CreateDoll() => new()
+ {
+ Id = 42u,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ [Fact]
+ public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries()
+ {
+ var renderer = new RecordingRenderer { TextureHandle = 0u };
+ var view = new RecordingView();
+ var factory = new RecordingFactory { CanBuild = false };
+ var presenter = new PaperdollFramePresenter(renderer, view, factory);
+
+ presenter.Render();
+ presenter.Render();
+
+ Assert.True(presenter.IsDirty);
+ Assert.Equal(2, factory.BuildCount);
+ Assert.Equal(2, renderer.Dolls.Count);
+ Assert.All(renderer.Dolls, Assert.Null);
+ Assert.Equal(2, renderer.RenderCount);
+ Assert.Equal([0u, 0u], view.TextureHandles);
+ }
+
+ [Fact]
+ public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference()
+ {
+ Type[] owners =
+ [
+ typeof(PaperdollFramePresenter),
+ typeof(RetailPaperdollFrameView),
+ typeof(PaperdollInventoryVisibility),
+ typeof(LivePaperdollEntityLookup),
+ typeof(RetailPaperdollDollFactory),
+ typeof(RetailPaperdollPoseApplicator),
+ ];
+
+ 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 => field.FieldType == typeof(AcDream.App.UI.RetailUiRuntime));
+ Assert.DoesNotContain(
+ fields,
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ }
+ }
+
+ [Fact]
+ public void RetailFactory_MissingOrEmptyCurrentPlayerRetriesWithoutApplyingPose()
+ {
+ var entities = new RecordingEntityLookup();
+ var identity = new LocalPlayerIdentityState { ServerGuid = 7u };
+ var pose = new RecordingPoseApplicator();
+ var factory = new RetailPaperdollDollFactory(entities, identity, pose);
+
+ Assert.False(factory.TryBuild(out WorldEntity? missing));
+ Assert.Null(missing);
+
+ entities.Entities[7u] = CreateDoll();
+ Assert.False(factory.TryBuild(out WorldEntity? empty));
+ Assert.Null(empty);
+ Assert.Empty(pose.Applications);
+ }
+
+ [Fact]
+ public void RetailFactory_ResolvesCurrentIdentityAndClonesLiveAppearance()
+ {
+ var firstMesh = new MeshRef(0x01000001u, Matrix4x4.Identity)
+ {
+ SurfaceOverrides = new Dictionary { [2] = 0x08000001u },
+ };
+ var secondMesh = new MeshRef(
+ 0x01000002u,
+ Matrix4x4.CreateTranslation(1f, 2f, 3f));
+ var first = CreatePlayer(0x02000001u, firstMesh);
+ var second = CreatePlayer(0x02000002u, secondMesh);
+ var entities = new RecordingEntityLookup
+ {
+ Entities =
+ {
+ [10u] = first,
+ [20u] = second,
+ },
+ };
+ var identity = new LocalPlayerIdentityState { ServerGuid = 10u };
+ var pose = new RecordingPoseApplicator();
+ var factory = new RetailPaperdollDollFactory(entities, identity, pose);
+
+ Assert.True(factory.TryBuild(out WorldEntity? firstDoll));
+ identity.ServerGuid = 20u;
+ Assert.True(factory.TryBuild(out WorldEntity? secondDoll));
+
+ Assert.NotNull(firstDoll);
+ Assert.NotNull(secondDoll);
+ Assert.Equal(0x02000001u, firstDoll.SourceGfxObjOrSetupId);
+ Assert.Equal(0x02000002u, secondDoll.SourceGfxObjOrSetupId);
+ Assert.NotSame(first.MeshRefs, firstDoll.MeshRefs);
+ Assert.Equal(firstMesh.GfxObjId, firstDoll.MeshRefs[0].GfxObjId);
+ Assert.Same(firstMesh.SurfaceOverrides, firstDoll.MeshRefs[0].SurfaceOverrides);
+ Assert.Equal(
+ [(firstDoll, 0x02000001u), (secondDoll, 0x02000002u)],
+ pose.Applications);
+ Assert.Equal([10u, 20u], entities.RequestedGuids);
+ }
+
+ private static WorldEntity CreatePlayer(uint setupId, MeshRef mesh) => new()
+ {
+ Id = setupId,
+ SourceGfxObjOrSetupId = setupId,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = new List { mesh },
+ };
+
+ private sealed class RecordingRenderer : IPaperdollDollRenderer
+ {
+ public uint TextureHandle { get; init; }
+ public List Dolls { get; } = [];
+ public List<(int Width, int Height)> RenderSizes { get; } = [];
+ public int RenderCount => RenderSizes.Count;
+
+ public void SetDoll(WorldEntity? doll) => Dolls.Add(doll);
+
+ public uint Render(int width, int height)
+ {
+ RenderSizes.Add((width, height));
+ return TextureHandle;
+ }
+ }
+
+ private sealed class RecordingView : IPaperdollFrameView
+ {
+ public bool Visible { get; init; } = true;
+ public int Width { get; init; } = 100;
+ public int Height { get; init; } = 120;
+ public List TextureHandles { get; } = [];
+
+ public bool TryGetVisibleSize(out int width, out int height)
+ {
+ width = Width;
+ height = Height;
+ return Visible;
+ }
+
+ public void SetTextureHandle(uint textureHandle) =>
+ TextureHandles.Add(textureHandle);
+ }
+
+ private sealed class RecordingFactory : IPaperdollDollFactory
+ {
+ public bool CanBuild { get; init; } = true;
+ public WorldEntity? Doll { get; init; }
+ public int BuildCount { get; private set; }
+
+ public bool TryBuild(out WorldEntity? doll)
+ {
+ BuildCount++;
+ doll = Doll;
+ return CanBuild;
+ }
+ }
+
+ private sealed class RecordingEntityLookup : IPaperdollEntityLookup
+ {
+ public Dictionary Entities { get; } = [];
+ public List RequestedGuids { get; } = [];
+
+ public bool TryGet(uint serverGuid, out WorldEntity player)
+ {
+ RequestedGuids.Add(serverGuid);
+ return Entities.TryGetValue(serverGuid, out player!);
+ }
+ }
+
+ private sealed class RecordingPoseApplicator : IPaperdollPoseApplicator
+ {
+ public List<(WorldEntity Doll, uint SetupId)> Applications { get; } = [];
+
+ public void Apply(WorldEntity doll, uint setupId) =>
+ Applications.Add((doll, setupId));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs
new file mode 100644
index 00000000..2cada03b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs
@@ -0,0 +1,321 @@
+using System.Globalization;
+using System.Reflection;
+using AcDream.App.Rendering;
+using AcDream.Core.World;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class RenderFrameDiagnosticsControllerTests
+{
+ private static readonly RenderFrameOutcome Outcome = new(
+ new WorldRenderFrameOutcome(
+ VisibleLandblocks: 17,
+ TotalLandblocks: 43,
+ NormalWorldDrawn: true),
+ new PrivatePresentationFrameOutcome(
+ PortalViewportDrawn: false,
+ ScreenshotCaptured: true));
+
+ private static readonly RenderFrameTitleFacts TitleFacts = new(
+ EntityCount: 3721,
+ AnimatedEntityCount: 226,
+ Calendar: new DerethDateTime.Calendar(
+ 117,
+ DerethDateTime.MonthName.Leafcull,
+ 22,
+ DerethDateTime.HourName.DawnsongAndHalf),
+ DayFraction: 0.3125);
+
+ [Fact]
+ public void Snapshot_StartsAtTheAcceptedRuntimeDefaults()
+ {
+ var harness = new Harness(resourceDump: false);
+
+ Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
+ Assert.Equal(60.0, harness.Controller.Snapshot.Fps);
+ Assert.Equal(16.7, harness.Controller.Snapshot.FrameMilliseconds);
+ Assert.Empty(harness.Calls);
+ }
+
+ [Fact]
+ public void Publish_BelowHalfSecondDoesNotSampleOrPublish()
+ {
+ var harness = new Harness(resourceDump: true);
+
+ harness.Publish(0.499999);
+
+ Assert.Empty(harness.Calls);
+ Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
+ }
+
+ [Fact]
+ public void Publish_AtHalfSecondPublishesTitleThenResourcesThenSnapshot()
+ {
+ using var culture = new CultureScope(CultureInfo.InvariantCulture);
+ var harness = new Harness(resourceDump: true);
+
+ harness.Publish(0.2);
+ harness.Publish(0.3);
+
+ Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
+ Assert.Equal(
+ "acdream | 4 fps | 250.0 ms | lb 17/43 | ent 3721/anim 226 | "
+ + "PY117 Leafcull 22 DawnsongAndHalf (df=0.3125)",
+ harness.Title);
+ Assert.Equal(
+ new RenderFrameDiagnosticsSnapshot(
+ Fps: 4.0,
+ FrameMilliseconds: 250.0,
+ VisibleLandblocks: 17,
+ TotalLandblocks: 43,
+ EntityCount: 3721,
+ AnimatedEntityCount: 226),
+ harness.Controller.Snapshot);
+ Assert.Equal(
+ RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts),
+ harness.Line);
+ }
+
+ [Fact]
+ public void Publish_WhenResourceDumpIsOffDoesNotCaptureResources()
+ {
+ var harness = new Harness(resourceDump: false);
+
+ harness.Publish(0.5);
+
+ Assert.Equal(["facts", "title"], harness.Calls);
+ Assert.Null(harness.Line);
+ }
+
+ [Fact]
+ public void Publish_DiscardsCadenceOvershootAfterSuccessfulPublication()
+ {
+ var harness = new Harness(resourceDump: false);
+
+ harness.Publish(0.6);
+ harness.Calls.Clear();
+ harness.Publish(0.4);
+
+ Assert.Empty(harness.Calls);
+ Assert.Equal(1.0 / 0.6, harness.Controller.Snapshot.Fps, precision: 10);
+
+ harness.Publish(0.1);
+
+ Assert.Equal(["facts", "title"], harness.Calls);
+ Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
+ Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10);
+ }
+
+ [Fact]
+ public void TitleFailure_DoesNotCaptureResourcesPublishSnapshotOrResetWindow()
+ {
+ var harness = new Harness(resourceDump: true)
+ {
+ TitleFailure = new InvalidOperationException("title"),
+ };
+
+ InvalidOperationException actual = Assert.Throws(
+ () => harness.Publish(0.5));
+
+ Assert.Same(harness.TitleFailure, actual);
+ Assert.Equal(["facts", "title"], harness.Calls);
+ Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
+
+ harness.TitleFailure = null;
+ harness.Calls.Clear();
+ harness.Publish(0.0);
+
+ Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
+ Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
+ Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10);
+ }
+
+ [Fact]
+ public void ResourceLogFailure_LeavesPublishedCacheAndCadenceUntouched()
+ {
+ var harness = new Harness(resourceDump: true)
+ {
+ LogFailure = new InvalidOperationException("log"),
+ };
+
+ InvalidOperationException actual = Assert.Throws(
+ () => harness.Publish(0.5));
+
+ Assert.Same(harness.LogFailure, actual);
+ Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
+ Assert.NotNull(harness.Title);
+ Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot);
+
+ harness.LogFailure = null;
+ harness.Calls.Clear();
+ harness.Publish(0.0);
+
+ Assert.Equal(["facts", "title", "resources", "log"], harness.Calls);
+ Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10);
+ }
+
+ [Fact]
+ public void Constructor_RequiresEveryActiveNarrowSeam()
+ {
+ var facts = new FixedTitleFactsSource([], TitleFacts);
+ var title = new RecordingTitleSink([]);
+ var log = new RecordingLog([]);
+ var resources = new RecordingResources([], ResourceFacts);
+
+ Assert.Throws(() => new RenderFrameDiagnosticsController(
+ null!, title, log, false));
+ Assert.Throws(() => new RenderFrameDiagnosticsController(
+ facts, null!, log, false));
+ Assert.Throws(() => new RenderFrameDiagnosticsController(
+ facts, title, null!, false));
+ Assert.Throws(() => new RenderFrameDiagnosticsController(
+ facts, title, log, true));
+
+ _ = new RenderFrameDiagnosticsController(facts, title, log, false, resources: null);
+ _ = new RenderFrameDiagnosticsController(facts, title, log, true, resources);
+ }
+
+ [Fact]
+ public void Controller_RetainsOnlyNarrowTypedSeamsAndValueState()
+ {
+ FieldInfo[] fields = typeof(RenderFrameDiagnosticsController).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.FullName?.Contains("Silk.NET.Windowing.IWindow", StringComparison.Ordinal) == true);
+ Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleFactsSource));
+ Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleSink));
+ Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameDiagnosticLog));
+ Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameResourceDiagnosticsSource));
+ Assert.True(typeof(IRenderFrameDiagnosticsPhase).IsAssignableFrom(
+ typeof(RenderFrameDiagnosticsController)));
+ }
+
+ [Fact]
+ public void GpuStreamFormatter_PreservesTheAcceptedFieldOrderAndShape()
+ {
+ string actual = RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts);
+
+ Assert.Equal(
+ "[gpu-stream] emit=1 particles=2 bindings=3 wbSets=4 cellSets=5 "
+ + "particleSets=6/7B uiBytes=8 portalBytes=9 clipSets=10 terrainBuffers=11 "
+ + "lightBuffers=12 mesh=13/14/15 meshEst=16 meshUpload=17/18 "
+ + "uploadFrame=19/20/21/22/23 bufferFrame=24/25/26/27 "
+ + "staging=28/29/True/30 cpuMesh=31/32 mipmapFrame=33/34 meshCap=35 "
+ + "meshPhys=36/True gpuTrack=57/58/59 managed=55/56 ownedTextures=37/38 "
+ + "particleTextures=39/40/41/42/43 compositeCache=44/45/46 "
+ + "compositeAtlas=47/48 compositeUpload=49/50/51",
+ actual);
+ }
+
+ private sealed class Harness
+ {
+ public readonly List Calls = [];
+ public readonly RenderFrameDiagnosticsController Controller;
+ private readonly RecordingTitleSink _title;
+ private readonly RecordingLog _log;
+
+ public Harness(bool resourceDump)
+ {
+ _title = new RecordingTitleSink(Calls);
+ _log = new RecordingLog(Calls);
+ Controller = new RenderFrameDiagnosticsController(
+ new FixedTitleFactsSource(Calls, TitleFacts),
+ _title,
+ _log,
+ resourceDump,
+ new RecordingResources(Calls, ResourceFacts));
+ }
+
+ public Exception? TitleFailure
+ {
+ get => _title.Failure;
+ set => _title.Failure = value;
+ }
+
+ public Exception? LogFailure
+ {
+ get => _log.Failure;
+ set => _log.Failure = value;
+ }
+
+ public string? Title => _title.Title;
+ public string? Line => _log.Line;
+
+ public void Publish(double deltaSeconds) => Controller.Publish(
+ new RenderFrameInput(deltaSeconds, 1920, 1080),
+ Outcome);
+ }
+
+ private sealed class FixedTitleFactsSource(
+ List calls,
+ RenderFrameTitleFacts facts) : IRenderFrameTitleFactsSource
+ {
+ public RenderFrameTitleFacts Capture()
+ {
+ calls.Add("facts");
+ return facts;
+ }
+ }
+
+ private sealed class RecordingTitleSink(List calls) : IRenderFrameTitleSink
+ {
+ public Exception? Failure { get; set; }
+ public string? Title { get; private set; }
+
+ public void SetTitle(string title)
+ {
+ calls.Add("title");
+ if (Failure is not null)
+ throw Failure;
+ Title = title;
+ }
+ }
+
+ private sealed class RecordingResources(
+ List calls,
+ RenderFrameResourceDiagnosticsSnapshot snapshot)
+ : IRenderFrameResourceDiagnosticsSource
+ {
+ public RenderFrameResourceDiagnosticsSnapshot Capture()
+ {
+ calls.Add("resources");
+ return snapshot;
+ }
+ }
+
+ private sealed class RecordingLog(List calls) : IRenderFrameDiagnosticLog
+ {
+ public Exception? Failure { get; set; }
+ public string? Line { get; private set; }
+
+ public void WriteLine(string message)
+ {
+ calls.Add("log");
+ if (Failure is not null)
+ throw Failure;
+ Line = message;
+ }
+ }
+
+ private sealed class CultureScope : IDisposable
+ {
+ private readonly CultureInfo _prior = CultureInfo.CurrentCulture;
+
+ public CultureScope(CultureInfo culture) => CultureInfo.CurrentCulture = culture;
+
+ public void Dispose() => CultureInfo.CurrentCulture = _prior;
+ }
+
+ private static readonly RenderFrameResourceDiagnosticsSnapshot ResourceFacts = new(
+ new VfxStreamResourceDiagnostics(1, 2, 3),
+ new DynamicBufferResourceDiagnostics(4, 5, 6, 7, 8, 9, 10, 11, 12),
+ new MeshStreamResourceDiagnostics(
+ 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
+ 24, 25, 26, 27, 28, 29, true, 30, 31, 32, 33, 34, 35, 36, true),
+ new TextureStreamResourceDiagnostics(
+ 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51),
+ new ProcessResourceDiagnostics(55, 56, 57, 58, 59));
+}
diff --git a/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs b/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs
new file mode 100644
index 00000000..dec284d5
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs
@@ -0,0 +1,66 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class RollingTimingSampleWindowTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Constructor_RejectsNonPositiveCapacity(int capacity)
+ {
+ Assert.Throws(
+ () => new RollingTimingSampleWindow(capacity));
+ }
+
+ [Fact]
+ public void Snapshot_EmptyOrNonPositiveSamplesReturnsZeroes()
+ {
+ var samples = new RollingTimingSampleWindow(4);
+ samples.PushHundredthsMicroseconds(0);
+ samples.PushHundredthsMicroseconds(-10);
+
+ Assert.Equal(default, samples.Snapshot());
+ }
+
+ [Fact]
+ public void Snapshot_UsesTheAcceptedUpperMedianAndP95Rules()
+ {
+ var samples = new RollingTimingSampleWindow(8);
+ samples.PushHundredthsMicroseconds(100);
+ samples.PushHundredthsMicroseconds(200);
+
+ RollingTimingPercentiles snapshot = samples.Snapshot();
+
+ Assert.Equal(200, snapshot.MedianHundredthsMicroseconds);
+ Assert.Equal(200, snapshot.Percentile95HundredthsMicroseconds);
+ }
+
+ [Fact]
+ public void Push_WrapsAndRetainsOnlyTheNewestCapacitySamples()
+ {
+ var samples = new RollingTimingSampleWindow(3);
+ samples.PushHundredthsMicroseconds(100);
+ samples.PushHundredthsMicroseconds(200);
+ samples.PushHundredthsMicroseconds(300);
+ samples.PushHundredthsMicroseconds(400);
+
+ RollingTimingPercentiles snapshot = samples.Snapshot();
+
+ Assert.Equal(3, samples.Capacity);
+ Assert.Equal(300, snapshot.MedianHundredthsMicroseconds);
+ Assert.Equal(400, snapshot.Percentile95HundredthsMicroseconds);
+ }
+
+ [Fact]
+ public void PushStopwatchTicks_UsesHundredthsOfAMicrosecondUnits()
+ {
+ var samples = new RollingTimingSampleWindow(1);
+
+ samples.PushStopwatchTicks(System.Diagnostics.Stopwatch.Frequency);
+
+ Assert.Equal(
+ new RollingTimingPercentiles(100_000_000, 100_000_000),
+ samples.Snapshot());
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
new file mode 100644
index 00000000..319bd3d8
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
@@ -0,0 +1,292 @@
+using System.Numerics;
+using System.Reflection;
+using AcDream.App.Rendering;
+using AcDream.Core.Vfx;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class WorldRenderDiagnosticsTests
+{
+ [Fact]
+ public void GlTripwire_DisabledPerformsNoGlRead()
+ {
+ var gl = new RecordingGlStateReader();
+ var log = new RecordingLog();
+ var diagnostics = new WorldRenderDiagnostics(gl, log);
+
+ diagnostics.EmitGlStateTripwireIfChanged(enabled: false);
+
+ Assert.Equal(0, gl.StateCaptureCount);
+ Assert.Empty(log.Messages);
+ }
+
+ [Fact]
+ public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount()
+ {
+ var gl = new RecordingGlStateReader
+ {
+ State = State(depthFunction: 0x201),
+ };
+ var log = new RecordingLog();
+ var diagnostics = new WorldRenderDiagnostics(gl, log);
+
+ diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
+ diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
+ gl.State = State(depthFunction: 0x203);
+ diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
+
+ Assert.Equal(3, gl.StateCaptureCount);
+ Assert.Collection(
+ log.Messages,
+ first =>
+ {
+ Assert.StartsWith("[gl-state] frame=1 stable=0", first);
+ Assert.Contains("dfunc=0x201", first);
+ },
+ changed =>
+ {
+ Assert.StartsWith("[gl-state] frame=3 stable=1", changed);
+ Assert.Contains("dfunc=0x203", changed);
+ });
+ }
+
+ [Fact]
+ public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates()
+ {
+ var gl = new RecordingGlStateReader
+ {
+ Scissor = new RenderGlScissorSnapshot(
+ true,
+ new IntRenderRectangle(1, 2, 3, 4)),
+ };
+ var log = new RecordingLog();
+ var diagnostics = new WorldRenderDiagnostics(gl, log);
+
+ diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
+ diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
+ gl.Scissor = new RenderGlScissorSnapshot(
+ true,
+ new IntRenderRectangle(5, 6, 7, 8));
+ diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
+
+ Assert.Equal(3, gl.ScissorCaptureCount);
+ Assert.Collection(
+ log.Messages,
+ first => Assert.StartsWith("[clip-route-scis] n=1", first),
+ changed => Assert.StartsWith("[clip-route-scis] n=3", changed));
+ }
+
+ [Fact]
+ public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners()
+ {
+ FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Type[] forbidden =
+ [
+ typeof(GameWindow),
+ typeof(PortalVisibilityFrame),
+ typeof(ClipFrameAssembly),
+ typeof(InteriorEntityPartition.Result),
+ typeof(AcDream.Core.Physics.PhysicsEngine),
+ typeof(CameraController),
+ typeof(CellVisibility),
+ ];
+ foreach (Type type in forbidden)
+ Assert.DoesNotContain(fields, field => field.FieldType == type);
+ Assert.DoesNotContain(
+ fields,
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ }
+
+ [Fact]
+ public void SilkGlReader_ConsumesEnteringErrorBeforeStateQueries()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "WorldRenderDiagnostics.cs"));
+
+ int captureStart = source.IndexOf(
+ "public RenderGlStateSnapshot CaptureState()",
+ StringComparison.Ordinal);
+ int error = source.IndexOf("_gl.GetError()", captureStart, StringComparison.Ordinal);
+ int depth = source.IndexOf(
+ "_gl.IsEnabled(EnableCap.DepthTest)",
+ captureStart,
+ StringComparison.Ordinal);
+
+ Assert.True(captureStart >= 0);
+ Assert.True(error > captureStart);
+ Assert.True(depth > error);
+ }
+
+ [Fact]
+ public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
+ {
+ var log = new ThrowOnceLog();
+ var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
+ var facts = new TerrainRenderDiagnosticFacts(3, 5, 8);
+
+ diagnostics.BeginTerrainDraw();
+ diagnostics.EndTerrainDraw();
+ Assert.Throws(() =>
+ diagnostics.PublishTerrainDiagnostics(facts));
+
+ diagnostics.BeginTerrainDraw();
+ diagnostics.EndTerrainDraw();
+ diagnostics.PublishTerrainDiagnostics(facts);
+
+ Assert.Single(log.Messages);
+ Assert.Contains("draws=3/frame", log.Messages[0]);
+ Assert.Contains("visible=3", log.Messages[0]);
+ Assert.Contains("loaded=5", log.Messages[0]);
+ Assert.Contains("capacity=8", log.Messages[0]);
+ }
+
+ [Fact]
+ public void RenderSignature_LogsOnlyChangesAndCarriesStableCount()
+ {
+ var log = new RecordingLog();
+ var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
+
+ EmitMinimalSignature(diagnostics, "flat");
+ EmitMinimalSignature(diagnostics, "flat");
+ EmitMinimalSignature(diagnostics, "pview");
+
+ Assert.Collection(
+ log.Messages,
+ first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first),
+ changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed));
+ }
+
+ [Fact]
+ public void OutStageParticleProbe_IsDisabledWithoutEnumerationAndSuppressesDuplicates()
+ {
+ var log = new RecordingLog();
+ var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
+ var particles = new ParticleSystem(new EmitterDescRegistry());
+
+ diagnostics.EmitOutStageParticles(false, particles, new HashSet());
+ diagnostics.EmitOutStageParticles(true, particles, new HashSet());
+ diagnostics.EmitOutStageParticles(true, particles, new HashSet());
+
+ Assert.Single(log.Messages);
+ Assert.Equal(
+ "[outstage-pt] ids=0 attachedEmitters=0 matched=0 unattached=0 matchedIds=[] unmatchedIds=[]",
+ log.Messages[0]);
+ }
+
+ private static RenderGlStateSnapshot State(int depthFunction) => new(
+ DepthTest: true,
+ DepthWrite: true,
+ DepthFunction: depthFunction,
+ Blend: false,
+ BlendSource: 1,
+ BlendDestination: 0,
+ CullFace: true,
+ CullMode: 0x405,
+ FrontFace: 0x900,
+ Scissor: false,
+ ScissorBox: new IntRenderRectangle(0, 0, 1280, 720),
+ Viewport: new IntRenderRectangle(0, 0, 1280, 720),
+ DrawFramebuffer: 0,
+ AlphaToCoverage: false,
+ Stencil: false,
+ ClipBits: 0,
+ Error: 0);
+
+ private sealed class RecordingGlStateReader : IRenderGlStateReader
+ {
+ public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201);
+ public RenderGlScissorSnapshot Scissor { get; set; }
+ public int StateCaptureCount { get; private set; }
+ public int ScissorCaptureCount { get; private set; }
+
+ public RenderGlStateSnapshot CaptureState()
+ {
+ StateCaptureCount++;
+ return State;
+ }
+
+ public RenderGlScissorSnapshot CaptureScissor()
+ {
+ ScissorCaptureCount++;
+ return Scissor;
+ }
+ }
+
+ private sealed class RecordingLog : IRenderFrameDiagnosticLog
+ {
+ public List Messages { get; } = [];
+
+ public void WriteLine(string message) => Messages.Add(message);
+ }
+
+ private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
+ {
+ private bool _throw = true;
+ public List Messages { get; } = [];
+
+ public void WriteLine(string message)
+ {
+ if (_throw)
+ {
+ _throw = false;
+ throw new InvalidOperationException("diagnostic sink failed");
+ }
+
+ Messages.Add(message);
+ }
+ }
+
+ private static void EmitMinimalSignature(
+ WorldRenderDiagnostics diagnostics,
+ string branch) =>
+ diagnostics.EmitRenderSignatureIfChanged(
+ enabled: true,
+ branch: branch,
+ clipRoot: null,
+ viewerRoot: null,
+ playerRoot: null,
+ viewerCellId: 0,
+ playerCellId: 0,
+ playerIndoorGate: false,
+ cameraInsideCell: false,
+ renderSkyGate: false,
+ drawSkyThisFrame: false,
+ terrainDrawn: false,
+ terrainClipMode: TerrainClipMode.Skip,
+ skyDrawn: false,
+ depthClear: false,
+ outdoorSceneryDrawn: false,
+ outdoorPortalDrawn: false,
+ outdoorRootObjectCount: 0,
+ liveDynamicDrawnCount: 0,
+ sceneParticles: "none",
+ portalFrame: null,
+ clipAssembly: null,
+ drawableCells: null,
+ partition: null,
+ exteriorPortalFrame: null,
+ exteriorClipAssembly: null,
+ exteriorDrawableCells: null,
+ exteriorPartition: null,
+ cameraPosition: Vector3.Zero,
+ playerPosition: Vector3.Zero);
+
+ 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.");
+ }
+}