diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs index 4f625bf3..f2fbb600 100644 --- a/src/AcDream.App/Diagnostics/FrameProfiler.cs +++ b/src/AcDream.App/Diagnostics/FrameProfiler.cs @@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable private bool _wbDiagNoticePrinted; private bool _wasEnabled; + /// Most recent immutable report line, for explicit automation checkpoints. + public string? LastReport { get; private set; } + public FrameProfiler() { _stageUs = new FrameStatsBuffer[StageCount]; @@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable int gc0 = GC.CollectionCount(0) - _gc0Base; int gc1 = GC.CollectionCount(1) - _gc1Base; int gc2 = GC.CollectionCount(2) - _gc2Base; - Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs, - gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs)); + LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs, + gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs); + Console.WriteLine(LastReport); _lastReportTicks = nowTicks; _gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2; _framesInWindow = 0; diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs new file mode 100644 index 00000000..e1f4fcc5 --- /dev/null +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -0,0 +1,148 @@ +using Silk.NET.OpenGL; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace AcDream.App.Diagnostics; + +/// +/// Render-thread owner for diagnostic captures of the complete default +/// framebuffer. Requests may be made while retained UI ticks; capture occurs +/// later in the same frame after world, retained UI, and optional ImGui draw. +/// +internal sealed class FrameScreenshotController +{ + private enum CaptureState + { + Pending, + Complete, + Failed, + } + + private sealed record CaptureStatus(CaptureState State, string? Error = null); + + private readonly Func<(int Width, int Height)> _getSize; + private readonly Func _readRgba; + private readonly string _directory; + private readonly Action _log; + private readonly Queue _pending = new(); + private readonly Dictionary _status = + new(StringComparer.OrdinalIgnoreCase); + + public FrameScreenshotController( + GL gl, + Func<(int Width, int Height)> getSize, + string directory, + Action? log = null) + : this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) + { + ArgumentNullException.ThrowIfNull(gl); + } + + internal FrameScreenshotController( + Func<(int Width, int Height)> getSize, + Func readRgba, + string directory, + Action? log = null) + { + _getSize = getSize ?? throw new ArgumentNullException(nameof(getSize)); + _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); + _directory = string.IsNullOrWhiteSpace(directory) + ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) + : Path.GetFullPath(directory); + _log = log ?? (_ => { }); + } + + public bool TryRequest(string name, out string error) + { + if (!AutomationArtifactName.TryValidate(name, out error)) + return false; + + if (_status.TryGetValue(name, out CaptureStatus? status)) + { + if (status.State != CaptureState.Failed) + return true; + error = status.Error ?? $"screenshot '{name}' failed"; + return false; + } + + _status.Add(name, new CaptureStatus(CaptureState.Pending)); + _pending.Enqueue(name); + _log($"[world-gate] screenshot-request name={name}"); + return true; + } + + public bool IsComplete(string name) => + _status.TryGetValue(name, out CaptureStatus? status) + && status.State == CaptureState.Complete; + + /// Captures at most one queued image on the current GL thread. + public void CapturePending() + { + if (_pending.Count == 0) + return; + + string name = _pending.Dequeue(); + try + { + (int width, int height) = _getSize(); + if (width <= 0 || height <= 0) + throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); + + byte[] pixels = _readRgba(width, height); + int expected = checked(width * height * 4); + if (pixels.Length != expected) + throw new InvalidOperationException( + $"framebuffer read returned {pixels.Length} bytes; expected {expected}"); + + byte[] flipped = FlipRows(pixels, width, height); + Directory.CreateDirectory(_directory); + string path = Path.Combine(_directory, name + ".png"); + string temporaryPath = path + ".tmp"; + using (Image image = Image.LoadPixelData(flipped, width, height)) + image.SaveAsPng(temporaryPath); + File.Move(temporaryPath, path, overwrite: true); + + _status[name] = new CaptureStatus(CaptureState.Complete); + _log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}"); + } + 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}"); + } + } + + internal static byte[] FlipRows(byte[] pixels, int width, int height) + { + int stride = checked(width * 4); + byte[] flipped = new byte[pixels.Length]; + for (int row = 0; row < height; row++) + { + System.Buffer.BlockCopy( + pixels, + row * stride, + flipped, + (height - 1 - row) * stride, + stride); + } + return flipped; + } + + private static unsafe byte[] ReadDefaultFramebuffer(GL gl, int width, int height) + { + byte[] pixels = new byte[checked(width * height * 4)]; + fixed (byte* pointer = pixels) + { + gl.ReadPixels( + 0, + 0, + (uint)width, + (uint)height, + PixelFormat.Rgba, + PixelType.UnsignedByte, + pointer); + } + return pixels; + } +} diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs new file mode 100644 index 00000000..e6712a7f --- /dev/null +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using AcDream.App.Streaming; +using AcDream.App.UI.Testing; + +namespace AcDream.App.Diagnostics; + +internal static class AutomationArtifactName +{ + public static bool TryValidate(string? name, out string error) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 80) + { + error = "artifact name must contain 1-80 characters"; + return false; + } + + foreach (char c in name) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_') + { + error = $"artifact name '{name}' may contain only letters, digits, '-' and '_'"; + return false; + } + } + + error = string.Empty; + return true; + } +} + +internal sealed record WorldLifecycleResourceSnapshot( + int LoadedLandblocks, + int WorldEntities, + int AnimatedEntities, + int VisibleLandblocks, + int TotalLandblocks, + int LiveEntities, + int MaterializedLiveEntities, + int PendingLiveTeardowns, + int PendingLandblockRetirements, + int ParticleEmitters, + int Particles, + int ParticleBindings, + int ParticleOwners, + int EffectOwners, + int LightOwners, + int ScriptOwners, + int ActiveScripts, + int MeshRenderData, + int MeshAtlasArrays, + long MeshEstimatedBytes, + int StagedMeshUploads, + long StagedMeshBytes, + long TrackedGpuBytes, + int TrackedGpuBuffers, + int TrackedGpuTextures, + int OwnedCompositeTextures, + int CompositeTextureOwners, + int ActiveParticleTextures, + int ParticleTextureOwners, + int CompositeWarmupPending, + long ManagedBytes, + long ManagedCommittedBytes, + double Fps, + double FrameMilliseconds, + string? LastFrameProfile); + +internal sealed record WorldLifecycleCheckpoint( + int Sequence, + string Name, + DateTime TimestampUtc, + int ProcessId, + WorldRevealLifecycleSnapshot Reveal, + WorldLifecycleResourceSnapshot Resources); + +/// +/// Diagnostic-only runtime seam used by production retained-UI scripts. It +/// writes one structured checkpoint at explicit script edges and delegates GL +/// capture to the render-thread screenshot owner. +/// +internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly Func _getReveal; + private readonly Func _getPortalMaterializationCount; + private readonly Func _captureResources; + private readonly FrameScreenshotController _screenshots; + private readonly string _artifactDirectory; + private readonly Action _log; + private int _sequence; + + public WorldLifecycleAutomationController( + Func getReveal, + Func getPortalMaterializationCount, + Func captureResources, + FrameScreenshotController screenshots, + string artifactDirectory, + Action? log = null) + { + _getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal)); + _getPortalMaterializationCount = getPortalMaterializationCount + ?? throw new ArgumentNullException(nameof(getPortalMaterializationCount)); + _captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources)); + _screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots)); + _artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory) + ? throw new ArgumentException("An automation artifact directory is required.", nameof(artifactDirectory)) + : Path.GetFullPath(artifactDirectory); + _log = log ?? (_ => { }); + } + + public bool IsWorldReady => _getReveal().IsReady; + public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved; + public int PortalMaterializationCount => _getPortalMaterializationCount(); + + public bool TryWriteCheckpoint(string name, out string error) + { + if (!AutomationArtifactName.TryValidate(name, out error)) + return false; + + try + { + Directory.CreateDirectory(_artifactDirectory); + var checkpoint = new WorldLifecycleCheckpoint( + Sequence: checked(++_sequence), + Name: name, + TimestampUtc: DateTime.UtcNow, + ProcessId: Environment.ProcessId, + Reveal: _getReveal(), + Resources: _captureResources()); + string json = JsonSerializer.Serialize(checkpoint, JsonOptions); + string timelinePath = Path.Combine(_artifactDirectory, "world-lifecycle.checkpoints.jsonl"); + File.AppendAllText(timelinePath, json + Environment.NewLine); + File.WriteAllText( + Path.Combine(_artifactDirectory, $"checkpoint-{name}.json"), + json + Environment.NewLine); + _log($"[world-gate] checkpoint name={name} sequence={checkpoint.Sequence} path={timelinePath}"); + error = string.Empty; + return true; + } + catch (Exception exception) + { + error = $"checkpoint '{name}' failed: {exception.Message}"; + return false; + } + } + + public bool TryRequestScreenshot(string name, out string error) => + _screenshots.TryRequest(name, out error); + + public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1ffb0a79..548eec6e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -134,6 +134,8 @@ public sealed class GameWindow : IDisposable // per OnRender + three stage scopes. All logic lives in // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); + private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; + private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private ResourceShutdownTransaction? _shutdown; private readonly FramePacingController _framePacing = new(); @@ -2275,6 +2277,24 @@ public sealed class GameWindow : IDisposable ScreenSize: () => (_window.Size.X, _window.Size.Y)); void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); + if (_options.UiProbeEnabled + && _options.AutomationArtifactDirectory is { } artifactDirectory) + { + _frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController( + _gl!, + () => (_window!.Size.X, _window.Size.Y), + System.IO.Path.Combine(artifactDirectory, "screenshots"), + UiProbeLog); + _worldLifecycleAutomation = + new AcDream.App.Diagnostics.WorldLifecycleAutomationController( + () => _worldRevealTelemetry.Snapshot, + () => _worldRevealTelemetry.PortalMaterializationCount, + CaptureWorldLifecycleResourceSnapshot, + _frameScreenshots, + artifactDirectory, + UiProbeLog); + } + _retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount( new AcDream.App.UI.RetailUiRuntimeBindings( Host: _uiHost, @@ -2426,7 +2446,8 @@ public sealed class GameWindow : IDisposable UiProbeLog, action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, (action, held) => - _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true))); + _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true, + _worldLifecycleAutomation))); } @@ -11226,6 +11247,11 @@ public sealed class GameWindow : IDisposable } } + // Explicit diagnostic captures are requested by the retained-UI + // automation tick above and executed only after every presentation + // layer has drawn into the back buffer on this render thread. + _frameScreenshots?.CapturePending(); + // Update the window title with performance stats every ~0.5s. _perfAccum += deltaSeconds; _perfFrameCount++; @@ -11235,6 +11261,8 @@ public sealed class GameWindow : IDisposable double fps = _perfFrameCount / _perfAccum; int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; + _lastVisibleLandblocks = visibleLandblocks; + _lastTotalLandblocks = totalLandblocks; // Keep the developer diagnostic title independent from retail's // /framerate switch. That command owns the in-world SmartBox readout; @@ -11346,6 +11374,50 @@ public sealed class GameWindow : IDisposable } } + private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot + CaptureWorldLifecycleResourceSnapshot() + { + var meshManager = _wbMeshAdapter?.MeshManager; + var mesh = meshManager?.Diagnostics ?? default; + GCMemoryInfo memory = GC.GetGCMemoryInfo(); + return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot( + LoadedLandblocks: _worldState.LoadedLandblockIds.Count, + WorldEntities: _worldState.Entities.Count, + AnimatedEntities: _animatedEntities.Count, + VisibleLandblocks: _lastVisibleLandblocks, + TotalLandblocks: _lastTotalLandblocks, + LiveEntities: _liveEntities?.Count ?? 0, + MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, + PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, + PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0, + ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0, + Particles: _particleSystem?.ActiveParticleCount ?? 0, + ParticleBindings: _particleSink?.ActiveBindingCount ?? 0, + ParticleOwners: _particleSink?.TrackedOwnerCount ?? 0, + EffectOwners: _entityEffects?.ReadyOwnerCount ?? 0, + LightOwners: _liveEntityLights?.TrackedOwnerCount ?? 0, + ScriptOwners: _scriptRunner?.ActiveOwnerCount ?? 0, + ActiveScripts: _scriptRunner?.ActiveScriptCount ?? 0, + MeshRenderData: mesh.RenderData, + MeshAtlasArrays: mesh.AtlasArrays, + MeshEstimatedBytes: mesh.EstimatedBytes, + StagedMeshUploads: _wbMeshAdapter?.StagedUploadBacklog ?? 0, + StagedMeshBytes: _wbMeshAdapter?.StagedUploadBytes ?? 0, + TrackedGpuBytes: AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes, + TrackedGpuBuffers: AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount, + TrackedGpuTextures: AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount, + OwnedCompositeTextures: _textureCache?.OwnedBindlessTextureCount ?? 0, + CompositeTextureOwners: _textureCache?.TextureOwnerCount ?? 0, + ActiveParticleTextures: _textureCache?.ActiveParticleTextureCount ?? 0, + ParticleTextureOwners: _textureCache?.ParticleTextureOwnerCount ?? 0, + CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0, + ManagedBytes: GC.GetTotalMemory(forceFullCollection: false), + ManagedCommittedBytes: memory.TotalCommittedBytes, + Fps: _lastFps, + FrameMilliseconds: _lastFrameMs, + LastFrameProfile: _frameProfiler.LastReport); + } + private bool RunRemoteTeleportHook( uint serverGuid, uint localEntityId, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index dc482d8c..8fa4ae39 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -44,7 +44,8 @@ public sealed record RuntimeOptions( bool RetailUi, string? AcDir, bool UiProbeDump, - string? UiProbeScript) + string? UiProbeScript, + string? AutomationArtifactDirectory) { /// /// Build options from the process environment. Used by @@ -94,7 +95,9 @@ public sealed record RuntimeOptions( RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), - UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT"))); + UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), + AutomationArtifactDirectory: + NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 44ecb911..77075738 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings( bool DumpOnStart, Action Log, Func PressInput, - Func SetInputHeld); + Func SetInputHeld, + Testing.IRetailUiAutomationRuntime? Runtime = null); public sealed record RetailUiCursorBindings( CursorFeedbackController Feedback, @@ -266,7 +267,8 @@ public sealed class RetailUiRuntime : IDisposable bindings.Chat.CommandBus(), ChatChannelKind.Say), bindings.Probe.PressInput, - bindings.Probe.SetInputHeld); + bindings.Probe.SetInputHeld, + bindings.Probe.Runtime); } } diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 8ddf5cf7..9262eb51 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.App.UI.Testing; +/// +/// Narrow bridge from retained-UI scripts to render/world lifecycle +/// diagnostics. Implementations run on the same update/render thread as the +/// script; they must not mutate gameplay state or call GL from another thread. +/// +public interface IRetailUiAutomationRuntime +{ + bool IsWorldReady { get; } + bool IsWorldViewportVisible { get; } + int PortalMaterializationCount { get; } + bool TryWriteCheckpoint(string name, out string error); + bool TryRequestScreenshot(string name, out string error); + bool IsScreenshotComplete(string name); +} + /// /// Tiny one-thread script runner for . /// It is intended for local diagnostic launches, not for gameplay. Commands @@ -20,6 +35,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private readonly Action? _submitCommand; private readonly Func? _pressInput; private readonly Func? _setInputHeld; + private readonly IRetailUiAutomationRuntime? _runtime; private readonly List _commands = new(); private readonly HashSet _heldInputs = new(); private readonly bool _dumpOnStart; @@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable Action? log = null, Action? submitCommand = null, Func? pressInput = null, - Func? setInputHeld = null) + Func? setInputHeld = null, + IRetailUiAutomationRuntime? runtime = null) { _probe = probe ?? throw new ArgumentNullException(nameof(probe)); _log = log ?? (_ => { }); _submitCommand = submitCommand; _pressInput = pressInput; _setInputHeld = setInputHeld; + _runtime = runtime; _dumpOnStart = dumpOnStart; if (!string.IsNullOrWhiteSpace(scriptPath)) @@ -162,6 +180,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable "assert" => DoAssert(command), "command" => DoCommand(command), "input" => DoInput(command), + "checkpoint" => DoCheckpoint(command), + "screenshot" => DoScreenshot(command), _ => Stop(command, $"unknown command '{p[0]}'"), }; } @@ -236,7 +256,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoWait(ScriptCommand command) { var p = command.Parts; - if (p.Length < 2) return Stop(command, "usage: wait item | wait element | wait ms "); + if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); string target = p[1].ToLowerInvariant(); if (target == "item") @@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable if (target == "ms") return DoSleep(command); - return Stop(command, "usage: wait item | wait element | wait ms "); + if (target == "world-ready") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (_runtime.IsWorldReady) return true; + return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "world readiness"); + } + + if (target == "world-visible") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (_runtime.IsWorldViewportVisible) return true; + return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "normal world viewport"); + } + + if (target == "materialized") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (p.Length < 3 || !TryParseInt(p[2], out int occurrence) || occurrence <= 0) + return Stop(command, "usage: wait materialized [timeoutMs]"); + if (_runtime.PortalMaterializationCount >= occurrence) return true; + return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}"); + } + + return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); } private bool DoSleep(ScriptCommand command) @@ -356,6 +399,31 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable } } + private bool DoCheckpoint(ScriptCommand command) + { + if (command.Parts.Length != 2) + return Stop(command, "usage: checkpoint "); + if (_runtime is null) + return Stop(command, "world lifecycle automation is unavailable"); + return _runtime.TryWriteCheckpoint(command.Parts[1], out string error) + || Stop(command, error); + } + + private bool DoScreenshot(ScriptCommand command) + { + if (command.Parts.Length is < 2 or > 3) + return Stop(command, "usage: screenshot [timeoutMs]"); + if (_runtime is null) + return Stop(command, "world lifecycle automation is unavailable"); + + string name = command.Parts[1]; + if (!_runtime.TryRequestScreenshot(name, out string error)) + return Stop(command, error); + if (_runtime.IsScreenshotComplete(name)) + return true; + return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'"); + } + private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label) { if (!HasExceeded(timeoutMs)) return false; diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs new file mode 100644 index 00000000..72a1842d --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using AcDream.App.Diagnostics; +using AcDream.App.Streaming; +using SixLabors.ImageSharp; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class WorldLifecycleAutomationControllerTests +{ + [Fact] + public void ScreenshotCapture_FlipsGlRowsAndWritesACompletePng() + { + string directory = NewDirectory(); + // GL order: bottom row red/green, top row blue/white. + byte[] rgba = + [ + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, + ]; + var logs = new List(); + var controller = new FrameScreenshotController( + () => (2, 2), + (_, _) => rgba, + directory, + logs.Add); + + try + { + Assert.True(controller.TryRequest("login_stable", out string error), error); + Assert.False(controller.IsComplete("login_stable")); + + controller.CapturePending(); + + Assert.True(controller.IsComplete("login_stable")); + string path = Path.Combine(directory, "login_stable.png"); + using Image image = Image.Load(path); + Assert.Equal(2, image.Width); + Assert.Equal(2, image.Height); + Assert.Contains(logs, line => line.Contains("screenshot-complete", StringComparison.Ordinal)); + + byte[] flipped = FrameScreenshotController.FlipRows(rgba, 2, 2); + Assert.Equal(rgba.AsSpan(8, 8).ToArray(), flipped.AsSpan(0, 8).ToArray()); + Assert.Equal(rgba.AsSpan(0, 8).ToArray(), flipped.AsSpan(8, 8).ToArray()); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("")] + [InlineData("../escape")] + [InlineData("has spaces")] + [InlineData("name.png")] + public void ArtifactNames_RejectPathsAndAmbiguousCharacters(string name) + { + Assert.False(AutomationArtifactName.TryValidate(name, out string error)); + Assert.NotEmpty(error); + } + + [Fact] + public void Checkpoint_WritesCanonicalRevealAndResourceSnapshot() + { + string directory = NewDirectory(); + var reveal = new WorldRevealLifecycleSnapshot( + Generation: 7, + Kind: WorldRevealKind.Portal, + Readiness: new WorldRevealReadinessSnapshot( + 0x8A020164u, + IsIndoor: true, + IsUnhydratable: false, + RequiredRenderRadius: 0, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true), + Materialized: true, + Completed: true, + Cancelled: false, + WorldViewportObserved: true, + InvariantFailureCount: 0); + var resources = EmptyResources() with + { + LoadedLandblocks = 1, + WorldEntities = 42, + TrackedGpuBytes = 1234, + }; + var screenshots = new FrameScreenshotController( + () => (1, 1), + (_, _) => [0, 0, 0, 255], + Path.Combine(directory, "screenshots")); + var controller = new WorldLifecycleAutomationController( + () => reveal, + () => 3, + () => resources, + screenshots, + directory); + + try + { + Assert.True(controller.IsWorldReady); + Assert.True(controller.IsWorldViewportVisible); + Assert.Equal(3, controller.PortalMaterializationCount); + Assert.True(controller.TryWriteCheckpoint("dungeon", out string error), error); + + string line = Assert.Single(File.ReadAllLines( + Path.Combine(directory, "world-lifecycle.checkpoints.jsonl"))); + using JsonDocument json = JsonDocument.Parse(line); + Assert.Equal("dungeon", json.RootElement.GetProperty("name").GetString()); + Assert.Equal(7, json.RootElement.GetProperty("reveal").GetProperty("generation").GetInt64()); + Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32()); + Assert.True(File.Exists(Path.Combine(directory, "checkpoint-dungeon.json"))); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static WorldLifecycleResourceSnapshot EmptyResources() => new( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0d, 0d, null); + + private static string NewDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "acdream-world-gate-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index 9d9be640..387a1752 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -33,12 +33,14 @@ public class RuntimeOptionsRetailUiTests { ["ACDREAM_UI_PROBE_DUMP"] = "1", ["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt", + ["ACDREAM_AUTOMATION_ARTIFACT_DIR"] = @"C:\tmp\world-gate", }; var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); Assert.True(opts.UiProbeDump); Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript); + Assert.Equal(@"C:\tmp\world-gate", opts.AutomationArtifactDirectory); Assert.True(opts.UiProbeEnabled); } } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index edc446aa..98b177b1 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -41,6 +41,7 @@ public sealed class RuntimeOptionsTests Assert.Null(opts.LegacyStreamRadius); Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); + Assert.Null(opts.AutomationArtifactDirectory); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); } diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index f9d50060..337f0477 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -24,6 +24,32 @@ public sealed class RetailUiAutomationProbeTests => LastDrop = (targetList, targetCell, payload); } + private sealed class FakeRuntime : IRetailUiAutomationRuntime + { + public bool IsWorldReady { get; set; } + public bool IsWorldViewportVisible { get; set; } + public int PortalMaterializationCount { get; set; } + public List Checkpoints { get; } = new(); + public HashSet ScreenshotRequests { get; } = new(); + public HashSet CompletedScreenshots { get; } = new(); + + public bool TryWriteCheckpoint(string name, out string error) + { + Checkpoints.Add(name); + error = string.Empty; + return true; + } + + public bool TryRequestScreenshot(string name, out string error) + { + ScreenshotRequests.Add(name); + error = string.Empty; + return true; + } + + public bool IsScreenshotComplete(string name) => CompletedScreenshots.Contains(name); + } + private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects) RootWithTwoItemLists() { @@ -527,4 +553,86 @@ public sealed class RetailUiAutomationProbeTests File.Delete(path); } } + + [Fact] + public void ScriptRunner_worldLifecycleCommands_waitForCanonicalRuntimeState() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "wait world-ready 1000", + "wait world-visible 1000", + "wait materialized 2 1000", + "checkpoint stable", + "screenshot stable 1000", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + Assert.False(runner.Completed); + + runtime.IsWorldReady = true; + runner.Tick(0.001d); + Assert.False(runner.Completed); + + runtime.IsWorldViewportVisible = true; + runtime.PortalMaterializationCount = 2; + runner.Tick(0.001d); + + Assert.Equal(["stable"], runtime.Checkpoints); + Assert.Contains("stable", runtime.ScreenshotRequests); + Assert.False(runner.Completed); + + runtime.CompletedScreenshots.Add("stable"); + runner.Tick(0.001d); + + Assert.True(runner.Completed); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_worldReadyTimeout_stopsWithActionableLine() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + var logs = new List(); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllText(path, "wait world-ready 10"); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + log: logs.Add, + runtime: runtime); + + runner.Tick(0d); + runner.Tick(0.011d); + + Assert.True(runner.Completed); + Assert.Contains(logs, line => + line.Contains("timed out waiting for world readiness", StringComparison.Ordinal)); + } + finally + { + File.Delete(path); + } + } }