Phase 2 implements a minimal, dependency-free headless adapter for the GameEngineAdapter project. This adapter is housed in a separate assembly (GameEngineAdapter.Headless) under the JohnLudlow.GameEngineAdapter.Headless namespace, referencing the core contracts from JohnLudlow.GameEngineAdapter.Core. It enables deterministic, reproducible integration testing and CI runs by simulating rendering, input, and audio operations without any native platform dependencies. The HeadlessAdapter records render commands, simulates input via scripted events, and provides deterministic execution modes (fixed timestep, seeded RNG) to ensure stable test results. A TestAdapter is also provided for CI environments to record and assert adapter calls.
- Phase 2 — Headless adapter development
- Overview
- Table of contents
- Plan issue
- Plan status
- Definition of terms
- Architectural considerations and constraints
- Implementation guide
- Known issues and design concerns
- See also
- References
Complete
The Phase 2 deliverables are all in place. Phase 1 (HeadlessAdapter core) was complete in earlier work; Phase 2 (TestAdapter with provider-call recording) was completed via the Recording* decorator providers; Phase 3 (deterministic execution) was completed via DeterministicEngineRunner.Run(int, Action<DeterministicTickContext>?) plus the AI/combat/map-generation integration scenarios delivered under #16 (plan: integration-test-suite.md, guide: deterministic-integration-testing.md). all unit tests pass on this branch.
| Term | Meaning | Reference |
|---|---|---|
| Adapter | A class that implements the Phase 1 interfaces to abstract platform-specific functionality for a given engine or runtime. | |
| Deterministic tick | A simulation step that produces identical results given the same initial state and inputs. | |
| Fixed timestep | A simulation approach where each update advances time by a constant interval, avoiding variable frame rate effects. | |
| HeadlessAdapter | An adapter implementation that simulates engine operations without platform or GPU dependencies. | |
| No-op | An operation or method that performs no action (no operation), used to satisfy interface contracts without side effects. | |
| Seeded RNG | A random number generator initialized with a specific seed to produce reproducible sequences across runs. | |
| TestAdapter | An adapter implementation that records adapter lifecycle and provider method calls for verification and assertion in automated tests. |
- Separate assembly: The headless adapter lives in a dedicated assembly (
GameEngineAdapter.Headless) under theJohnLudlow.GameEngineAdapter.Headlessnamespace. This isolates the headless implementation from the core contracts defined inJohnLudlow.GameEngineAdapter.Core, ensuring engine-specific adapters do not depend on each other. The headless assembly references the core assembly via a project reference. - Dependency on Phase 1 interfaces: The HeadlessAdapter and TestAdapter must implement all interfaces defined in Phase 1 (
IEngineAdapter,IRenderProvider,IInputProvider,IUserInterfaceProvider,IAssetProvider,IAudioPlayer,IAssetLoader). Implementation cannot begin until Phase 1 interfaces are finalized. - No native platform dependencies: The implementation must not use any native APIs, GPU, audio hardware, or OS-specific features. All adapters must run on any platform supported by .NET 10.
- Deterministic execution: The goal is reproducible behaviour given the same scenario, seed, and scripted inputs.
DeterministicEngineRunnerprovides a fixed-timestep loop, a seededRandom, and aRun(int, Action<DeterministicTickContext>?)overload that exposes simulation time and the runner's RNG viaDeterministicTickContextso test authors can drive scenario logic deterministically. - Call recording architecture: Headless providers record their own interactions (e.g.
HeadlessRenderProvider.RecordedSprites,HeadlessAudioPlayer.RecordedCalls).TestAdapteradds a second recording layer viaRecordingRenderProvider,RecordingInputProvider,RecordingAssetProvider, andRecordingAudioPlayerdecorators that wrap the inner headless providers and append every cross-provider call toTestAdapter.RecordedCalls. Note that theIUserInterfaceProvideris currently delegated directly (no decorator) because the UI contract is still pending finalisation. - Separation of concerns: Each provider (render, input, UI, asset, audio) is independently testable and replaceable.
- CI compatibility: The adapters must run in CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware.
- (Complete) Headless adapter passes integration scenarios for AI, combat and map generation.
- GIVEN deterministic seeds and scenario scripts
- WHEN CI runs the integration suite
- THEN results are stable and asserted by tests.
- Note: integration scenarios for AI, combat, and map generation are implemented in
src/GameEngineAdapter.UnitTests/AiIntegrationTests.cs,CombatIntegrationTests.cs, andMapGenerationIntegrationTests.cs. The dedicated plan isintegration-test-suite.md; the contributor guide isdeterministic-integration-testing.md.
Complete
Implement a HeadlessAdapter and its providers that simulate all engine operations without any platform dependencies, recording render and input operations for verification. Success criteria: the adapter implements all Phase 1 contracts and can run a complete simulation loop without native dependencies.
- HeadlessAdapter: Implements
IEngineAdapter. Composes headless providers for rendering, input, UI, assets, and audio. Currently returnsEngineCapabilities(includingContractVersion) from theCapabilitiesproperty. - HeadlessRenderProvider: Implements
IRenderProvider. Records all render commands (SubmitSprite,SubmitText,SubmitMesh) to separate typed lists per draw type (RecordedSprites,RecordedTexts,RecordedMeshes) for later inspection.BeginFramereturns aFrameScope;EndFrameandPresentare no-ops. - HeadlessInputProvider: Implements
IInputProvider. Provides deterministic, scriptable input state via methods such asScriptKeyDown,ScriptKeyUp, andScriptMousePosition. - HeadlessUserInterfaceProvider: Implements
IUserInterfaceProvider. Currently empty (pending final UI provider contract). - HeadlessAssetProvider: Implements
IAssetProvider. Manages asset cache and lifecycle state in memory without actual file or resource access. Composes aHeadlessAssetLoader(implementingIAssetLoader) that returns stub assets for any requested identifier. - HeadlessAudioPlayer: Implements
IAudioPlayer. Records calls (StartPlayback,StopPlayback,SetVolume) for test assertion.
-
(Complete) HeadlessAdapter implements all Phase 1 contracts
- GIVEN the Phase 1 interfaces and DTOs are defined
- WHEN HeadlessAdapter is instantiated with an
EngineConfig - THEN all provider accessors return functional headless implementations.
-
(Complete) Render command recording
- GIVEN a HeadlessRenderProvider
- WHEN sprites, text, and meshes are submitted
- THEN all commands are recorded in order and retrievable for assertion.
-
(Complete) Scripted input playback
- GIVEN a HeadlessInputProvider initialized with scripted events
- WHEN input is polled
- THEN events are returned in the scripted order.
namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Simulates engine operations for headless, deterministic testing.
/// Exposes provider instances; individual providers may record their own calls for verification.
/// </summary>
public sealed class HeadlessAdapter : IEngineAdapter
{
/// <summary>Gets the headless engine capabilities.</summary>
public EngineCapabilities Capabilities { get; }
/// <summary>Gets the configuration used to initialize this adapter.</summary>
public EngineConfig Config { get; }
/// <inheritdoc />
public IRenderProvider RenderProvider { get; }
/// <inheritdoc />
public IInputProvider InputProvider { get; }
/// <inheritdoc />
public IUserInterfaceProvider UserInterfaceProvider { get; }
/// <inheritdoc />
public IAssetProvider AssetProvider { get; }
/// <inheritdoc />
public IAudioPlayer AudioPlayer { get; }
/// <summary>
/// Initializes a new headless adapter with the specified configuration.
/// </summary>
/// <param name="config">Adapter configuration.</param>
public HeadlessAdapter(EngineConfig config)
{
Config = config;
Capabilities = new EngineCapabilities(
Supports2D: true,
Supports3D: false,
SupportsShaders: false,
SupportsAudio: false,
SupportsRichUI: false,
ContractVersion: "1.0.0",
SupportedTextureFormats: [],
MaxTextureSize: 0,
MaxAudioChannels: 0);
RenderProvider = new HeadlessRenderProvider();
InputProvider = new HeadlessInputProvider();
UserInterfaceProvider = new HeadlessUserInterfaceProvider();
AssetProvider = new HeadlessAssetProvider();
AudioPlayer = new HeadlessAudioPlayer();
}
/// <inheritdoc />
public Task InitializeAsync(EngineConfig config, CancellationToken ct = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken ct = default)
=> Task.CompletedTask;
/// <inheritdoc />
public void Dispose() { /* No resources to release */ }
}// src/GameEngineAdapter.Headless/HeadlessRenderProvider.cs
public sealed class HeadlessRenderProvider : IRenderProvider
{
private readonly List<SpriteDrawDto> _recordedSprites = [];
private readonly List<TextDrawDto> _recordedTexts = [];
private readonly List<MeshDrawDto> _recordedMeshes = [];
public IReadOnlyList<SpriteDrawDto> RecordedSprites => _recordedSprites;
public IReadOnlyList<TextDrawDto> RecordedTexts => _recordedTexts;
public IReadOnlyList<MeshDrawDto> RecordedMeshes => _recordedMeshes;
public FrameScope BeginFrame(in CameraDescriptor camera) => new();
public void SubmitSprite(in SpriteDrawDto dto) => _recordedSprites.Add(dto);
public void SubmitText(in TextDrawDto dto) => _recordedTexts.Add(dto);
public void SubmitMesh(in MeshDrawDto dto) => _recordedMeshes.Add(dto);
public void EndFrame() { }
public void Present() { }
public void Clear()
{
_recordedSprites.Clear();
_recordedTexts.Clear();
_recordedMeshes.Clear();
}
}namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Simulates input by maintaining scripted state (keys held, buttons held, mouse position)
/// for deterministic testing.
/// </summary>
public sealed class HeadlessInputProvider : IInputProvider
{
private readonly HashSet<string> _keysDown = [];
private readonly HashSet<int> _buttonsDown = [];
private (float X, float Y) _mousePosition;
/// <summary>
/// Sets the specified key as held down.
/// </summary>
/// <param name="key">The key identifier.</param>
public void ScriptKeyDown(string key) => _keysDown.Add(key);
/// <summary>
/// Releases the specified key.
/// </summary>
/// <param name="key">The key identifier.</param>
public void ScriptKeyUp(string key) => _keysDown.Remove(key);
/// <summary>
/// Sets the specified mouse button as held down.
/// </summary>
/// <param name="button">The mouse button index.</param>
public void ScriptMouseButtonDown(int button) => _buttonsDown.Add(button);
/// <summary>
/// Releases the specified mouse button.
/// </summary>
/// <param name="button">The mouse button index.</param>
public void ScriptMouseButtonUp(int button) => _buttonsDown.Remove(button);
/// <summary>
/// Sets the scripted mouse position.
/// </summary>
/// <param name="x">X coordinate in screen space.</param>
/// <param name="y">Y coordinate in screen space.</param>
public void ScriptMousePosition(float x, float y) => _mousePosition = (x, y);
/// <inheritdoc />
public bool IsKeyDown(string key) => _keysDown.Contains(key);
/// <inheritdoc />
public bool IsMouseButtonDown(int button) => _buttonsDown.Contains(button);
/// <inheritdoc />
public (float X, float Y) GetMousePosition() => _mousePosition;
/// <summary>Resets all scripted input state.</summary>
public void Reset()
{
_keysDown.Clear();
_buttonsDown.Clear();
_mousePosition = (0f, 0f);
}
}namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Headless implementation of <see cref="IUserInterfaceProvider"/>.
/// Executes UI layout and hit-testing logic without rendering any visuals.
/// </summary>
public sealed class HeadlessUserInterfaceProvider : IUserInterfaceProvider
{
// Members to be defined when IUserInterfaceProvider is finalized.
}namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Manages asset cache and lifecycle state in memory without actual file or resource access.
/// Composes a <see cref="HeadlessAssetLoader"/> for stub asset loading.
/// </summary>
public sealed class HeadlessAssetProvider : IAssetProvider
{
private readonly Dictionary<string, object> _cache = [];
/// <summary>
/// Initializes a new <see cref="HeadlessAssetProvider"/> with a default
/// <see cref="HeadlessAssetLoader"/>.
/// </summary>
public HeadlessAssetProvider()
{
Loader = new HeadlessAssetLoader();
}
/// <inheritdoc />
public IAssetLoader Loader { get; }
/// <inheritdoc />
public object? GetAsset(string assetId) =>
_cache.TryGetValue(assetId, out var asset) ? asset : null;
/// <inheritdoc />
public void UnloadAsset(string assetId) => _cache.Remove(assetId);
/// <inheritdoc />
public bool IsAssetLoaded(string assetId) => _cache.ContainsKey(assetId);
/// <inheritdoc />
public void ClearCache() => _cache.Clear();
/// <summary>
/// Loads an asset via the loader and caches it.
/// </summary>
/// <param name="assetId">Asset identifier to load and cache.</param>
public void LoadAndCache(string assetId)
{
if (!_cache.ContainsKey(assetId))
{
_cache[assetId] = Loader.Load(assetId);
}
}
}namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Stub asset loader that returns placeholder objects for any requested identifier.
/// </summary>
public sealed class HeadlessAssetLoader : IAssetLoader
{
/// <inheritdoc />
public Task<object> LoadAsync(string assetId, CancellationToken ct = default) =>
Task.FromResult<object>(new object());
/// <inheritdoc />
public object Load(string assetId) => new object();
}namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Headless audio player that records all calls for verification.
/// All playback methods are no-ops; calls are recorded for test assertion.
/// </summary>
public sealed class HeadlessAudioPlayer : IAudioPlayer
{
private readonly List<(string Method, string AudioAssetId, object? Arg)> _recordedCalls = [];
/// <summary>Gets the list of recorded audio calls for test assertion.</summary>
public IReadOnlyList<(string Method, string AudioAssetId, object? Arg)> RecordedCalls =>
_recordedCalls;
/// <inheritdoc />
public void StartPlayback(string audioAssetId, bool loopPlayback = false) =>
_recordedCalls.Add(("StartPlayback", audioAssetId, loopPlayback));
/// <inheritdoc />
public void StopPlayback(string audioAssetId) =>
_recordedCalls.Add(("StopPlayback", audioAssetId, null));
/// <inheritdoc />
public void SetVolume(string audioAssetId, float volume) =>
_recordedCalls.Add(("SetVolume", audioAssetId, volume));
/// <summary>Clears all recorded calls.</summary>
public void Clear() => _recordedCalls.Clear();
}Complete
Provide a TestAdapter that wraps the HeadlessAdapter and records calls for assertion in CI environments.
- TestAdapter: Implements
IEngineAdapter. Composes aHeadlessAdapterinternally. - Call recording: Records both adapter lifecycle calls (
InitializeAsync,ShutdownAsync) and every provider method call toRecordedCalls. Each entry is aRecordedCall(ProviderName, MethodName, Arguments). - Provider recording:
TestAdapterexposesRecording*Providerdecorator instances (RecordingRenderProvider,RecordingInputProvider,RecordingAssetProvider,RecordingAudioPlayer) that wrap the innerHeadlessAdapter's providers. Each decorator forwards the call to the inner provider and appends aRecordedCallentry.UserInterfaceProvideris delegated directly (no decorator) pending the UI contract. - Inner accessor:
TestAdapter.Innerexposes the wrappedHeadlessAdapterso test code that needs to bypass recording (e.g.DeterministicEngineRunnerlifecycle calls) can reach the unwrapped providers.
-
(Complete) TestAdapter records adapter lifecycle calls
- GIVEN a TestAdapter wrapping a HeadlessAdapter
- WHEN
InitializeAsyncandShutdownAsyncare called - THEN the calls and arguments are recorded for assertion.
-
(Complete) TestAdapter records provider method calls
- GIVEN a TestAdapter wrapping a HeadlessAdapter
- WHEN any provider method is called
- THEN the call and its arguments are recorded for assertion.
-
(Complete) TestAdapter supports call assertion (for recorded calls)
- GIVEN expected sequences of recorded calls
- WHEN the test completes
- THEN recorded calls can be queried and asserted for correctness.
namespace JohnLudlow.GameEngineAdapter.Headless;
/// <summary>
/// DTO recording a single adapter call for test assertion.
/// </summary>
/// <param name="ProviderName">Name of the provider (e.g. "Render", "Input").</param>
/// <param name="MethodName">Name of the method called.</param>
/// <param name="Arguments">Arguments passed to the method.</param>
public readonly record struct RecordedCall(
string ProviderName,
string MethodName,
IReadOnlyList<object?> Arguments);namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// A test-friendly adapter that wraps <see cref="HeadlessAdapter"/> and records all provider
/// and lifecycle method calls to <see cref="RecordedCalls"/> for assertion in unit tests.
/// </summary>
public sealed class TestAdapter : IEngineAdapter
{
private readonly HeadlessAdapter _inner;
private readonly List<RecordedCall> _recordedCalls = [];
private readonly RecordingRenderProvider _renderProvider;
private readonly RecordingInputProvider _inputProvider;
private readonly RecordingAssetProvider _assetProvider;
private readonly RecordingAudioPlayer _audioPlayer;
/// <summary>Gets the underlying <see cref="HeadlessAdapter"/> instance.</summary>
public HeadlessAdapter Inner => _inner;
/// <summary>Gets the ordered log of all recorded provider and lifecycle calls.</summary>
public IReadOnlyList<RecordedCall> RecordedCalls => _recordedCalls;
/// <inheritdoc />
public EngineCapabilities Capabilities => _inner.Capabilities;
/// <inheritdoc />
public IRenderProvider RenderProvider => _renderProvider;
/// <inheritdoc />
public IInputProvider InputProvider => _inputProvider;
/// <inheritdoc />
public IUserInterfaceProvider UserInterfaceProvider => _inner.UserInterfaceProvider;
/// <inheritdoc />
public IAssetProvider AssetProvider => _assetProvider;
/// <inheritdoc />
public IAudioPlayer AudioPlayer => _audioPlayer;
/// <summary>
/// Initializes a new <see cref="TestAdapter"/> wrapping a <see cref="HeadlessAdapter"/>
/// created from the specified configuration.
/// </summary>
/// <param name="config">Adapter configuration.</param>
public TestAdapter(EngineConfig config)
{
_inner = new HeadlessAdapter(config);
_renderProvider = new RecordingRenderProvider(_inner.RenderProvider, _recordedCalls);
_inputProvider = new RecordingInputProvider(_inner.InputProvider, _recordedCalls);
_assetProvider = new RecordingAssetProvider(_inner.AssetProvider, _recordedCalls);
_audioPlayer = new RecordingAudioPlayer(_inner.AudioPlayer, _recordedCalls);
}
/// <inheritdoc />
public Task InitializeAsync(EngineConfig config, CancellationToken ct = default)
{
_recordedCalls.Add(new RecordedCall("Adapter", "InitializeAsync", [config]));
return _inner.InitializeAsync(config, ct);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken ct = default)
{
_recordedCalls.Add(new RecordedCall("Adapter", "ShutdownAsync", []));
return _inner.ShutdownAsync(ct);
}
/// <inheritdoc />
public void Dispose() => _inner.Dispose();
}Complete
Provide a deterministic execution harness for headless simulations (fixed timestep configuration and seeded RNG) to enable reproducible test runs.
- Fixed timestep:
DeterministicEngineRunneradvancesSimulationTimeby_fixedTimestepafter each step. Stepi'sonTickcallback observesSimulationTime = i * fixedTimestep(so step 0 seesTimeSpan.Zero). - Seeded RNG: The runner owns a
Randomseeded with the provided seed, exposed asDeterministicEngineRunner.Random. The same reference is passed to everyonTickcallback asDeterministicTickContext.Random, so scenario logic that draws from the runner's RNG is reproducible. - Scenario scripting: Scenarios are authored in two layers. (a)
HeadlessInputProviderexposes scripted-input setters (ScriptKeyDown,ScriptKeyUp,ScriptMousePosition) for tests that need to drive input. (b) The per-tickonTickcallback lets tests drive arbitrary simulation logic — render submissions, audio calls, RNG draws — usingDeterministicTickContext. The current integration scenarios (AiIntegrationTests.cs,CombatIntegrationTests.cs,MapGenerationIntegrationTests.cs) exercise (b) only; no scripted-input scenarios using theScript*setters exist yet. There is no separate scenario file format and none is currently planned — scenarios are written as ordinary C# test methods.
-
(Complete) Fixed timestep execution
- GIVEN a configured fixed timestep interval
- WHEN the simulation runs N steps
- THEN the runner executes N steps and
SimulationTimeadvances byfixedTimestepper step.
-
(Complete) Seeded RNG reproducibility
- GIVEN a fixed RNG seed
- WHEN the simulation runs twice with identical inputs
- THEN both runs produce identical observable outputs (recorded sprites, recorded calls, etc.) — see the AI/combat/map-generation integration scenarios for worked examples.
namespace JohnLudlow.GameEngineAdapter.Headless;
using JohnLudlow.GameEngineAdapter.Core;
/// <summary>
/// Runs a headless simulation with deterministic fixed timestep and seeded RNG.
/// </summary>
public sealed class DeterministicEngineRunner
{
private readonly HeadlessAdapter _adapter;
private readonly Random _random;
private readonly TimeSpan _fixedTimestep;
private readonly CameraDescriptor _camera;
/// <summary>
/// Gets the underlying headless adapter driven by this runner.
/// </summary>
public HeadlessAdapter Adapter => _adapter;
/// <summary>
/// Gets the seeded random number generator used by this runner.
/// </summary>
public Random Random => _random;
/// <summary>
/// Gets the current simulated time. Starts at <see cref="TimeSpan.Zero"/> and advances by
/// one <c>fixedTimestep</c> after each step's <c>Present()</c> call.
/// </summary>
public TimeSpan SimulationTime { get; private set; } = TimeSpan.Zero;
/// <summary>
/// Initializes a new deterministic engine runner.
/// </summary>
/// <param name="adapter">The headless adapter to drive.</param>
/// <param name="seed">RNG seed for reproducibility.</param>
/// <param name="fixedTimestep">Time interval per simulation step.</param>
/// <param name="camera">
/// Camera descriptor passed to <c>BeginFrame</c> each step. Defaults to
/// <c>default(CameraDescriptor)</c>; the headless render provider ignores camera
/// configuration, so the default value is safe for headless deterministic runs.
/// </param>
public DeterministicEngineRunner(
HeadlessAdapter adapter,
int seed,
TimeSpan fixedTimestep,
CameraDescriptor camera = default)
{
_adapter = adapter;
_random = new Random(seed);
_fixedTimestep = fixedTimestep;
_camera = camera;
SimulationTime = TimeSpan.Zero;
}
/// <summary>
/// Runs the simulation for the specified number of steps.
/// </summary>
/// <param name="steps">Number of simulation steps to execute.</param>
/// <param name="onTick">
/// Optional per-step callback invoked between <c>BeginFrame</c> and <c>EndFrame</c>,
/// receiving the current <see cref="SimulationTime"/>, the runner's seeded <see cref="Random"/>,
/// and the underlying <see cref="Adapter"/>. May be <see langword="null"/>.
/// </param>
public void Run(int steps, Action<DeterministicTickContext>? onTick = null)
{
var renderProvider = _adapter.RenderProvider;
for (var i = 0; i < steps; i++)
{
using var scope = renderProvider.BeginFrame(in _camera);
onTick?.Invoke(new DeterministicTickContext(SimulationTime, _random, _adapter));
renderProvider.EndFrame();
renderProvider.Present();
SimulationTime += _fixedTimestep;
}
}
}- Unit tests cover each headless provider independently, plus
TestAdapterandDeterministicEngineRunner. - Integration scenarios for AI, combat, and map generation are implemented under
src/GameEngineAdapter.UnitTests/*IntegrationTests.cs(plan:integration-test-suite.md, contributor guide:deterministic-integration-testing.md). - Adapters are designed to be compatible with CI environments (GitHub Actions, Azure DevOps, etc.) without requiring graphics or audio hardware.
TestAdapterexposesRecordedCallsfor asserting both adapter lifecycle calls and every provider method call across the recording decorators.
- HeadlessAdapter and TestAdapter must complete integration scenarios in under 100ms per scenario on typical CI hardware.
- No allocations or operations that would cause test flakiness or non-determinism.
- Call recording memory usage bounded by scenario size; consider clearing between scenarios.
- Scenario scripting format:
HeadlessInputProvidersupports scripted state viaScript*setter methods, but no integration scenario currently exercises them — the existing scenarios drive simulation logic from the per-tickonTickcallback in C#. There is no queue/script playback format. Adopting a richer playback format (e.g. timestamped event queue, file-backed scenario script), or adding a scripted-input integration scenario, is deferred to a future phase. IUserInterfaceProviderdecorator:TestAdapterdelegatesUserInterfaceProviderdirectly to the innerHeadlessAdapterbecause the UI provider contract is still pending finalisation. Once the contract is fixed, aRecordingUserInterfaceProviderdecorator should be added so UI calls also land inRecordedCalls.- Call recording overhead: Recording every provider call may impact performance for very large scenarios. The recording buffer is currently unbounded; consider clearing between scenarios for long-running test runs.