refactor(render): extract frame presentation diagnostics

This commit is contained in:
Erik 2026-07-22 05:02:31 +02:00
parent 7e4cfb37c3
commit 733126a272
20 changed files with 3767 additions and 1021 deletions

View file

@ -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 |

View file

@ -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,

View file

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

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

View 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

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

View file

@ -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))

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

View 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}";
}
}

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

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

View file

@ -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")]

View file

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

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

View file

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

View file

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

View file

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

View file

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

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