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