refactor(render): extract frame presentation diagnostics
This commit is contained in:
parent
7e4cfb37c3
commit
733126a272
20 changed files with 3767 additions and 1021 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -76,10 +76,10 @@ internal sealed class FrameScreenshotController
|
|||
&& status.State == CaptureState.Complete;
|
||||
|
||||
/// <summary>Captures at most one queued image on the current GL thread.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs
Normal file
79
src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Same-frame render facts consumed by the developer DebugVM.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the facts immediately before private presentation. A disabled
|
||||
/// consumer leaves the prior snapshot untouched and does not enumerate the
|
||||
/// borrowed shadow-object sequence.
|
||||
/// </summary>
|
||||
public void PublishDebugVmFacts(
|
||||
bool consumerActive,
|
||||
int visibleLandblocks,
|
||||
int totalLandblocks,
|
||||
Vector3 nearestOrigin,
|
||||
IEnumerable<ShadowEntry>? 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);
|
||||
}
|
||||
}
|
||||
387
src/AcDream.App/Rendering/DevToolsFramePresenter.cs
Normal file
387
src/AcDream.App/Rendering/DevToolsFramePresenter.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Mutable late-composition seam for player-mode menu operations.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Typed panel operations used by menu and input presentation.</summary>
|
||||
internal sealed class DevToolsPanelSet : IDevToolsPanelSet
|
||||
{
|
||||
private readonly VitalsPanel _vitals;
|
||||
private readonly ChatPanel _chat;
|
||||
private readonly DebugPanel _debug;
|
||||
private readonly DebugVM _debugViewModel;
|
||||
private readonly SettingsPanel? _settings;
|
||||
|
||||
public DevToolsPanelSet(
|
||||
VitalsPanel vitals,
|
||||
ChatPanel chat,
|
||||
DebugPanel debug,
|
||||
DebugVM debugViewModel,
|
||||
SettingsPanel? settings)
|
||||
{
|
||||
_vitals = vitals ?? throw new ArgumentNullException(nameof(vitals));
|
||||
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
_debug = debug ?? throw new ArgumentNullException(nameof(debug));
|
||||
_debugViewModel = debugViewModel
|
||||
?? throw new ArgumentNullException(nameof(debugViewModel));
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public bool Contains(DevToolsPanelKind kind) =>
|
||||
kind != DevToolsPanelKind.Settings || _settings is not null;
|
||||
|
||||
public string? GetTitle(DevToolsPanelKind kind) => GetPanel(kind)?.Title;
|
||||
|
||||
public bool IsVisible(DevToolsPanelKind kind) => GetPanel(kind)?.IsVisible == true;
|
||||
|
||||
public void Toggle(DevToolsPanelKind kind)
|
||||
{
|
||||
if (GetPanel(kind) is { } panel)
|
||||
panel.IsVisible = !panel.IsVisible;
|
||||
}
|
||||
|
||||
public void FocusChatInput() => _chat.FocusInput();
|
||||
|
||||
public void AddDebugToast(string message) => _debugViewModel.AddToast(message);
|
||||
|
||||
private IPanel? GetPanel(DevToolsPanelKind kind) => kind switch
|
||||
{
|
||||
DevToolsPanelKind.Vitals => _vitals,
|
||||
DevToolsPanelKind.Chat => _chat,
|
||||
DevToolsPanelKind.Debug => _debug,
|
||||
DevToolsPanelKind.Settings => _settings,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the optional ImGui developer frame, menu policy, panel actions, and
|
||||
/// reusable default layout. Retained gameplay UI remains a separate earlier
|
||||
/// presentation phase.
|
||||
/// </summary>
|
||||
internal sealed class DevToolsFramePresenter
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
309
src/AcDream.App/Rendering/PaperdollFramePresenter.cs
Normal file
309
src/AcDream.App/Rendering/PaperdollFramePresenter.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Canonical live-entity lookup used by the paperdoll factory.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the static retail paperdoll clone from the canonical live player
|
||||
/// projection and applies the DAT-defined held pose.
|
||||
/// </summary>
|
||||
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<MeshRef>(player.MeshRefs),
|
||||
basePalette,
|
||||
subPalettes,
|
||||
partOverrides);
|
||||
_pose.Apply(doll, player.SourceGfxObjOrSetupId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies retail's DAT-defined settled paperdoll stance.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
|
||||
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
|
||||
/// Animation DID.
|
||||
/// </summary>
|
||||
private uint ResolvePoseDid()
|
||||
{
|
||||
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
||||
masterDid,
|
||||
out var master)
|
||||
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
||||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
||||
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<DatReaderWriter.DBObjs.Setup>(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<MeshRef>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,22 +12,24 @@ namespace AcDream.App.Rendering;
|
|||
/// <summary>
|
||||
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
|
||||
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
|
||||
/// built by <see cref="DollEntityBuilder"/> + wired by GameWindow) into a private off-screen
|
||||
/// built by <see cref="DollEntityBuilder"/> + wired by <see cref="PaperdollFramePresenter"/>) into a private off-screen
|
||||
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
|
||||
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
|
||||
///
|
||||
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> 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.</para>
|
||||
///
|
||||
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
|
||||
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
|
||||
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
|
||||
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a STATIC pose
|
||||
/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.</para>
|
||||
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a static held pose.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
|
||||
/// <summary>Set (or clear) the doll entity built from the live player projection.</summary>
|
||||
public void SetDoll(WorldEntity? doll)
|
||||
{
|
||||
if (ReferenceEquals(_doll, doll))
|
||||
|
|
|
|||
168
src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
Normal file
168
src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs
Normal file
|
|
@ -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<LiveEntityAnimationState> _animations;
|
||||
private readonly WorldTimeService _time;
|
||||
|
||||
public RuntimeRenderFrameTitleFactsSource(
|
||||
GpuWorldState world,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only sampler for the explicit, low-frequency GPU-stream diagnostic.
|
||||
/// It borrows canonical owners and returns one immutable value snapshot.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
269
src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
Normal file
269
src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Current values published by the successful render-frame cadence.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Non-render facts sampled only when the title cadence publishes.</summary>
|
||||
internal readonly record struct RenderFrameTitleFacts(
|
||||
int EntityCount,
|
||||
int AnimatedEntityCount,
|
||||
DerethDateTime.Calendar Calendar,
|
||||
double DayFraction);
|
||||
|
||||
internal interface IRenderFrameTitleFactsSource
|
||||
{
|
||||
RenderFrameTitleFacts Capture();
|
||||
}
|
||||
|
||||
/// <summary>Narrow host seam; diagnostics can publish chrome without retaining a window.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
|
||||
VfxStreamResourceDiagnostics Vfx,
|
||||
DynamicBufferResourceDiagnostics DynamicBuffers,
|
||||
MeshStreamResourceDiagnostics Mesh,
|
||||
TextureStreamResourceDiagnostics Textures,
|
||||
ProcessResourceDiagnostics Process);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes post-presentation frame statistics. The caller invokes this only after
|
||||
/// screenshot capture, preserving the accepted screenshot-before-title/resource order.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
54
src/AcDream.App/Rendering/RollingTimingSampleWindow.cs
Normal file
54
src/AcDream.App/Rendering/RollingTimingSampleWindow.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal readonly record struct RollingTimingPercentiles(
|
||||
long MedianHundredthsMicroseconds,
|
||||
long Percentile95HundredthsMicroseconds);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-capacity rolling timing distribution. Zero and negative values remain
|
||||
/// empty padding and do not dilute the diagnostic percentiles.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
593
src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
Normal file
593
src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Render-thread GL state reader used only by explicitly enabled probes.</summary>
|
||||
internal sealed class SilkRenderGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
private readonly GL _gl;
|
||||
|
||||
public SilkRenderGlStateReader(GL gl) =>
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
||||
public RenderGlStateSnapshot CaptureState()
|
||||
{
|
||||
Span<int> scissor = stackalloc int[4];
|
||||
Span<int> 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<int> 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]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class WorldRenderDiagnostics
|
||||
{
|
||||
private readonly IRenderGlStateReader _gl;
|
||||
private readonly IRenderFrameDiagnosticLog _log;
|
||||
private readonly HashSet<uint> _lastViewerFloodCells = [];
|
||||
private readonly HashSet<uint> _outStageUnmatched = [];
|
||||
private readonly HashSet<uint> _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<uint> 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<uint>? drawableCells,
|
||||
InteriorEntityPartition.Result? partition,
|
||||
PortalVisibilityFrame? exteriorPortalFrame,
|
||||
ClipFrameAssembly? exteriorClipAssembly,
|
||||
IReadOnlySet<uint>? 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<uint> 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<uint>? ids, bool preserveOrder)
|
||||
{
|
||||
if (ids is null)
|
||||
return "[]";
|
||||
|
||||
var values = new List<uint>(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<uint>? 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<uint>(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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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<ShadowEntry>).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<ShadowEntry> entries)
|
||||
: IEnumerable<ShadowEntry>
|
||||
{
|
||||
public int EnumerationCount { get; private set; }
|
||||
|
||||
public IEnumerator<ShadowEntry> GetEnumerator()
|
||||
{
|
||||
EnumerationCount++;
|
||||
return entries.GetEnumerator();
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
|
||||
GetEnumerator();
|
||||
}
|
||||
}
|
||||
333
tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
Normal file
333
tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs
Normal file
|
|
@ -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<string>();
|
||||
var backend = new RecordingBackend(calls);
|
||||
var commands = new RecordingCommandSource();
|
||||
var presenter = Create(backend, commands: commands);
|
||||
|
||||
presenter.BeginFrame(0.25f);
|
||||
presenter.Render(0.5, 1280, 720);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"begin:0.25",
|
||||
"begin-bar",
|
||||
"begin-menu:View",
|
||||
"begin-menu:Camera",
|
||||
"end-bar",
|
||||
"panels:0.5",
|
||||
"draw-data",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(commands.Current, backend.Contexts.Single().Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ResolvesTheCurrentCommandBusEveryFrame()
|
||||
{
|
||||
var backend = new RecordingBackend([]);
|
||||
var first = new RecordingCommandBus();
|
||||
var second = new RecordingCommandBus();
|
||||
var commands = new RecordingCommandSource { Current = first };
|
||||
var presenter = Create(backend, commands: commands);
|
||||
|
||||
presenter.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<string> calls) : IDevToolsFrameBackend
|
||||
{
|
||||
public HashSet<string> OpenMenus { get; } = [];
|
||||
public HashSet<string> ClickedItems { get; } = [];
|
||||
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
|
||||
public List<PanelContext> Contexts { get; } = [];
|
||||
public List<LayoutCallWithCondition> Layouts { get; } = [];
|
||||
|
||||
public 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>(T command) where T : notnull
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPanels : IDevToolsPanelSet
|
||||
{
|
||||
private readonly HashSet<DevToolsPanelKind> _visible = [];
|
||||
|
||||
public bool HasSettings { get; init; } = true;
|
||||
public List<DevToolsPanelKind> Toggles { get; } = [];
|
||||
public List<string> Toasts { get; } = [];
|
||||
public int FocusChatCount { get; private set; }
|
||||
|
||||
public bool Contains(DevToolsPanelKind kind) =>
|
||||
kind != DevToolsPanelKind.Settings || HasSettings;
|
||||
|
||||
public string? GetTitle(DevToolsPanelKind kind) => Contains(kind)
|
||||
? kind.ToString()
|
||||
: null;
|
||||
|
||||
public bool IsVisible(DevToolsPanelKind kind) => _visible.Contains(kind);
|
||||
|
||||
public void Toggle(DevToolsPanelKind kind)
|
||||
{
|
||||
if (!Contains(kind))
|
||||
return;
|
||||
Toggles.Add(kind);
|
||||
if (!_visible.Add(kind))
|
||||
_visible.Remove(kind);
|
||||
}
|
||||
|
||||
public void FocusChatInput() => FocusChatCount++;
|
||||
|
||||
public void AddDebugToast(string message) => Toasts.Add(message);
|
||||
}
|
||||
|
||||
private readonly record struct LayoutCall(
|
||||
string Title,
|
||||
Vector2 Position,
|
||||
Vector2 Size);
|
||||
|
||||
private readonly record struct LayoutCallWithCondition(
|
||||
string Title,
|
||||
Vector2 Position,
|
||||
Vector2 Size,
|
||||
DevToolsPanelLayoutCondition Condition)
|
||||
{
|
||||
public LayoutCall WithoutCondition() => new(Title, Position, Size);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MeshRef>(),
|
||||
};
|
||||
|
||||
[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<uint, uint> { [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<MeshRef> { mesh },
|
||||
};
|
||||
|
||||
private sealed class RecordingRenderer : IPaperdollDollRenderer
|
||||
{
|
||||
public uint TextureHandle { get; init; }
|
||||
public List<WorldEntity?> 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<uint> 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<uint, WorldEntity> Entities { get; } = [];
|
||||
public List<uint> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
|
||||
null!, title, log, false));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
|
||||
facts, null!, log, false));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameDiagnosticsController(
|
||||
facts, title, null!, false));
|
||||
Assert.Throws<ArgumentNullException>(() => 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<string> 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<string> calls,
|
||||
RenderFrameTitleFacts facts) : IRenderFrameTitleFactsSource
|
||||
{
|
||||
public RenderFrameTitleFacts Capture()
|
||||
{
|
||||
calls.Add("facts");
|
||||
return facts;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingTitleSink(List<string> 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<string> calls,
|
||||
RenderFrameResourceDiagnosticsSnapshot snapshot)
|
||||
: IRenderFrameResourceDiagnosticsSource
|
||||
{
|
||||
public RenderFrameResourceDiagnosticsSnapshot Capture()
|
||||
{
|
||||
calls.Add("resources");
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLog(List<string> 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));
|
||||
}
|
||||
|
|
@ -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<ArgumentOutOfRangeException>(
|
||||
() => 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());
|
||||
}
|
||||
}
|
||||
292
tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
Normal file
292
tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(() =>
|
||||
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<uint>());
|
||||
diagnostics.EmitOutStageParticles(true, particles, new HashSet<uint>());
|
||||
diagnostics.EmitOutStageParticles(true, particles, new HashSet<uint>());
|
||||
|
||||
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<string> Messages { get; } = [];
|
||||
|
||||
public void WriteLine(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
private bool _throw = true;
|
||||
public List<string> 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.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue