test(runtime): add deterministic world gate artifacts

This commit is contained in:
Erik 2026-07-20 22:37:16 +02:00
parent db45b81f75
commit 354c2adc2e
11 changed files with 703 additions and 10 deletions

View file

@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable
private bool _wbDiagNoticePrinted; private bool _wbDiagNoticePrinted;
private bool _wasEnabled; private bool _wasEnabled;
/// <summary>Most recent immutable report line, for explicit automation checkpoints.</summary>
public string? LastReport { get; private set; }
public FrameProfiler() public FrameProfiler()
{ {
_stageUs = new FrameStatsBuffer[StageCount]; _stageUs = new FrameStatsBuffer[StageCount];
@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable
int gc0 = GC.CollectionCount(0) - _gc0Base; int gc0 = GC.CollectionCount(0) - _gc0Base;
int gc1 = GC.CollectionCount(1) - _gc1Base; int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base; int gc2 = GC.CollectionCount(2) - _gc2Base;
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs, LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs)); gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs);
Console.WriteLine(LastReport);
_lastReportTicks = nowTicks; _lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2; _gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0; _framesInWindow = 0;

View file

@ -0,0 +1,148 @@
using Silk.NET.OpenGL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace AcDream.App.Diagnostics;
/// <summary>
/// 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.
/// </summary>
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<int, int, byte[]> _readRgba;
private readonly string _directory;
private readonly Action<string> _log;
private readonly Queue<string> _pending = new();
private readonly Dictionary<string, CaptureStatus> _status =
new(StringComparer.OrdinalIgnoreCase);
public FrameScreenshotController(
GL gl,
Func<(int Width, int Height)> getSize,
string directory,
Action<string>? log = null)
: this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
{
ArgumentNullException.ThrowIfNull(gl);
}
internal FrameScreenshotController(
Func<(int Width, int Height)> getSize,
Func<int, int, byte[]> readRgba,
string directory,
Action<string>? 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;
/// <summary>Captures at most one queued image on the current GL thread.</summary>
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<Rgba32> image = Image.LoadPixelData<Rgba32>(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;
}
}

View file

@ -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);
/// <summary>
/// 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.
/// </summary>
internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly Func<WorldRevealLifecycleSnapshot> _getReveal;
private readonly Func<int> _getPortalMaterializationCount;
private readonly Func<WorldLifecycleResourceSnapshot> _captureResources;
private readonly FrameScreenshotController _screenshots;
private readonly string _artifactDirectory;
private readonly Action<string> _log;
private int _sequence;
public WorldLifecycleAutomationController(
Func<WorldRevealLifecycleSnapshot> getReveal,
Func<int> getPortalMaterializationCount,
Func<WorldLifecycleResourceSnapshot> captureResources,
FrameScreenshotController screenshots,
string artifactDirectory,
Action<string>? 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);
}

View file

@ -134,6 +134,8 @@ public sealed class GameWindow : IDisposable
// per OnRender + three stage scopes. All logic lives in // per OnRender + three stage scopes. All logic lives in
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1). // AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); 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 AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private readonly FramePacingController _framePacing = new(); private readonly FramePacingController _framePacing = new();
@ -2275,6 +2277,24 @@ public sealed class GameWindow : IDisposable
ScreenSize: () => (_window.Size.X, _window.Size.Y)); ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); 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( _retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
new AcDream.App.UI.RetailUiRuntimeBindings( new AcDream.App.UI.RetailUiRuntimeBindings(
Host: _uiHost, Host: _uiHost,
@ -2426,7 +2446,8 @@ public sealed class GameWindow : IDisposable
UiProbeLog, UiProbeLog,
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) => (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. // Update the window title with performance stats every ~0.5s.
_perfAccum += deltaSeconds; _perfAccum += deltaSeconds;
_perfFrameCount++; _perfFrameCount++;
@ -11235,6 +11261,8 @@ public sealed class GameWindow : IDisposable
double fps = _perfFrameCount / _perfAccum; double fps = _perfFrameCount / _perfAccum;
int entityCount = _worldState.Entities.Count; int entityCount = _worldState.Entities.Count;
int animatedCount = _animatedEntities.Count; int animatedCount = _animatedEntities.Count;
_lastVisibleLandblocks = visibleLandblocks;
_lastTotalLandblocks = totalLandblocks;
// Keep the developer diagnostic title independent from retail's // Keep the developer diagnostic title independent from retail's
// /framerate switch. That command owns the in-world SmartBox readout; // /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( private bool RunRemoteTeleportHook(
uint serverGuid, uint serverGuid,
uint localEntityId, uint localEntityId,

View file

@ -44,7 +44,8 @@ public sealed record RuntimeOptions(
bool RetailUi, bool RetailUi,
string? AcDir, string? AcDir,
bool UiProbeDump, bool UiProbeDump,
string? UiProbeScript) string? UiProbeScript,
string? AutomationArtifactDirectory)
{ {
/// <summary> /// <summary>
/// Build options from the process environment. Used by /// Build options from the process environment. Used by
@ -94,7 +95,9 @@ public sealed record RuntimeOptions(
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), 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")));
} }
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary> /// <summary>True iff live-mode credentials are present and valid for connecting.</summary>

View file

@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings(
bool DumpOnStart, bool DumpOnStart,
Action<string> Log, Action<string> Log,
Func<InputAction, bool> PressInput, Func<InputAction, bool> PressInput,
Func<InputAction, bool, bool> SetInputHeld); Func<InputAction, bool, bool> SetInputHeld,
Testing.IRetailUiAutomationRuntime? Runtime = null);
public sealed record RetailUiCursorBindings( public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback, CursorFeedbackController Feedback,
@ -266,7 +267,8 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Chat.CommandBus(), bindings.Chat.CommandBus(),
ChatChannelKind.Say), ChatChannelKind.Say),
bindings.Probe.PressInput, bindings.Probe.PressInput,
bindings.Probe.SetInputHeld); bindings.Probe.SetInputHeld,
bindings.Probe.Runtime);
} }
} }

View file

@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Testing; namespace AcDream.App.UI.Testing;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>. /// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
/// It is intended for local diagnostic launches, not for gameplay. Commands /// It is intended for local diagnostic launches, not for gameplay. Commands
@ -20,6 +35,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private readonly Action<string>? _submitCommand; private readonly Action<string>? _submitCommand;
private readonly Func<InputAction, bool>? _pressInput; private readonly Func<InputAction, bool>? _pressInput;
private readonly Func<InputAction, bool, bool>? _setInputHeld; private readonly Func<InputAction, bool, bool>? _setInputHeld;
private readonly IRetailUiAutomationRuntime? _runtime;
private readonly List<ScriptCommand> _commands = new(); private readonly List<ScriptCommand> _commands = new();
private readonly HashSet<InputAction> _heldInputs = new(); private readonly HashSet<InputAction> _heldInputs = new();
private readonly bool _dumpOnStart; private readonly bool _dumpOnStart;
@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
Action<string>? log = null, Action<string>? log = null,
Action<string>? submitCommand = null, Action<string>? submitCommand = null,
Func<InputAction, bool>? pressInput = null, Func<InputAction, bool>? pressInput = null,
Func<InputAction, bool, bool>? setInputHeld = null) Func<InputAction, bool, bool>? setInputHeld = null,
IRetailUiAutomationRuntime? runtime = null)
{ {
_probe = probe ?? throw new ArgumentNullException(nameof(probe)); _probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { }); _log = log ?? (_ => { });
_submitCommand = submitCommand; _submitCommand = submitCommand;
_pressInput = pressInput; _pressInput = pressInput;
_setInputHeld = setInputHeld; _setInputHeld = setInputHeld;
_runtime = runtime;
_dumpOnStart = dumpOnStart; _dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath)) if (!string.IsNullOrWhiteSpace(scriptPath))
@ -162,6 +180,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
"assert" => DoAssert(command), "assert" => DoAssert(command),
"command" => DoCommand(command), "command" => DoCommand(command),
"input" => DoInput(command), "input" => DoInput(command),
"checkpoint" => DoCheckpoint(command),
"screenshot" => DoScreenshot(command),
_ => Stop(command, $"unknown command '{p[0]}'"), _ => Stop(command, $"unknown command '{p[0]}'"),
}; };
} }
@ -236,7 +256,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoWait(ScriptCommand command) private bool DoWait(ScriptCommand command)
{ {
var p = command.Parts; var p = command.Parts;
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>"); if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
string target = p[1].ToLowerInvariant(); string target = p[1].ToLowerInvariant();
if (target == "item") if (target == "item")
@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
if (target == "ms") if (target == "ms")
return DoSleep(command); return DoSleep(command);
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>"); 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 <occurrence> [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) 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 <name>");
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 <name> [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) private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{ {
if (!HasExceeded(timeoutMs)) return false; if (!HasExceeded(timeoutMs)) return false;

View file

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

View file

@ -33,12 +33,14 @@ public class RuntimeOptionsRetailUiTests
{ {
["ACDREAM_UI_PROBE_DUMP"] = "1", ["ACDREAM_UI_PROBE_DUMP"] = "1",
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt", ["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)); var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
Assert.True(opts.UiProbeDump); Assert.True(opts.UiProbeDump);
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript); Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
Assert.Equal(@"C:\tmp\world-gate", opts.AutomationArtifactDirectory);
Assert.True(opts.UiProbeEnabled); Assert.True(opts.UiProbeEnabled);
} }
} }

View file

@ -41,6 +41,7 @@ public sealed class RuntimeOptionsTests
Assert.Null(opts.LegacyStreamRadius); Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.UiProbeDump); Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript); Assert.Null(opts.UiProbeScript);
Assert.Null(opts.AutomationArtifactDirectory);
Assert.False(opts.UiProbeEnabled); Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials); Assert.False(opts.HasLiveCredentials);
} }

View file

@ -24,6 +24,32 @@ public sealed class RetailUiAutomationProbeTests
=> LastDrop = (targetList, targetCell, payload); => 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<string> Checkpoints { get; } = new();
public HashSet<string> ScreenshotRequests { get; } = new();
public HashSet<string> 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) private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
RootWithTwoItemLists() RootWithTwoItemLists()
{ {
@ -527,4 +553,86 @@ public sealed class RetailUiAutomationProbeTests
File.Delete(path); 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>();
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);
}
}
} }