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."); + } +}