test(runtime): add deterministic world gate artifacts
This commit is contained in:
parent
db45b81f75
commit
354c2adc2e
11 changed files with 703 additions and 10 deletions
|
|
@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable
|
|||
private bool _wbDiagNoticePrinted;
|
||||
private bool _wasEnabled;
|
||||
|
||||
/// <summary>Most recent immutable report line, for explicit automation checkpoints.</summary>
|
||||
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;
|
||||
|
|
|
|||
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ public sealed record RuntimeOptions(
|
|||
bool RetailUi,
|
||||
string? AcDir,
|
||||
bool UiProbeDump,
|
||||
string? UiProbeScript)
|
||||
string? UiProbeScript,
|
||||
string? AutomationArtifactDirectory)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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")));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings(
|
|||
bool DumpOnStart,
|
||||
Action<string> Log,
|
||||
Func<InputAction, bool> PressInput,
|
||||
Func<InputAction, bool, bool> SetInputHeld);
|
||||
Func<InputAction, bool, bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input;
|
|||
|
||||
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>
|
||||
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||
/// 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 Func<InputAction, bool>? _pressInput;
|
||||
private readonly Func<InputAction, bool, bool>? _setInputHeld;
|
||||
private readonly IRetailUiAutomationRuntime? _runtime;
|
||||
private readonly List<ScriptCommand> _commands = new();
|
||||
private readonly HashSet<InputAction> _heldInputs = new();
|
||||
private readonly bool _dumpOnStart;
|
||||
|
|
@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
|||
Action<string>? log = null,
|
||||
Action<string>? submitCommand = 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));
|
||||
_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 <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();
|
||||
if (target == "item")
|
||||
|
|
@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
|||
if (target == "ms")
|
||||
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)
|
||||
|
|
@ -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)
|
||||
{
|
||||
if (!HasExceeded(timeoutMs)) return false;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
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>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue