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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue