aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src')
-rw-r--r--src/Godosa.Audio/Godosa.Audio.csproj18
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Reverb.cs83
-rw-r--r--src/Godosa.Audio/OpenAlAudio.Streams.cs119
-rw-r--r--src/Godosa.Audio/OpenAlAudio.cs360
-rw-r--r--src/Godosa.Core.Tests/Audio/AudioMixTests.cs22
-rw-r--r--src/Godosa.Core.Tests/Audio/Mp3Tests.cs90
-rw-r--r--src/Godosa.Core.Tests/Audio/SynthTests.cs122
-rw-r--r--src/Godosa.Core.Tests/Audio/WavReaderTests.cs127
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs59
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs97
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs53
-rw-r--r--src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs38
-rw-r--r--src/Godosa.Core.Tests/Godosa.Core.Tests.csproj13
-rw-r--r--src/Godosa.Core.Tests/Input/GamepadScriptTests.cs56
-rw-r--r--src/Godosa.Core.Tests/Input/InputScriptTests.cs73
-rw-r--r--src/Godosa.Core.Tests/Input/MenuCursorTests.cs42
-rw-r--r--src/Godosa.Core.Tests/Runs/RouteLegsTests.cs116
-rw-r--r--src/Godosa.Core.Tests/Runs/RunsTests.cs149
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs47
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs185
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs100
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs157
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs83
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs103
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs111
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs54
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs85
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs224
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs63
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs58
-rw-r--r--src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs84
-rw-r--r--src/Godosa.Core.Tests/SettingsFileTests.cs90
-rw-r--r--src/Godosa.Core/Audio/AudioTypes.cs45
-rw-r--r--src/Godosa.Core/Audio/BiquadFilter.cs37
-rw-r--r--src/Godosa.Core/Audio/Mp3.cs133
-rw-r--r--src/Godosa.Core/Audio/Pcm.cs24
-rw-r--r--src/Godosa.Core/Audio/SignalStats.cs51
-rw-r--r--src/Godosa.Core/Audio/Synth.cs189
-rw-r--r--src/Godosa.Core/Audio/WavReader.cs157
-rw-r--r--src/Godosa.Core/Diagnostics/DebugFlags.cs20
-rw-r--r--src/Godosa.Core/Diagnostics/EngineDebug.cs25
-rw-r--r--src/Godosa.Core/Diagnostics/FrameTimeStats.cs139
-rw-r--r--src/Godosa.Core/Diagnostics/PerfCsv.cs27
-rw-r--r--src/Godosa.Core/Godosa.Core.csproj5
-rw-r--r--src/Godosa.Core/Input/GamepadState.cs58
-rw-r--r--src/Godosa.Core/Input/InputScript.cs82
-rw-r--r--src/Godosa.Core/Input/MenuCursor.cs21
-rw-r--r--src/Godosa.Core/Runs/DigestLine.cs13
-rw-r--r--src/Godosa.Core/Runs/FrameClock.cs23
-rw-r--r--src/Godosa.Core/Runs/HeadlessLoop.cs20
-rw-r--r--src/Godosa.Core/Runs/RouteLegs.cs117
-rw-r--r--src/Godosa.Core/Runs/RunFlags.cs39
-rw-r--r--src/Godosa.Core/Runs/Scripts/CheckpointCache.cs112
-rw-r--r--src/Godosa.Core/Runs/Scripts/Condition.cs234
-rw-r--r--src/Godosa.Core/Runs/Scripts/Input.cs24
-rw-r--r--src/Godosa.Core/Runs/Scripts/InputPlans.cs111
-rw-r--r--src/Godosa.Core/Runs/Scripts/LibVerbs.cs32
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunGame.cs68
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunOutput.cs21
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunReport.cs58
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptAst.cs25
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptLoader.cs218
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptParser.cs231
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptProgram.cs57
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs100
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunner.cs568
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptTypes.cs46
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptValidator.cs109
-rw-r--r--src/Godosa.Core/Runs/Scripts/StepLexer.cs104
-rw-r--r--src/Godosa.Core/Runs/Scripts/Vars.cs174
-rw-r--r--src/Godosa.Core/Runs/SeedStreams.cs14
-rw-r--r--src/Godosa.Core/Runs/StateDigest.cs51
-rw-r--r--src/Godosa.Core/SettingsFile.cs49
-rw-r--r--src/Godosa.Desktop/Buttons.cs15
-rw-r--r--src/Godosa.Desktop/CursorWarp.cs66
-rw-r--r--src/Godosa.Desktop/DisplayMode.cs19
-rw-r--r--src/Godosa.Desktop/FrameTimeGraph.cs65
-rw-r--r--src/Godosa.Desktop/GlProgram.cs35
-rw-r--r--src/Godosa.Desktop/Godosa.Desktop.csproj12
-rw-r--r--src/Godosa.Desktop/GpuTimer.cs71
-rw-r--r--src/Godosa.Desktop/InputScriptPlayer.cs124
-rw-r--r--src/Godosa.Desktop/PadNavigator.cs85
-rw-r--r--src/Godosa.Gamepad/Godosa.Gamepad.csproj11
-rw-r--r--src/Godosa.Gamepad/SdlGamepadSource.cs107
85 files changed, 7385 insertions, 0 deletions
diff --git a/src/Godosa.Audio/Godosa.Audio.csproj b/src/Godosa.Audio/Godosa.Audio.csproj
new file mode 100644
index 0000000..a478730
--- /dev/null
+++ b/src/Godosa.Audio/Godosa.Audio.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <Using Include="Godosa.Core.Audio" />
+ <Using Include="Godosa.Core.Diagnostics" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="Silk.NET.OpenAL" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" />
+ <PackageReference Include="Silk.NET.OpenAL.Extensions.Soft" />
+ <PackageReference Include="Silk.NET.OpenAL.Soft.Native" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Audio/OpenAlAudio.Reverb.cs b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
new file mode 100644
index 0000000..b93e74a
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Reverb.cs
@@ -0,0 +1,83 @@
+using Silk.NET.OpenAL.Extensions.Creative;
+
+namespace Godosa.Audio;
+
+/// <summary>One EFX reverb for the region (audio spec §2): positioned sources send to it, 2D
+/// ones (beds, music, interface) don't. Air absorption dulls distant sounds.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private EffectExtension? _efx;
+ private uint _effect, _effectSlot;
+ private ReverbPreset? _reverbApplied;
+ private ReverbPreset? _reverbWanted;
+ private bool _reverbInitialised;
+
+ /// <summary>The current region's reverb, or null for none (UI thread).</summary>
+ public ReverbPreset? Reverb
+ {
+ set { lock (_poseLock) _reverbWanted = value; }
+ }
+
+ private void InitReverb()
+ {
+ if (!_al!.TryGetExtension(out _efx)) _efx = null;
+ if (_efx is null)
+ {
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "no EFX: no reverb");
+ return;
+ }
+ _effect = _efx.GenEffects(1)[0];
+ _efx.SetEffectProperty(_effect, EffectInteger.EffectType, (int)EffectType.Reverb);
+ _effectSlot = _efx.GenAuxiliaryEffectSlots(1)[0];
+ _reverbApplied = null;
+ _reverbInitialised = false;
+ }
+
+ private void UpdateReverb()
+ {
+ if (_efx is null) return;
+ ReverbPreset? wanted;
+ lock (_poseLock) wanted = _reverbWanted;
+ if (_reverbInitialised && _reverbApplied == wanted) return;
+ _reverbInitialised = true;
+ _reverbApplied = wanted;
+ if (wanted is not { } p)
+ {
+ // No effect in the slot: sends to it are dropped and the mixer runs no reverb.
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", "reverb off");
+ return;
+ }
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDensity, p.Density);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDiffusion, p.Diffusion);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGain, p.Gain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbGainHF, p.GainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayTime, p.DecayTime);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayHFRatio, p.DecayHFRatio);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsGain, p.ReflectionsGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsDelay, p.ReflectionsDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbGain, p.LateReverbGain);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbDelay, p.LateReverbDelay);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbAirAbsorptionGainHF, p.AirAbsorptionGainHF);
+ _efx.SetEffectProperty(_effect, EffectFloat.ReverbRoomRolloffFactor, p.RoomRolloffFactor);
+ _efx.SetEffectProperty(_effect, EffectInteger.ReverbDecayHFLimit, p.DecayHFLimit ? 1 : 0);
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, (int)_effect); // re-attach applies the change
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"reverb {(p == ReverbPreset.Forest ? "forest" : p == ReverbPreset.Cave ? "cave" : "plain")}");
+ }
+
+ private void SendToReverb(uint source, bool positioned)
+ {
+ if (_efx is null) return;
+ _efx.SetSourceProperty(source, EFXSourceInteger3.AuxiliarySendFilter, positioned ? (int)_effectSlot : 0, 0, 0);
+ _efx.SetSourceProperty(source, EFXSourceFloat.AirAbsorptionFactor, positioned ? 1f : 0f);
+ }
+
+ private void ReleaseReverb()
+ {
+ if (_efx is null) return;
+ _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0);
+ _efx.DeleteAuxiliaryEffectSlot(_effectSlot);
+ _efx.DeleteEffect(_effect);
+ _efx = null;
+ }
+}
diff --git a/src/Godosa.Audio/OpenAlAudio.Streams.cs b/src/Godosa.Audio/OpenAlAudio.Streams.cs
new file mode 100644
index 0000000..435c058
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.Streams.cs
@@ -0,0 +1,119 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using Silk.NET.OpenAL;
+
+namespace Godosa.Audio;
+
+/// <summary>Endless stereo streams (ambience beds, music — audio spec §5–§6): each holds one
+/// source with a queue of buffers the audio thread refills from its generator.</summary>
+public sealed unsafe partial class OpenAlAudio
+{
+ private const int StreamBuffers = 4, StreamFrames = 2048; // ~190 ms queued
+
+ public sealed class StreamHandle
+ {
+ internal StreamHandle(IStereoStream source, int channel) => (Source, Channel) = (source, channel);
+ internal IStereoStream Source { get; }
+ internal int Channel { get; }
+ internal int Slot = -1;
+ internal uint[] Buffers = System.Array.Empty<uint>();
+ private int _gainBits = System.BitConverter.SingleToInt32Bits(1f); // written by the UI thread
+
+ public float Gain
+ {
+ get => System.BitConverter.Int32BitsToSingle(Volatile.Read(ref _gainBits));
+ set => Volatile.Write(ref _gainBits, System.BitConverter.SingleToInt32Bits(value));
+ }
+ }
+
+ private readonly bool[] _streamSlot = new bool[SourceCount];
+ private readonly List<StreamHandle> _streams = new(); // audio thread only
+ private readonly ConcurrentQueue<(StreamHandle Stream, bool Add)> _streamCommands = new();
+ private readonly float[] _streamMix = new float[StreamFrames * 2];
+ private readonly short[] _streamPcm = new short[StreamFrames * 2];
+
+ public StreamHandle AddStream(IStereoStream source, int channel, float gain = 1f)
+ {
+ var handle = new StreamHandle(source, channel) { Gain = gain };
+ _streamCommands.Enqueue((handle, true));
+ return handle;
+ }
+
+ public void RemoveStream(StreamHandle handle) => _streamCommands.Enqueue((handle, false));
+
+ private void UpdateStreams(AudioPauseState state)
+ {
+ var al = _al!;
+ while (_streamCommands.TryDequeue(out var command))
+ {
+ if (command.Add) _streams.Add(command.Stream);
+ else if (_streams.Remove(command.Stream)) Release(command.Stream);
+ }
+ foreach (var s in _streams)
+ {
+ if (s.Slot < 0 && !Claim(s)) continue;
+ uint source = _sources[s.Slot];
+ _gain[s.Slot] = s.Gain;
+ SetGain(s.Slot, s.Gain * _mix.ChannelGain(s.Channel, state));
+ al.GetSourceProperty(source, GetSourceInteger.BuffersProcessed, out int processed);
+ for (int i = 0; i < processed; i++)
+ {
+ uint buffer;
+ al.SourceUnqueueBuffers(source, 1, &buffer);
+ Fill(s, buffer);
+ al.SourceQueueBuffers(source, 1, &buffer);
+ }
+ if (SourceState(source) != Silk.NET.OpenAL.SourceState.Playing) al.SourcePlay(source); // start, or recover from a starve
+ }
+ }
+
+ private bool Claim(StreamHandle s)
+ {
+ var al = _al!;
+ int slot = FreeSource();
+ if (slot < 0) return false;
+ s.Slot = slot;
+ _streamSlot[slot] = true;
+ _channel[slot] = s.Channel;
+ _appliedGain[slot] = -1f;
+ _pausedByUs[slot] = false;
+ uint source = _sources[slot];
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0);
+ al.SetSourceProperty(source, SourceBoolean.Looping, false);
+ al.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
+ al.SetSourceProperty(source, SourceVector3.Position, System.Numerics.Vector3.Zero);
+ al.SetSourceProperty(source, SourceFloat.Pitch, 1f);
+ SendToReverb(source, positioned: false);
+ s.Buffers = al.GenBuffers(StreamBuffers);
+ foreach (uint b in s.Buffers) Fill(s, b);
+ fixed (uint* ids = s.Buffers) al.SourceQueueBuffers(source, StreamBuffers, ids);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"stream {s.Source.GetType().Name} {s.Channel} on source {slot}");
+ return true;
+ }
+
+ private void Fill(StreamHandle s, uint buffer)
+ {
+ s.Source.Render(_streamMix, StreamFrames);
+ for (int i = 0; i < _streamMix.Length; i++) _streamPcm[i] = Pcm.ToShort(_streamMix[i]);
+ _al!.BufferData(buffer, BufferFormat.Stereo16, _streamPcm, Pcm.SampleRate);
+ }
+
+ private void Release(StreamHandle s)
+ {
+ if (s.Slot < 0) return;
+ var al = _al!;
+ uint source = _sources[s.Slot];
+ al.SourceStop(source);
+ al.SetSourceProperty(source, SourceInteger.Buffer, 0); // detaches the queue
+ al.DeleteBuffers(s.Buffers);
+ _streamSlot[s.Slot] = false;
+ s.Slot = -1;
+ }
+
+ /// <summary>Before the context goes (HRTF change): streams re-claim a source afterwards.</summary>
+ private void ReleaseStreams()
+ {
+ foreach (var s in _streams) Release(s);
+ }
+}
diff --git a/src/Godosa.Audio/OpenAlAudio.cs b/src/Godosa.Audio/OpenAlAudio.cs
new file mode 100644
index 0000000..7a0d59b
--- /dev/null
+++ b/src/Godosa.Audio/OpenAlAudio.cs
@@ -0,0 +1,360 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Threading;
+using Silk.NET.OpenAL;
+
+namespace Godosa.Audio;
+
+/// <summary>
+/// OpenAL Soft output on its own thread (every AL call happens there; other threads post
+/// sounds, loops, streams and the listener pose). Mix policy comes from the game's
+/// <see cref="IAudioMix"/>. No device or any AL failure → silent (logged, never throws).
+/// </summary>
+public sealed unsafe partial class OpenAlAudio : IDisposable
+{
+ private const int SourceCount = 32;
+ private const int MaxQueued = 64;
+ private const int UpdateMs = 10;
+ public const float ReferenceDistance = 2f, MaxDistance = 60f;
+
+ // ALC_SOFT_HRTF (not wrapped by Silk.NET): context attribute + status query.
+ private const int AlcHrtfSoft = 0x1992, AlcHrtfStatusSoft = 0x1993, AlcDontCareSoft = 0x0002;
+ // Mix at our buffers' rate: no per-source resampling (~15 % of the mixer, measured).
+ private const int AlcFrequency = 0x1007;
+
+ public static OpenAlAudio? Current { get; private set; }
+
+ /// <summary>A one-shot sound. <paramref name="Render"/> runs once per <paramref name="Key"/>
+ /// (the buffer is cached); no position = heard in the head (2D). Falloff: inverse distance,
+ /// gain = Reference / distance, distance clamped to [Reference, Max] (world units of the game).
+ /// <paramref name="Falloff"/> (distance → gain factor, audio thread) replaces that curve for a positioned sound:
+ /// OpenAL only pans it, the factor is applied every update (the game's own attenuation).</summary>
+ public readonly record struct Sound(string Key, Func<PcmClip> Render, int Channel,
+ Vector3? Position = null, float Gain = 1f, float Pitch = 1f, float Reference = ReferenceDistance, float Max = MaxDistance,
+ Func<float, float>? Falloff = null);
+
+ /// <summary>A looping sound that follows something (a projectile in flight). Posted as the
+ /// full set every frame: new keys start, missing keys stop.</summary>
+ public readonly record struct LoopEmitter(object Key, Sound Sound, Vector3 Position, Vector3 Velocity);
+
+ private readonly IAudioMix _mix;
+ private readonly object _loopLock = new();
+ private List<LoopEmitter> _loopsPosted = new(), _loopsWorking = new();
+ private readonly Dictionary<object, int> _loopSlots = new(ReferenceEqualityComparer.Instance);
+ private readonly HashSet<object> _loopSeen = new(ReferenceEqualityComparer.Instance);
+ private readonly List<object> _loopGone = new();
+ private readonly ConcurrentQueue<Sound> _queue = new();
+ private readonly ConcurrentQueue<int> _stops = new();
+ private readonly Thread _thread;
+ private readonly object _poseLock = new();
+ private volatile bool _running = true;
+ private Vector3 _listenerPosition, _listenerForward = -Vector3.UnitZ, _listenerUp = Vector3.UnitY;
+ private int _state; // AudioPauseState, written by the UI thread
+
+ private ALContext? _alc;
+ private AL? _al;
+ private Device* _device;
+ private Context* _context;
+ private HrtfMode _hrtfApplied;
+ private readonly uint[] _sources = new uint[SourceCount];
+ private readonly int[] _channel = new int[SourceCount];
+ private readonly float[] _gain = new float[SourceCount];
+ private readonly float[] _appliedGain = new float[SourceCount]; // last gain sent to AL (skip repeats)
+ private readonly Func<float, float>?[] _falloff = new Func<float, float>?[SourceCount]; // null = OpenAL's curve
+ private readonly Vector3[] _position = new Vector3[SourceCount];
+ private Vector3 _appliedPosition, _appliedForward;
+ private readonly bool[] _pausedByUs = new bool[SourceCount];
+ private readonly Dictionary<string, uint> _buffers = new();
+
+ public static OpenAlAudio Start(IAudioMix mix) => Current = new OpenAlAudio(mix);
+
+ private OpenAlAudio(IAudioMix mix)
+ {
+ _mix = mix;
+ _thread = new Thread(Run) { IsBackground = true, Name = "audio" };
+ _thread.Start();
+ }
+
+ public AudioPauseState State
+ {
+ set => Volatile.Write(ref _state, (int)value);
+ }
+
+ public void Play(in Sound sound)
+ {
+ if (_queue.Count < MaxQueued) _queue.Enqueue(sound);
+ }
+
+ /// <summary>Stops the one-shot sounds playing on <paramref name="channel"/> (loops and streams keep going). Applied on
+ /// the audio thread before any sound queued after this call starts, so stop-then-play cuts cleanly (a voice line
+ /// replaced by the next one).</summary>
+ public void StopChannel(int channel) => _stops.Enqueue(channel);
+
+ /// <summary>Replaces the set of loop emitters (UI thread, every rendered frame).</summary>
+ public void SetLoops(List<LoopEmitter> loops)
+ {
+ lock (_loopLock)
+ {
+ _loopsPosted.Clear();
+ _loopsPosted.AddRange(loops);
+ }
+ }
+
+ /// <summary>Listener = the camera (UI thread, every rendered frame).</summary>
+ public void SetListener(Vector3 position, Vector3 forward, Vector3 up)
+ {
+ lock (_poseLock) (_listenerPosition, _listenerForward, _listenerUp) = (position, forward, up);
+ }
+
+ public void Dispose()
+ {
+ _running = false;
+ _thread.Join(500);
+ if (ReferenceEquals(Current, this)) Current = null;
+ }
+
+ private void Run()
+ {
+ try
+ {
+ Open();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"audio: disabled ({e.Message})");
+ Close();
+ }
+ while (_running)
+ {
+ try
+ {
+ if (_al is not null) Update();
+ else _queue.Clear();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"audio: disabled after error ({e.Message})");
+ Close();
+ }
+ Thread.Sleep(UpdateMs);
+ }
+ Close();
+ }
+
+ private void Open()
+ {
+ _alc = ALContext.GetApi(soft: true);
+ _al = AL.GetApi(soft: true);
+ _device = _alc.OpenDevice("");
+ if (_device == null) throw new InvalidOperationException("no audio device");
+ CreateContext(_mix.Hrtf);
+ }
+
+ private void CreateContext(HrtfMode mode)
+ {
+ int hrtf = mode switch { HrtfMode.On => 1, HrtfMode.Off => 0, _ => AlcDontCareSoft };
+ int* attributes = stackalloc int[] { AlcHrtfSoft, hrtf, AlcFrequency, Pcm.SampleRate, 0 };
+ _context = _alc!.CreateContext(_device, attributes);
+ if (_context == null) throw new InvalidOperationException("no audio context");
+ _alc.MakeContextCurrent(_context);
+ _hrtfApplied = mode;
+
+ var al = _al!;
+ al.DistanceModel(DistanceModel.InverseDistanceClamped);
+ fixed (uint* ids = _sources) al.GenSources(SourceCount, ids);
+ foreach (uint s in _sources)
+ {
+ al.SetSourceProperty(s, SourceFloat.ReferenceDistance, ReferenceDistance);
+ al.SetSourceProperty(s, SourceFloat.MaxDistance, MaxDistance);
+ al.SetSourceProperty(s, SourceFloat.RolloffFactor, 1f);
+ }
+ Array.Clear(_pausedByUs);
+ Array.Fill(_appliedGain, -1f);
+ (_appliedPosition, _appliedForward) = (new Vector3(float.NaN), Vector3.Zero);
+ InitReverb();
+
+ int status = -1;
+ _alc.GetContextProperty(_device, (GetContextInteger)AlcHrtfStatusSoft, 1, &status);
+ if (EngineDebug.Audio)
+ EngineDebug.Write("audio", $"device '{_alc.GetContextProperty(_device, GetContextString.DeviceSpecifier)}' hrtf request {mode} status {status}");
+ }
+
+ /// <summary>HRTF only changes on a new context; buffers belong to the device and stay.</summary>
+ private void DestroyContext()
+ {
+ if (_context == null) return;
+ ReleaseStreams();
+ ReleaseReverb();
+ fixed (uint* ids = _sources) _al!.DeleteSources(SourceCount, ids);
+ _loopSlots.Clear();
+ _alc!.MakeContextCurrent(null);
+ _alc.DestroyContext(_context);
+ _context = null;
+ }
+
+ private void Update()
+ {
+ var al = _al!;
+ if (_mix.Hrtf != _hrtfApplied)
+ {
+ DestroyContext();
+ CreateContext(_mix.Hrtf);
+ }
+
+ var state = (AudioPauseState)Volatile.Read(ref _state);
+ Vector3 position, forward, up;
+ lock (_poseLock) (position, forward, up) = (_listenerPosition, _listenerForward, _listenerUp);
+ if (position != _appliedPosition || forward != _appliedForward)
+ {
+ al.SetListenerProperty(ListenerVector3.Position, position);
+ float* orientation = stackalloc float[] { forward.X, forward.Y, forward.Z, up.X, up.Y, up.Z };
+ al.SetListenerProperty(ListenerFloatArray.Orientation, orientation);
+ (_appliedPosition, _appliedForward) = (position, forward);
+ }
+
+ bool pauseEffects = _mix.WorldPaused(state);
+ for (int i = 0; i < SourceCount; i++)
+ {
+ var s = SourceState(_sources[i]);
+ if (s is not (Silk.NET.OpenAL.SourceState.Playing or Silk.NET.OpenAL.SourceState.Paused)) continue;
+ SetGain(i, _gain[i] * Attenuation(i, position) * _mix.ChannelGain(_channel[i], state));
+ if (!_mix.Pausable(_channel[i])) continue;
+ if (pauseEffects && s == Silk.NET.OpenAL.SourceState.Playing)
+ {
+ al.SourcePause(_sources[i]);
+ _pausedByUs[i] = true;
+ }
+ else if (!pauseEffects && _pausedByUs[i])
+ {
+ al.SourcePlay(_sources[i]);
+ _pausedByUs[i] = false;
+ }
+ }
+
+ while (_stops.TryDequeue(out int stop))
+ for (int i = 0; i < SourceCount; i++)
+ if (_channel[i] == stop && !_streamSlot[i] && !_loopSlots.ContainsValue(i))
+ al.SourceStop(_sources[i]);
+ while (_queue.TryDequeue(out var sound))
+ {
+ if (pauseEffects && _mix.Pausable(sound.Channel)) continue; // a paused world makes no new sounds
+ int slot = FreeSource();
+ if (slot < 0) break;
+ Start(slot, sound, state);
+ }
+ UpdateLoops(state);
+ UpdateStreams(state);
+ UpdateReverb();
+ }
+
+ private void UpdateLoops(AudioPauseState state)
+ {
+ var al = _al!;
+ lock (_loopLock) (_loopsPosted, _loopsWorking) = (_loopsWorking, _loopsPosted);
+ _loopSeen.Clear();
+ foreach (var loop in _loopsWorking)
+ {
+ _loopSeen.Add(loop.Key);
+ if (!_loopSlots.TryGetValue(loop.Key, out int slot))
+ {
+ if (_mix.WorldPaused(state) && _mix.Pausable(loop.Sound.Channel)) continue;
+ slot = FreeSource();
+ if (slot < 0) continue;
+ Start(slot, loop.Sound, state, looping: true);
+ _loopSlots[loop.Key] = slot;
+ }
+ _gain[slot] = loop.Sound.Gain; // a fleeing critter gets louder and higher
+ al.SetSourceProperty(_sources[slot], SourceFloat.Pitch, loop.Sound.Pitch);
+ al.SetSourceProperty(_sources[slot], SourceVector3.Position, loop.Position);
+ _position[slot] = loop.Position;
+ al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, loop.Velocity); // doppler
+ }
+ _loopGone.Clear();
+ foreach (var (key, slot) in _loopSlots)
+ if (!_loopSeen.Contains(key))
+ {
+ al.SourceStop(_sources[slot]);
+ al.SetSourceProperty(_sources[slot], SourceBoolean.Looping, false);
+ al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, Vector3.Zero);
+ _loopGone.Add(key);
+ }
+ foreach (var key in _loopGone) _loopSlots.Remove(key);
+ }
+
+ /// <summary>The slot's own falloff at the listener; 1 without one.</summary>
+ private float Attenuation(int slot, Vector3 listener) =>
+ _falloff[slot] is { } f ? f(Vector3.Distance(_position[slot], listener)) : 1f;
+
+ private void SetGain(int slot, float gain)
+ {
+ if (gain == _appliedGain[slot]) return;
+ _al!.SetSourceProperty(_sources[slot], SourceFloat.Gain, gain);
+ _appliedGain[slot] = gain;
+ }
+
+ private Silk.NET.OpenAL.SourceState SourceState(uint source)
+ {
+ _al!.GetSourceProperty(source, GetSourceInteger.SourceState, out int s);
+ return (Silk.NET.OpenAL.SourceState)s;
+ }
+
+ private int FreeSource()
+ {
+ for (int i = 0; i < SourceCount; i++)
+ {
+ if (_streamSlot[i]) continue; // a starved stream stops, but its source stays taken
+ var s = SourceState(_sources[i]);
+ if (s is Silk.NET.OpenAL.SourceState.Initial or Silk.NET.OpenAL.SourceState.Stopped) return i;
+ }
+ return -1;
+ }
+
+ private void Start(int slot, in Sound sound, AudioPauseState state, bool looping = false)
+ {
+ var al = _al!;
+ uint source = _sources[slot];
+ if (!_buffers.TryGetValue(sound.Key, out uint buffer))
+ {
+ buffer = al.GenBuffer();
+ var clip = sound.Render();
+ al.BufferData(buffer, BufferFormat.Mono16, clip.Samples, clip.SampleRate);
+ _buffers[sound.Key] = buffer;
+ }
+ _channel[slot] = sound.Channel;
+ _gain[slot] = sound.Gain;
+ _pausedByUs[slot] = false;
+ al.SetSourceProperty(source, SourceInteger.Buffer, buffer);
+ al.SetSourceProperty(source, SourceFloat.Pitch, sound.Pitch);
+ al.SetSourceProperty(source, SourceFloat.ReferenceDistance, sound.Reference);
+ al.SetSourceProperty(source, SourceFloat.MaxDistance, sound.Max);
+ bool positioned = sound.Position is not null;
+ _falloff[slot] = positioned ? sound.Falloff : null;
+ _position[slot] = sound.Position ?? Vector3.Zero;
+ al.SetSourceProperty(source, SourceFloat.RolloffFactor, _falloff[slot] is null ? 1f : 0f);
+ _appliedGain[slot] = -1f; // force
+ SetGain(slot, sound.Gain * Attenuation(slot, _appliedPosition) * _mix.ChannelGain(sound.Channel, state));
+ al.SetSourceProperty(source, SourceBoolean.SourceRelative, !positioned);
+ var at = sound.Position ?? Vector3.Zero;
+ al.SetSourceProperty(source, SourceVector3.Position, at);
+ al.SetSourceProperty(source, SourceBoolean.Looping, looping);
+ SendToReverb(source, positioned);
+ al.SourcePlay(source);
+ if (EngineDebug.Audio) EngineDebug.Write("audio", $"play {sound.Key} {_mix.ChannelName(sound.Channel)} {(positioned ? at.ToString("F1", null) : "2D")}");
+ }
+
+ private void Close()
+ {
+ if (_al is not null && _alc is not null && _device != null)
+ {
+ if (_context != null)
+ foreach (uint b in _buffers.Values) _al.DeleteBuffer(b); // AL calls need the context
+ _buffers.Clear();
+ DestroyContext();
+ _alc.CloseDevice(_device);
+ }
+ _device = null;
+ _al = null;
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/AudioMixTests.cs b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
new file mode 100644
index 0000000..93a0bed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs
@@ -0,0 +1,22 @@
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class AudioMixTests
+{
+ private sealed class Plain : IAudioMix
+ {
+ public HrtfMode Hrtf => default;
+ public float ChannelGain(int channel, AudioPauseState state) => 1f;
+ public bool Pausable(int channel) => true;
+ public bool WorldPaused(AudioPauseState state) => false;
+ }
+
+ [Fact]
+ public void ChannelName_DefaultsToTheNumber()
+ {
+ IAudioMix mix = new Plain();
+ Assert.Equal("2", mix.ChannelName(2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/Mp3Tests.cs b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
new file mode 100644
index 0000000..9cdca1e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs
@@ -0,0 +1,90 @@
+using System;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+/// <summary>Fixtures: our own sine tones (ffmpeg lavfi sine, amplitude 1/8, 0.25 s) encoded with lame 3.100: mono
+/// 440 Hz at 22050 Hz (64 kbit/s, --resample 22.05); stereo 440 Hz left / 880 Hz right at 44100 Hz (128 kbit/s,
+/// --resample 44.1). Oracle: a sine of f Hz crosses zero 2·f times a second; counted in a 0.1 s window inside the tone
+/// (lame's encoder delay, ≈ 1105 samples, and frame padding are near-silent and cross zero at random).</summary>
+public class Mp3Tests
+{
+ private static readonly byte[] Mono = Convert.FromBase64String("//OAxAAAAAAAAAAAAEluZm8AAAAPAAAADAAACpsAFRUVFRUVFRUqKioqKioqKkBAQEBAQEBAVVVVVVVVVVVVampqampqamqAgICAgICAgJWVlZWVlZWVlaqqqqqqqqqqwMDAwMDAwMDV1dXV1dXV1dXq6urq6urq6v//////////AAAAOUxBTUUzLjEwMAFuAAAAACwcAAAUQCQDNyIAAEAAAAqbZuNtUQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zgMQAGXhihD9PGAAL1tu36HmmaZpmmh7mwIYQcQsB3AIwDsFWOM62d48ePHjwEATB8Hwf5zg/4YlPfynn+U8/ynv6OCAPg+8HwcBAMKDAPg/LgQEOD79Hg+D4Pg+DgIAgCAYB8HwfB8CAgCAYjwfP0TgwNcGSMHRBihkACAAK6gCMA0AjTEgxJYwkpCdMCGApDGbhG4wkwAOMQqUPjLEQmAwk0JoMx5AuDBQADYxfUKqMB8AhwNBPMDcTFA140RjiJkV4GMzUBmsxAZjORMn/84LEXT8LcigBn6gAoZbgYUHYGPB2BjojgYHERk6K/AxKIgMSiwAAJgYWC4GFg6ySqPXANBAGDQYBg0IALAMDAgFAwMCQbpL/+ygMAgADAICDZQbBoNg0OiCyILIhHLf/6sMUhgEUkGrg1cMaIKiCo5QoIWUQ0XL///t8XKQ4c4gxRHNJ0yIsXjpMnlF5Pda//X/1f0TjTFKUkUSZMjIhxiXSAlIvEC6v9v01VYyqiYAYAEgQAHCwCoYGyB6GB7iehg7oeUYfeDIGF7joBlOJHP/zgsQkLBPOBAHfGAEH1Hq4ZlbBCKDhzgwJkB+MENBNjAoQC0wGoBZMAzAODAAwBkBAHMt/Hmtk1QqS22FpeNlWPioRu2FEE3FKmZkk/lBPhAunsxZN7W6tv/63ue+RHzyIH8wxRFzelzU56kmdaH+Vu/U8NXSS2lK/9PY+Y5bGd1SovmdJjHIoTCg0zM5DSl+jC3tYaPy8ylWAiYVWn8t3KrGjAZAtcQAAaPQGACjANgCc4pgsKOgARYqThb6DpedFRqEp21m2dmddWQ+ilOnE//OCxDcesnYU9P7EZXb+351mFovqvZLSuiUVXPotLo+VyyF8+qPUhKazkmtDedn6mkpbrvYuksSf+B3ZTb0o/Dmj7e7HyfHqX917evUOfJdhIHW9VY8k6YF2IbAUAQAgAGYAoAGmAvgKR5ZKTWfyJBCyLByoXuigQEUVHXd6dtDO1ykc11PumfIGEZmdSTNRGYYdzKyBtrvvoZrXQpX7m9D96MlZN6siutdadkRL0J1ulLq+mzLr1Z366lZ0lJz72uc1bseQrvjuIMtgQ7CYZ5//84LEgCC7nggK/splKg8c/WNaASEAAMDyExzAAgBcwAEAGMAdAFDAbgG8+V5c4MEeAHwMA1CQAqmc1aB69zPtL6NqU7URccSpKIhWmQpHQZeju+9HbY8thobRp01eujlmnzM/P7QSm+d24ncJvV9iS87smVIg631WdMmk4mpTiEWveelO7KZk05pMy1k3a7qamWbnJ5FQu0LHXmJm6JC6KkxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqu46uwUOAP/zgsTBJBO+BAD5Rt0CYIaJdGACgCxgAQAOYA4ALmA2gPR8abK6cbEgCNIkFkzWhQ5KLurGS2TpeZOfS0PefkvHzKtghefDPMy35DyY4HFadILmXyFwNSuZnrlmc9mPQ/6m5Bi3aHEnOpfWrmXT/I5rsvJdbbYt7kUpHPRyv/9Jl6gykWvJkFaehSGu3pcTrvMX2qRzVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVd5Y00dQ//OCxMoji84EAP8GafjBJBK8wAYAXMAFACDAHQBgwGwCHPg2bajjApMZgAOCqZzQYHm5rnU4v62u+f6S6muO+V7mG2rgLpcU/ddd1UTMLNDTJneep//rarj4TeeUiJia/plhfqhqbc3q/FeixN7QV5tbk2rAbLu0aBn1q+yD9NXvGp7I48V7tmVMkkZz0dNtMi06gxZdTEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/84LEySNydgQA/xBpVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVcABG3Pd//+t5QSZdxymTDUVTAEwCA05EpRAwE8ga20VnryLpWcuz6Eqy6XV1pXXXUdP5vy7qyjI/nBr1FtnWhGStTK7KCxxbXrS1zDhovFyX0xSUvbY9poTE59dIWyKHjjK0RhMQU0vGpMwAAAQMAOAIjAVAFQwCECfMFQE1wCGHmJXh9pjBxBgZBUBEH1vOqBkI4UmYbQHfGEtAyhgwP/zgsSfGPlWJbTnxEgBwGA2gYosBLDgBGYCKAEmAHABC9P1rnmQhKIJsqnWhTlKSVTlZKxh4flae1jVK2clkVwwlioe50low941CupjuRmRiuImIWY2wxc4g+MSZToREQioSjmEVdKFJMV36kZEVTEut7Kqoea7UddonmaYXMhhyOcqGKRh5mVGs5CuaYTNgQjfnBB09SUBcwHhVDJUIgMHIdkwZxGDFfCUMaAH80t9yjWBO3MoWk8xiC2zE4ItOuIZAxCwizx4FNMpcYExfgRj//OCxPwwG84AAV8oARHRCjEQEL3uefcJZyt4mNQ8YzOgCVZl5AsHv5mxyyYiPRngZGChqY4CpgscQPEJx1DCRMMaAMyKLTFgTTeMGhBEdDKbn5frhjUOhYBAYxmCg8DhIluYGBJfVC0EgjPDCczv2zBAOLUmFQKDAGEBdAIVgtmSKTR5U3FYrScOdw/m+5gABLcBwATbDAGrKq9IhST2I9Oky5uO3ZisR1z/5zfe65h6KEpTUXmtdkb9LocZgkNtAZXKGtRKLOjVjNSrchqVUe//84LE/1O72igBnuAB/////////+B3LiTiRKBH/g52779xyHI5RRuGastoPrU1bOU1bNbVbv//f//1////////FYf1F5RLJRZl9Lbwo6TCk3b3nhlulx5WhXizFCoVAGPOGpaG9gHBcGrJDpU3VU71UHLzbVzxXTlLxIoZFEatQaU4AkSHxkTxpURmxieYIBmIEmEAo+pMBtAAIBiTVFC3C5FymTxpIVO9i4fUYlcrldO2nKdM6dNE6WXTEnkOmYlcro2a1rv/23mC93BevYuoT//zgsR0JlF5rAHaeACfahPnx4FToldEqwVcDQcBoGioKjwVd8GlA0eEp0SnYNf6P/1nakxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq");
+ private static readonly byte[] Stereo = Convert.FromBase64String("//uQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAALAAATlgAXFxcXFxcXFxcuLi4uLi4uLi5FRUVFRUVFRUVdXV1dXV1dXV10dHR0dHR0dHSLi4uLi4uLi4uioqKioqKioqK6urq6urq6urrR0dHR0dHR0dHo6Ojo6Ojo6Oj///////////8AAAA5TEFNRTMuMTAwAaoAAAAALCcAABSAJAQvRgAAgAAAE5awSG/GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQBAAAAp0NzJ1kwABUofnTrCQAC+ytNznFAAF8lWeLONAAAUbbkNVU00TNFTPCwxqPHNQczxpCF61h0h0i1TsTa+/8vCCAAAAAIEEIB8HwfB+UBA5icH+CDpzp85y/nOX93T7uXB8HwfD4IBiUAYP6QQOZf3dIBjbcip1TqnVOqdicDvouxCWARmdZteb2mkZa9dbvhgUCgUCgUIEEJzmjB8H3lAQDGXP8EHTnT5zl/Ocv5zp93Lg+Hy4PggCDhAAwfPw+CBzLh/u6QAMwAEMEMIIADDZGMQ+pIwxfgF6GPz8buKIVAB0oOPieWBDZTH4cMNhxzuutsA0AT4AUQQN34CwgQbQbf8QQFYCkKoNv/j0iHw+NN//FUiHw+IR6Pf6gaEoSBr/iU6DUCECGAAXARgcBaRdydEx2PDCEhYYZeHBMIDGlmNbhIwiEg7TJMGVwszkEdAvfA9RPhGvxGiRHqOH/HcMMJcSI9f/HqZF4vGJd//LpkXi8iXS7/EQVBURHv9YKiIKgqIkATSIoYA4AdmAPAHZgTgFyYAgBPGAj//uSBAsIAswVRDd8QABPQWjt7zwBCqhVES/sRoFqFWEV34kQgcBltKwGZHsKjmFcA3JgpAHOYEmBeGBUgUhgXIEUYBiAVQNRw7jk36ez9+Vw4i3iRxijh78q3/rXb9NWjq/enG///vAAAAEjcRBBP3YdlKqBgIgKGAqGkYIITRhRgZGDqJ8Y6oxR1bn2mPQLeTCSAIAowGwIBIAsuKLcf6E+qvG2Ws7OS/7Ff6/6ftq/Tq/3K/+kCGhZUtmYA8ARGAmgK5gTwGMYMSFEmUyJ7plEoUWYMgBvHZx5q7GacrGm0JlZKlZD4AchH7fmRPndAYYHYt/JUd+H7voIuPKsbeiz9y/0J/XMPd1o6IAABvLdyZUeMdtBCwDl/jAkADAQgDE52gyWMC6ABRIA2QtaFB0XDoptNO2v3s6+nRV+I7V+351mFiuL0Z0UULgezzrSufWRWCCrQIlBawvMIqW66at9bMVTNvUwEkBSMBQAcTAcwLgwMgFvMHdEgDMuIzgzGMRmMG0BOzZUoyQxNzXjlsk2A1DBZpsus9VNvpCr6cijaP/7kgQdjwLNFcKD+xGwPQPo7RfCKAxoVwgOfqJBhJVgwf2JSJnztYA3zHeINXvTkFfeitiWs5KPbfrXVsfv6N3NbUQgIBmzWyCSStUCzARDMfZrKmpbY6lzyRoDpoMihM9YRdK76dtHtfvr365Bgx0au//9n/rd1/eQ+P5hv/95g4EGSySaYTxyeqmFbCFho+cB8aMIIYGFhgv4HQoABqReAaTVYGkJUBlYqAFA8UARcuF9Nn+3OfT9Z7SLsfmGV17C5JjxzbG98otcZS7Ze+5zb8sbsIJjGJvI9mUIUZFLpgdd+sa0RTuMC0DqQQAFlnjADwAgwE4BPOw2R4D1BEIRhoFWc+UjDglel9G1Y7UTjakpQrXyOidN96W2W1gY+qTXIx1hvqtHllOnFnYxrGhTAYTgqxpkUnCTBAgrxq1TTFFNCgDLKjRkxeZaiGgO5vPoYJYGKmQfsKpkAgYGYIiBpAbJeBlSQHGZgd/wBuywWZHcav0vb0/f3nrJUOE3xfKPUjTOuR6e7/f/s7L5NoyYL2pD5Vv06CwAqB7j+5pbJlb/+5IELwgCpxXEM3+gkF5hWEV3+0ALKFcMr+xGwOsP5DQ/CKTlQNAYuCYFAOYCCAlHM4okZ24cGGqETNYVLKl3TgCLMJUB9cyXZAM/Uo+8jffgIcD1ZVNSLBqZVHCkzuJMq1LdF2oX2HTShUqLrINa4VSwoHm8BOKGBMYBgBMwDYAsMBzAbzA4APQwh0MRM4YX1zNwwxUwiQEWP1vTd4s2Z+Nj5TOT0uw/8op7BH1ZPkVfsrxnsKX3p6n96Mo7675JJpG3G3q9orR8/11sQdVvv/m6wGBFbJZCiXzlBGAgEjDLWlBi/pvzHnDQDrzTsDTttU9O2/9n/erenxdVPtEMl/vfr/+3/1f9y932+pUAACOLMYjgxoVTJaONDSEwUsOYMkAcRDIww4QwSoD9M1QDIBM1k6NyhzSB0WBn5nrKVo1F2aihDpT7KvpfVZ/b2Kcvu/ZyKHs9NG5LrXWN7umAQAd/VWaWSZm4wBQGLYmBQEmAfgLByyae6YFiALA4AxRGZLA04IDTMjrdu+mrdL30Y9Fuucelu837brhiu9q1IJ57//uSBEoIMogIw8Of2JBXBWhFd+VEi5hXCg5+okFSFWEB34kQ7GudW5d91W4QRdNo+oolz1r/84kFCAyKMzRR6OFw0wnYNYNBhZ+jP/A14wn8E3A442ANFKQDOqXAzdDAMfEoA4Bi5CLlwzTQ9Tea/+cbVYxViqkeUV9HXeV73XsQOzO8lail+xExqcn11q2l0XXmlmv1u5MrKM2sNAAClsjAsCjAPgGE5RJUmMCuAGQcAZIis2hM+DMX2ojdDtr1qzd/0ef396r323GxHbmYtFkswKIG6mpOWcUtYxeVsN7MCdy17mNl9IGVAEyiqmJheYpIxjo+mbYAYIgGamPEsopjqQZGYHmBwAaB6BnQwHMVgeXYBvyQWkjuKr/7+ky9+k5kyeaujtbvwl+j5bX3/7P7vRRJnPv9QA9/WM0zEzJqACgAXdMBgHMAzAVjgEU6c5MUCCFJJtYtYFX25PYlB7TS/JV00ajYw7s8X8Z7yt7IpFbMelCZxGvfKKqkK3IWFRinRRFrgXDJGnImv8DDDPlTmRjDRENNdCK01rRETDSBWP/7kgRcCEJzFcQzn6CQTeEoUXf7QAiQHxute0JBQY/hVd+JEOm5M6nMoiMnPMGJdynBMMPW7WzmWDK7bk0Vu9Ov/t0L/v1s/9/+3/+mAgN63qZZ8ZrUqBABLvGA4EGAYALpvxilgYEgALg4AiSRbaK3wZlKnr+vdH36U1WvxEd+xCBGL7K86fk16uyu8kvelyRRtWuNQxHff1WeVRSqAAAjizEouMTEkxufjMkFMEBDQjHJmbAxvUMyMDiA5zMwDQATopjzZTfERoFAs9jYrq11B30q7a/o12f6NK6u/9naUt/rsv33fQyAAAY/+PqtUCpgriMMOclQEwBwHD4nnjAwMSsL9S21xO/9emj/3stdNdBnv3/T/ahf/exeS02fu/0//5cS6f62tuSBdZawwVA8xQGQ6n7Q6UGYxTBc7IAggaEKUUwp8DkPZ/f192yMYd+UZ6az+ytTKX0fp6/9/bd/T//V2JgGUIg/38qrpGsPGgFTGAIAMAcASTXakXQ8g8MFLVfqdHnqqmV8jMUdKtlPjKdFHjNKdLXan2f9q9H0aGv/+5IEgggCSwjEQ5/QkDyj+LwfwigIMFcnruBGoQaEYdnP6QA6dzdTtNUAACNAMWDsxaWDHyXM3zIwRYPLMe3dtzHgA7cwPMEQAylUAK0B4lYH73Ac0oCISNIqn1K/V5g6H+kfu7koU2sjT/+/Spbdn/1f/2c0AABBA5f+6zcTNeHi2yDpgEA5gEoC4bRCpanzLA4irpxYte0da5zbqFIqRBCKJqf/rQTT/Z/cQ6tjN++qUv6e5Xc1NrGp1rqBi+fmTZAQEhgZgOmDgCwYkIn5voULm8+KGYkANx9Z5rXpnnBm+ZhyrXKchyX9fq363OO+Wbv+/0ZJ5bd9mr3/t/9/+z7v6sQAAVLpnC1HzX63NGkYrkv8w4CAKnRtEoDgNWTQ7LrPVTT+xdV2fZvbnv+PTm/r/6Nrf/X9tK8hr/s3f+8wEMBUMBAAfTAUwM4wI4GdMFZFBDJcJxoyS0TsMEqBczE34FSJxZwdZHm3D4sPNrIrPVbfrSM/p0OPoUR7WVL8uyp5Gv9dQkW/pcuWvazRGWejVZdPO0bmJ7xhIAQAd3rG//uSBLwIAl8WQ8OfoJBHYThod/pACNhXFy9oRoD0D6N1vwjYaZCaa1MYDgEWZMCgTMA1AijgW3N8wJUAqMASAAUNmWw9ZQBN7300Rqt3pdKOv2yiiuvz6d79Ri38iPYvqL2kC68XvqptcguZhlWemm0/02ZQWuNCqaViIVMtCTXDI7eFMbwcQ7v+BDt2HMMb8Kk7p+NOZjOWMzC4MULFTxCvUB92yl98gH3abd7KgzQ+qxDH2/OV6qu9X23810fZxiFJAo9VBEJA7yx1MsiNQKVMCABLNGBYJGAZASJvwTuYYEgAWmAIgACKLLodvlMV+hFdn6yU7UdqKlT7GeD+/6druiFEbXP29VPpbuv9D/2NsC98mkCKKnU0KQuKnFoODU1Bk1MTLVE1KULKAAACdUshZJQEAgkYAEpj7hghCmmSnVqZIAo5gagpAk8HCAPCB7cA1IaJsavV+v2v610Tpt963CT5j+ds93jnfR/q//tphUAACIgy/LGmZCal0AYEgABgBMCgVMAxAlDen3q8wI0AuMAQAA0UmWw9ZQpmZk+j7v/7kgTviILcFcKD+xGwWOVYRXfiRIn8IxDN+2JBkKGhFd+JEIq2JT3r6p6jWW910VvUGlBi666sLi8uhwni7bwAp7WFq+4g4oOs2pQUuuaL3nrx51tClx7nIpyWRAAAwVwVTBLBZMHEBkwyhcjGyM8PICt07f06TKfHTMTkFUwwxITENDjMNcM0wIwQTAaA0MDACYFAKRl4DBDAQ5eNk6ehiGGggBlBkc5JjgSRIQllo2hpyKWKrmMQaRQQJDJdcwRQEGle3BAOpvKmcPxBa52XtwLZoA2nxxrDOHEhzcs5K4b1KIYlk25DuTleV09/+4YYfvOnz1T04EOJEYPqBAaCC4YEgYLg++ifEYfq6jgkOJ1ROHy71YP0EPghWAQAA5rrYyADABAFMA0BMwEQC0DjAsBaMGoGIwvAfzCAAFMLcM8xXBkjEEFDOri44www/DFsEMMJAGAwVAIACAWp0YCJmUjYGA0KxEGGQGBnR0ZgKBc3OEjDfloIWDCyExcPIgMGAgODBGCmRExnBUYwDLCmBh5ioyYuKgobR0MJCQUFu0n/+5IE+ggCMhVH616YmGTlWEh34kQYcLkTteyAA++iYva9sAAItRhZZ9AeloYaHmGgqSb0JCIOKaM0oXcnXbcemcBUjTHTLuIoPJDblu/f+njEYjF6vDblxeYZ279+kllivbwp6e3+FSksbpLGOeesMN0lJz888//PusPzzzwp7Y4MOWuCA0EHNlAxB8+D6qS59QAczMFAwn1iegAFGIKEIAAAADKkF7NLl4YwGQRAcDd/m2ecwYz44BgQAfmAMBH/mD2HqYWAUg8CCWi/wPADA/KYAEYF1eB4F4HSzgnMEsJo34GbggYUOFLoUjDmiEpFBafAx44AwaCQoM6AaBmKDmS/gKFAAhIAIACgEBwELfCkZJqX/EFxkhOouYQmGfFmGKKl6v5UGkO0XKOodZTJAk0quuv/jnEBJQjifIITpFiZIaTH1f/+Rc+TpeIsWSYQLZaIqUimdNF6/q///yuXi6ZAAAEaMKQQAAAGfxSmkKmAEEgcAf+St2ZhiyYIAuYDA7/nA0AmaRoBgGBADf4GcAiBoEUAYLEQCgLwNcGMDTi7//uSBIOABjtoRs56gADWrRkJztQAAAABpBwAACAAADSDgAAEAijAgAA5RX4GGR2BgMChQohQdC5RHJABIfAwuFgBAqCAIBCAgGAWTJo5NJ/AOEAGCgGBgMDAUAoCwFBvhSMk1I/wbNhgoGxIYqBt4MfCzhiigvV/IgLcO0VqM8M+Ux4HtJauuv/jNDkj8RxFyCERIMTJDSYX6lf/8i5bIiTxAiVJhAtloipNFM6VFrr+r///J8niZKX/60xBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==");
+
+ private static (float[] Left, float[] Right) Render(Mp3Stream s, int frames)
+ {
+ var buf = new float[2 * frames];
+ s.Render(buf, frames);
+ var (l, r) = (new float[frames], new float[frames]);
+ for (var i = 0; i < frames; i++)
+ (l[i], r[i]) = (buf[2 * i], buf[2 * i + 1]);
+ return (l, r);
+ }
+
+ [Fact]
+ public void Reader_decodes_mono_at_the_file_rate()
+ {
+ var clip = Mp3Reader.Read(Mono)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 5512, 5512 + 3 * 1152);
+ var f = Array.ConvertAll(clip.Samples, s => s / 32767f);
+ Assert.InRange(SignalStats.ZeroCrossings(f, 2400, 2400 + 2205), 86, 90); // 0.1 s of 440 Hz → 88
+ }
+
+ [Fact]
+ public void Reader_downmixes_stereo_to_mono()
+ {
+ var clip = Mp3Reader.Read(Stereo)!.Value;
+ Assert.Equal(44100, clip.SampleRate);
+ Assert.InRange(clip.Samples.Length, 11025, 11025 + 3 * 1152);
+ }
+
+ [Fact]
+ public void Reader_rejects_garbage() => Assert.Null(Mp3Reader.Read(new byte[300]));
+
+ /// <summary>Oracle: both tones are 0.25 s; the LAME tag's encoder delay and padding are not counted (the Info header's
+ /// 12 and 11 whole frames would be 0.313 and 0.287 s). Within 1 sample of rounding.</summary>
+ [Fact]
+ public void Seconds_come_from_the_headers_without_encoder_delay()
+ {
+ Assert.InRange(Mp3Reader.Seconds(Mono)!.Value, 0.25, 0.25 + 1 / 22050.0);
+ Assert.InRange(Mp3Reader.Seconds(Stereo)!.Value, 0.25, 0.25 + 1 / 44100.0);
+ Assert.Null(Mp3Reader.Seconds(new byte[300]));
+ }
+
+ [Fact]
+ public void Stream_keeps_channels_apart()
+ {
+ using var s = new Mp3Stream(Stereo, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // 440 Hz → 88 in 0.1 s
+ Assert.InRange(SignalStats.ZeroCrossings(r, 4000, 4000 + 4410), 174, 178); // 880 Hz → 176
+ }
+
+ [Fact]
+ public void Stream_resamples_mono_to_44100_and_duplicates_it()
+ {
+ using var s = new Mp3Stream(Mono, loop: false);
+ var (l, r) = Render(s, 11025);
+ Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // still 440 Hz (176 if played as 44.1 kHz)
+ Assert.Equal(l, r);
+ }
+
+ [Fact]
+ public void Once_only_goes_silent_and_finishes_looping_does_not()
+ {
+ using var once = new Mp3Stream(Mono, loop: false);
+ Render(once, 44100); // 1 s, past the 0.25 s file
+ Assert.True(once.Finished);
+ var tail = new float[200];
+ Array.Fill(tail, 1f); // the stream must write the silence, not rely on a cleared buffer
+ once.Render(tail, 100);
+ Assert.All(tail, x => Assert.Equal(0f, x));
+ using var loop = new Mp3Stream(Mono, loop: true);
+ var (l, _) = Render(loop, 44100);
+ Assert.False(loop.Finished);
+ Assert.InRange(SignalStats.Rms(l, 22050), 0.05f, 0.1f); // second half still sounding (sine 1/8: RMS 0.088 less gaps)
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/SynthTests.cs b/src/Godosa.Core.Tests/Audio/SynthTests.cs
new file mode 100644
index 0000000..7c2c29b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/SynthTests.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Linq;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class SynthTests
+{
+ [Fact]
+ public void Random_IsDeterministicAndInUnitRange()
+ {
+ var a = new SynthRandom(42);
+ var b = new SynthRandom(42);
+ var values = Enumerable.Range(0, 1000).Select(_ => a.Next01()).ToArray();
+
+ Assert.Equal(values, Enumerable.Range(0, 1000).Select(_ => b.Next01()).ToArray());
+ Assert.All(values, v => Assert.InRange(v, 0f, 0.99999f));
+ Assert.InRange(values.Average(), 0.45f, 0.55f);
+ }
+
+ [Fact]
+ public void Random_IsXorshift32OfTheMixedSeed() // pinned: seeded songs must sound the same in every run
+ {
+ // oracle: independent Python murmur3 fmix32 + xorshift32 — fmix32(1) = 0x514E28B7, first state 0x1F48D1FB
+ Assert.Equal(0x514E28B7u, SynthRandom.Mix(1));
+ Assert.Equal(2050257f / 16777216f, new SynthRandom(1).Next01());
+ }
+
+ [Fact]
+ public void Random_NearbySeeds_StartFarApart()
+ {
+ var firsts = Enumerable.Range(0, 20).Select(s => new SynthRandom((uint)s).Next01()).ToList();
+
+ Assert.True(firsts.Max() - firsts.Min() > 0.5f);
+ }
+
+ [Fact]
+ public void Loop_CrossfadesTheTailIntoTheHead()
+ {
+ var x = new float[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+
+ var y = Synth.Loop(x, overlapSamples: 2);
+
+ Assert.Equal(new float[] { 8, 5, 2, 3, 4, 5, 6, 7 }, y); // y[^1] = 7 wraps to y[0] = 8, like x[7] → x[8]
+ }
+
+ [Fact]
+ public void Hash_IsFnv1a() // stable across runs, unlike string.GetHashCode
+ {
+ Assert.Equal(0xE40C292Cu, SynthRandom.Hash("a"));
+ Assert.Equal(0x1A47E90Bu, SynthRandom.Hash("abc"));
+ }
+
+ [Fact]
+ public void Sine_SteadyPitch()
+ {
+ var x = Synth.Sine(1f, 100f);
+
+ Assert.Equal(44100, x.Length);
+ Assert.InRange(SignalStats.ZeroCrossings(x), 198, 201);
+ }
+
+ [Fact]
+ public void Sine_SweepIsExponential()
+ {
+ var x = Synth.Sine(1f, 100f, 400f); // f = 100·4^t: 144 crossings in the first half, 289 in the second
+
+ Assert.InRange(SignalStats.ZeroCrossings(x, 0, 22050), 141, 148);
+ Assert.InRange(SignalStats.ZeroCrossings(x, 22050), 285, 292);
+ }
+
+ [Fact]
+ public void LowPass_PassesLowCutsHigh()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 200f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 200f), 2000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 8000f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 8000f), 2000);
+
+ Assert.InRange(low, 0.89f, 1.05f); // within 1 dB
+ Assert.InRange(high, 0f, 0.1f); // −20 dB or more (2nd order: ≈ −36 dB at 3 octaves)
+ }
+
+ [Fact]
+ public void HighPass_PassesHighCutsLow()
+ {
+ float low = SignalStats.Rms(Synth.Sine(0.5f, 100f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 100f), 4000);
+ float high = SignalStats.Rms(Synth.Sine(0.5f, 6000f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 6000f), 4000);
+
+ Assert.InRange(low, 0f, 0.05f);
+ Assert.InRange(high, 0.89f, 1.05f);
+ }
+
+ [Fact]
+ public void BandPass_PassesCentreOnly()
+ {
+ float Ratio(float hz) => SignalStats.Rms(Synth.Sine(0.5f, hz).BandPass(1000f, 2f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, hz), 4000);
+
+ Assert.InRange(Ratio(1000f), 0.85f, 1.05f);
+ Assert.InRange(Ratio(100f), 0f, 0.1f);
+ Assert.InRange(Ratio(8000f), 0f, 0.1f);
+ }
+
+ [Fact]
+ public void Perc_RisesThenDecaysByTimeConstant()
+ {
+ var x = Enumerable.Repeat(1f, 44100).ToArray().Perc(attack: 0.01f, decay: 0.1f);
+
+ Assert.Equal(0f, x[0]);
+ Assert.InRange(x[441], 0.99f, 1f); // top at the end of the attack
+ Assert.InRange(x[441 + 4410], 0.36f, 0.375f); // e^-1 one time constant later
+ }
+
+ [Fact]
+ public void ToPcm_NormalisesToLevel_AndFadesEnds()
+ {
+ var pcm = Synth.ToPcm(Enumerable.Repeat(0.3f, 4410).ToArray(), level: 0.5f);
+
+ Assert.Equal((short)16384, pcm.Max());
+ Assert.Equal(0, pcm[0]);
+ Assert.Equal(0, pcm[^1]);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Audio/WavReaderTests.cs b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
new file mode 100644
index 0000000..6ed5485
--- /dev/null
+++ b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using Godosa.Core.Audio;
+using Xunit;
+
+namespace Godosa.Core.Tests.Audio;
+
+public class WavReaderTests
+{
+ private static byte[] Wav(short format, short channels, int rate, short bits, byte[] data, bool extensible = false)
+ {
+ using var ms = new MemoryStream();
+ using var w = new BinaryWriter(ms);
+ int fmtSize = extensible ? 40 : 16;
+ w.Write("RIFF"u8); w.Write(4 + 8 + fmtSize + 8 + data.Length); w.Write("WAVE"u8);
+ w.Write("fmt "u8); w.Write(fmtSize);
+ w.Write(extensible ? (short)-2 : format); w.Write(channels); w.Write(rate);
+ w.Write(rate * channels * bits / 8); w.Write((short)(channels * bits / 8)); w.Write(bits);
+ if (extensible)
+ {
+ w.Write((short)22); w.Write(bits); w.Write(0);
+ w.Write((int)format); w.Write(new byte[12]); // sub-format GUID: first 4 bytes = format tag
+ }
+ w.Write("data"u8); w.Write(data.Length); w.Write(data);
+ return ms.ToArray();
+ }
+
+ private static byte[] Shorts(params short[] s)
+ {
+ var b = new byte[s.Length * 2];
+ Buffer.BlockCopy(s, 0, b, 0, b.Length);
+ return b;
+ }
+
+ [Fact]
+ public void Pcm16Stereo_DownmixesToMono()
+ {
+ var clip = WavReader.Read(Wav(1, 2, 22050, 16, Shorts(1000, 3000, -2000, -4000)));
+
+ Assert.NotNull(clip);
+ Assert.Equal(22050, clip!.Value.SampleRate);
+ Assert.Equal(new short[] { 2000, -3000 }, clip.Value.Samples);
+ }
+
+ [Fact]
+ public void Pcm24Mono()
+ {
+ // 0x400000 = +0.5 FS, 0xC00000 = −0.5 FS (little-endian 3-byte)
+ var clip = WavReader.Read(Wav(1, 1, 44100, 24, new byte[] { 0, 0, 0x40, 0, 0, 0xC0 }));
+
+ Assert.Equal(new short[] { 16384, -16384 }, clip!.Value.Samples);
+ }
+
+ [Fact]
+ public void Float32_Extensible()
+ {
+ var data = new byte[8];
+ BitConverter.GetBytes(0.5f).CopyTo(data, 0);
+ BitConverter.GetBytes(-1f).CopyTo(data, 4);
+
+ var clip = WavReader.Read(Wav(3, 1, 48000, 32, data, extensible: true));
+
+ Assert.Equal(new short[] { 16384, -32767 }, clip!.Value.Samples);
+ Assert.Equal(48000, clip.Value.SampleRate);
+ }
+
+ [Theory]
+ [InlineData(new byte[0])]
+ [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 })]
+ public void Garbage_IsNull(byte[] bytes) => Assert.Null(WavReader.Read(bytes));
+
+ [Fact]
+ public void Pcm8_UnsignedCentredOn128()
+ {
+ var clip = WavReader.Read(Wav(1, 1, 8000, 8, new byte[] { 128, 192, 0 }));
+ Assert.Equal(new short[] { 0, 16384, -32768 }, clip!.Value.Samples);
+ }
+
+ // MS ADPCM fixtures: our own tones encoded by ffmpeg 7 (adpcm_ms, block 256, standard 7 coefficient pairs). Oracle:
+ // ffmpeg's own adpcm_ms decode (pcm_s16le), stereo averaged with truncation, cut to the fact chunk's frame count.
+ private static readonly byte[] AdpcmMono = Convert.FromBase64String("UklGRnQEAABXQVZFZm10IDIAAAACAAEAIlYAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAACAYAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAQAAAAQAPwHAAB3d3MhAQAP/dzNvd3M7d3d3u8BNFNTRDNSMzQjIyEQ/tvM293M3dzd7d7vASREUzUzQ0MzNCIiAf7b283b3c3dzd7e7wEjVEQ1JDM0MzMzIhH+zL3MzdzN3dzt7f/xE1Q1NEM0M0MzMjIQDtrczM3czd3c7d7+ACNFQ0RDNDNDMzMiEQ7bzMzc3N3Nzt7e/gATRTRENDNDQyMzIhEO6829zN3M7c7t7u/xEkREQ0NEJCMjMiIgD+vb3M3Nzdzd3d7f8BNTVDRDQ0MzNCMiEQ7trb3b3c3c3d3d7/8iU1Q0Q0M0NCMjIiAf3bzM3M3czt3d7e7wEkU0REMASgLk/uj2QkIyMyIRH+zMzb3N3Nze3d3u8BI2Q0RDQzQzQyMiIQ/tvM292+3O3d3d7vADNjU0QzUjNCMyMiEP/Nrc3M3Nztzt7u7wETRTRENDNDQjMyIhH/zbzb3czd3N3d3e/xI1REQ0NDQzNCMjEgDtzL3M3Nzdze3d3+8RNUQ1NDQzQzNCIxEQ/czMzczdzc7d3t7/AjRFNTNDQ0MzMzMhEP28zNzM7N7O3e3u/wE0RERDQ0M0IzMzIhDuvbzc293c3dzt7v8BM1RENDRDM0IzMiIQ/sy9zNzc3N3d3d7vASRUNTQ0NDM0MjIiEP7a3Mzczd3N3O3u/wAjVDU0AEkC1PUA7jRCMzMzIhD+zMzM3M3dzc7e7u8AI1NTUzQ0MzQzIyIR/tvMzczc3c3dzu7vARNEU0NEM0M0MjIiEf/b283Nzc3N7c7u//ESREQ1M0M0M0IyIhAP3Mvc2+ztzt3e3u4AIlQ1NEM0NDMzMzIh/9vMzNzczs7e3e7uABNEU0Q0NDNDMzMjEQ7rzbzdvd3N3O3t/vESRERDRDNDQzIzIxEP3Mvczb7c7d3O3v4PEkQ1Q0NDQzQjMjIRD+2tvc3NzO3c7e7v8BFDVDRDQ0M0MjMiIQ/trczNzN3Ozt7t7/ACNUNTQ0NDNDIzIxIP7a3Mzczd3Nzt7e/wAjRTUwDmAfTseOVEMzQzMzIiAOzMzM3M3c3d3O3u8AJ3cQDx8fHx8fHx8fAQ8BDx8B8Q8fAfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8="); // 440 Hz ×4 gain, 22050 Hz, fact 1544
+ private static readonly byte[] AdpcmStereo = Convert.FromBase64String("UklGRnQFAABXQVZFZm10IDIAAAACAAIAIlYAAIA+AAAAAQQAIAD0AAcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAATwQAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAUAAAAAEAAQAP8Bs/gAAJLxeHd4RyATGycbEQ8EDeTs0sHAz87RwsLa0NPN39TP4NDi+eDxAh8uQ0JOTj5QPUcwMUtELjIwMT4wPSQgHQ7w5820ztC/38Pew9DN1d7SzN/U793k4v4BESpPQ14+Q08wQT5GPUMxMj9OLz4vJy8PEf/j27LMwt6y38Pa08Dez9Pf1dzU3+zj7g8VIE5RPl80TUAxQjtDPzNOMC8wMDUtLBUOAOHAwsC93cbdxM/S2sHQwu3Q1O3Q4uHwDR8TQExETEQwXzI6Qz8wTjNNITMpMSAQIwD+/dKw0rrB38XRzcLd0dDD3NTcwe7i/vTxHBQ8QUAAAEoACA649VUMRPRlAm4/NlAsQS8zPzExSyEvMR8THv700c2w37LSztHKwt7T38zW39Lf3dLg4w4CKDFjMF4/QUI9RE8/PjZNMDM+Mz8oMREd8fPMws3DzcTC28/R0MHO5N7U3t/e4e/n8RsSMVBNQ0A9UDU/TzJAOjBCPiQ9MiAhHxIN/NKz0rjP0rHhzNDS0s3SzuTd4tvg4uACCxMxQEpQNE5BPkNNPzQ/TjI/QiwhLiUgDgDx48/DysDO1sDO3tDB38PQ3e/X0NHu8vvyIitRP1NAMEBNPkc/MU8/QC4yMC4vIiUQDf/nwMyw0cLN0MLez9XP0tzS0M7j4eHvAABTAH8GBvDJ2Q3wlegXEDFMRE9OQTFCP04+TzEyPT8xQSAnER0MBe3Gz87RsMHS0LDo0cDi3t/U393Q4eXtDxQwTk9SPl81TTNAP0I+Pk0kPDMwJDsSLw7w4sXAvdHCydKx7cDg0dTcz9Tg0ODg7fT8ES80bzFPQUExTE89P0AwN04iPi42ICEeAfL5wMDDwM3UzsLfwdvB0NDS3dDD4uDp8QEQET1RPFM9UjIwTzdMMUM7IUAhIi0gIw8f/vviwr3exMLQuuPfwN/d0cHX7t7V7+7w8BUtM19ATTJUPkAyTTFPMTAwQTwlLz0fJgAA7OK+w9K4083A0t3BwOTd3wAAhACACpH0huoO9tQc1O/g4P31HR8yQ1k0XTBDPUBDPTFOIEMlLD8hIBUtDwTw3dOg0bzfw9LN3cHQ3s3Q19DV39zg8fIPIj5bREBPTUUwTTIwTUIvMTQ9NTwiPh8UAu/hsM3NxtC91N3D3MTc08Df7dLQ7uPqBQ4fMENcQTJRO0ExQTFPPj4wRD8jPCMvHBEA8uvAtt+w0sDQysLQ0M3S0NLQzuHu5O0DCREhMm0+UD9HPk9BMzwzQT5BLT4kLCERHgUP0dG+wMHD2LPf393Ewe/f09Dd0NDb9f3/ICRNUUM8VDBNQzwzQDE/TCMwPDIyLhArBPDdw8Kw2rHTy9IAAIUA0RBwAFYCbwKB6s7SzeHU0dzR3uPs8fAUME9DWjFQPjRRPjE+T0QgPTQ7IjAgIB4O8+zToNW70cDC3s7V3s7Swu/e0OXR7ODwAwskTk5CQ0swUTA/RD1PMz0wNU0jLDQeHxT/7dTAvcLO1MDcv+PQz+Hd1N7UzeHi/fwDBxA7QVAwUT4wUiBBHwAAAAEPAQ8BDwEAH/AR8B/wEf8R8A8AAQAPAQ8AAQ8BAA8BDwEPAQ8AAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8B"); // 440 Hz left, noise right, fact 1103
+
+ private static readonly byte[] AdpcmQuiet = Convert.FromBase64String("UklGRnQBAABXQVZFZm10IDIAAAACAAEAESsAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAASwEAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAEAAAAQAAMAAAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); // 300 Hz ×0.004, 11025 Hz, fact 331
+
+ [Fact]
+ public void MsAdpcm_QuietKeepsTheStepFloorOf16()
+ {
+ var s = WavReader.Read(AdpcmQuiet)!.Value.Samples; // ffmpeg: steps stay at the floor, samples move by 16
+ Assert.Equal(331, s.Length);
+ Assert.Equal(new short[] { 0, 3, 3, 3, 3, 19, 19, 19, 19, 19, 19, 19 }, s[..12]);
+ Assert.Equal((302, 3682), (Sum(s), AbsSum(s)));
+ }
+
+ private static int Sum(short[] s) { int n = 0; foreach (var x in s) n += x; return n; }
+ private static int AbsSum(short[] s) { int n = 0; foreach (var x in s) n += Math.Abs(x); return n; }
+
+ [Fact]
+ public void MsAdpcm_Mono_MatchesFfmpeg()
+ {
+ var s = WavReader.Read(AdpcmMono)!.Value.Samples;
+ Assert.Equal(1544, s.Length);
+ Assert.Equal(new short[] { 0, 2044, 2156, 2422, 3059, 4585 }, s[..6]);
+ Assert.Equal(new short[] { -6016, -4057, -2328, -284, 2060, 3464 }, s[498..504]); // across the block edge at 500
+ Assert.Equal(new short[] { -16364, -16364, -15876 }, s[^3..]);
+ Assert.Equal((74278, 16090432), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void MsAdpcm_Stereo_MatchesFfmpeg()
+ {
+ var clip = WavReader.Read(AdpcmStereo)!.Value;
+ Assert.Equal(22050, clip.SampleRate);
+ var s = clip.Samples;
+ Assert.Equal(1103, s.Length);
+ Assert.Equal(new short[] { -1847, -679, -687, -386, -527, 1116 }, s[..6]);
+ Assert.Equal(new short[] { 277, 3542, 2416, 255, -1687, -2380 }, s[498..504]);
+ Assert.Equal(new short[] { -1115, -937, 606 }, s[^3..]);
+ Assert.Equal((-183764, 2966742), (Sum(s), AbsSum(s)));
+ }
+
+ [Fact]
+ public void Downmix_InterleavedFloat()
+ {
+ Assert.Equal(new short[] { 16384, 0 }, Pcm.Downmix(new[] { 0.25f, 0.75f, 1f, -1f }, 2));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
new file mode 100644
index 0000000..c67042e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs
@@ -0,0 +1,59 @@
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class DebugFlagsTests
+{
+ [Fact]
+ public void Categories_AreCommaSeparated_Trimmed_CaseInsensitive()
+ {
+ var flags = new DebugFlags(" input , AUDIO");
+ Assert.True(flags.On("input"));
+ Assert.True(flags.On("audio"));
+ Assert.False(flags.On("frames"));
+ }
+
+ [Fact]
+ public void All_TurnsOnEveryCategory_ButNotExactSwitches()
+ {
+ var flags = new DebugFlags("all");
+ Assert.True(flags.On("script"));
+ Assert.False(flags.Exact("nohud"));
+ }
+
+ [Fact]
+ public void Exact_MatchesOnlyTheNamedSwitch()
+ {
+ var flags = new DebugFlags("frames,NoHud");
+ Assert.True(flags.Exact("nohud"));
+ Assert.False(flags.Exact("input"));
+ }
+
+ [Fact]
+ public void MissingOrEmpty_TurnsNothingOn()
+ {
+ Assert.False(new DebugFlags(null).On("input"));
+ Assert.False(new DebugFlags(" , ,").On("input"));
+ }
+
+ [Fact]
+ public void EngineDebug_Apply_SetsEachLibCategory()
+ {
+ try
+ {
+ EngineDebug.Apply(new DebugFlags("script"));
+ Assert.True(EngineDebug.Script);
+ Assert.False(EngineDebug.Input);
+ Assert.False(EngineDebug.Audio);
+ EngineDebug.Apply(new DebugFlags("input,audio"));
+ Assert.True(EngineDebug.Input);
+ Assert.True(EngineDebug.Audio);
+ Assert.False(EngineDebug.Script);
+ }
+ finally
+ {
+ EngineDebug.Apply(new DebugFlags(null));
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
new file mode 100644
index 0000000..44b6758
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs
@@ -0,0 +1,97 @@
+using System;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class FrameTimeStatsTests
+{
+ /// <summary>99 smooth 10 ms frames plus one 100 ms hitch.</summary>
+ private static FrameTimeStats OneHitch()
+ {
+ var stats = new FrameTimeStats(capacity: 100);
+ for (int i = 0; i < 99; i++)
+ stats.Record(0.010, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ stats.Record(0.100, workSeconds: 0.002, simTicks: 0, simSeconds: 0);
+ return stats;
+ }
+
+ [Fact]
+ public void Fps_IsFramesOverTotalTime_NotMeanOfInstantaneousFps()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(100, s.Frames);
+ // 100 frames / 1.09 s. (Mean of per-frame FPS would be (99*100 + 10)/100 = 99.1.)
+ Assert.Equal(91.743, s.Fps, 3);
+ Assert.Equal(10.9, s.AvgMs, 6);
+ }
+
+ [Fact]
+ public void Percentiles_UseNearestRank_AndMaxCatchesTheHitch()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.P50Ms, 6);
+ Assert.Equal(10.0, s.P95Ms, 6);
+ Assert.Equal(10.0, s.P99Ms, 6); // rank ceil(0.99*100) = 99th of 100
+ Assert.Equal(100.0, s.MaxMs, 6);
+ }
+
+ [Fact]
+ public void OnePercentLow_AveragesTheSlowestOnePercentOfFrames()
+ {
+ var s = OneHitch().Summarize();
+
+ Assert.Equal(10.0, s.OnePercentLowFps, 6); // worst 1 of 100 frames = 100 ms
+ }
+
+ [Fact]
+ public void Hitches_CountFramesOverTwiceTheMedian()
+ {
+ var stats = new FrameTimeStats(capacity: 10);
+ foreach (var ms in new[] { 10, 10, 10, 10, 10, 20, 21, 35 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Assert.Equal(2, stats.Summarize().Hitches); // median 10 → 21 and 35 (20 is not > 20)
+ }
+
+ [Fact]
+ public void Window_DropsOldestFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ foreach (var ms in new[] { 100, 100, 3, 4, 5, 6 })
+ stats.Record(ms / 1000.0, 0, 0, 0);
+
+ Span<float> intervals = stackalloc float[4];
+ int n = stats.CopyIntervalsMs(intervals);
+
+ Assert.Equal(4, n);
+ Assert.Equal(new float[] { 3, 4, 5, 6 }, intervals.ToArray());
+ Assert.Equal(6.0, stats.Summarize().MaxMs, 4);
+ }
+
+ [Fact]
+ public void Sim_ReportsAchievedTickRateAndCostPerTick()
+ {
+ var stats = new FrameTimeStats(capacity: 60);
+ for (int i = 0; i < 60; i++) // 1 s at 60 fps; a tick every other frame, 1 ms each
+ stats.Record(1.0 / 60.0, workSeconds: 0.004, simTicks: i % 2, simSeconds: (i % 2) * 0.001);
+
+ var s = stats.Summarize();
+
+ Assert.Equal(30.0, s.SimTickRate, 3);
+ Assert.Equal(1.0, s.AvgTickMs, 6);
+ Assert.Equal(4.0, s.AvgWorkMs, 6);
+ }
+
+ [Fact]
+ public void Empty_SummarizesToZerosWithoutDividingByZero()
+ {
+ var s = new FrameTimeStats().Summarize();
+
+ Assert.Equal(0, s.Frames);
+ Assert.Equal(0.0, s.Fps);
+ Assert.Equal(0.0, s.AvgTickMs);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
new file mode 100644
index 0000000..46cf5ed
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+using System.Threading;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class GpuAndCsvTests
+{
+ [Fact]
+ public void GpuSamples_AverageSeparatelyFromFrames()
+ {
+ var stats = new FrameTimeStats(capacity: 4);
+ stats.Record(0.016, 0.002, 1, 0.0001);
+ Assert.Equal(0, stats.Summarize().GpuSamples);
+ Assert.Equal(0.0, stats.Summarize().AvgGpuMs);
+
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ var s = stats.Summarize();
+ Assert.Equal(2, s.GpuSamples);
+ Assert.Equal(2.0, s.AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void GpuSamples_RollOverTheWindow()
+ {
+ var stats = new FrameTimeStats(capacity: 2);
+ stats.RecordGpu(0.010);
+ stats.RecordGpu(0.001);
+ stats.RecordGpu(0.003);
+
+ Assert.Equal(2.0, stats.Summarize().AvgGpuMs, precision: 9);
+ }
+
+ [Fact]
+ public void CsvRow_IsInvariantCulture_MsWithThreeDecimals_GpuBlankWhenUnknown()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // comma decimals must not leak in
+ try
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("12.500,16.667,2.000,1,0.100,1.250", PerfCsv.Row(12.5, 0.0166667, 0.002, 1, 0.0001, 0.00125));
+ Assert.Equal("0.000,16.667,2.000,0,0.000,", PerfCsv.Row(0, 0.0166667, 0.002, 0, 0, null));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
new file mode 100644
index 0000000..fd64778
--- /dev/null
+++ b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using Godosa.Core.Diagnostics;
+using Xunit;
+
+namespace Godosa.Core.Tests.Diagnostics;
+
+public class PerfCsvTests
+{
+ [Fact]
+ public void Header_AppendsCallerColumnsInOrder()
+ {
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header());
+ Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms,draw_calls,triangles",
+ PerfCsv.Header("draw_calls", "triangles"));
+ }
+
+ [Fact]
+ public void Row_AppendsCallerValuesInOrder()
+ {
+ Assert.Equal("1.500,16.000,4.000,2,1.000,,12,2.500,",
+ PerfCsv.Row(1.5, 0.016, 0.004, 2, 0.001, null, "12", PerfCsv.Ms(0.0025), PerfCsv.Ms(null)));
+ }
+
+ [Fact]
+ public void Ms_IsInvariant_ThreeDecimals()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ try
+ {
+ Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
+ Assert.Equal("12.500", PerfCsv.Ms(0.0125));
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
new file mode 100644
index 0000000..76a07ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit" />
+ <PackageReference Include="xunit.runner.visualstudio" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
new file mode 100644
index 0000000..9c0c9dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs
@@ -0,0 +1,56 @@
+using System.Numerics;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class GamepadScriptTests
+{
+ [Theory]
+ [InlineData("PadA", PadButtons.A)]
+ [InlineData("PadB", PadButtons.B)]
+ [InlineData("PadX", PadButtons.X)]
+ [InlineData("PadY", PadButtons.Y)]
+ [InlineData("PadBack", PadButtons.Back)]
+ [InlineData("PadStart", PadButtons.Start)]
+ [InlineData("PadUp", PadButtons.DpadUp)]
+ [InlineData("PadDown", PadButtons.DpadDown)]
+ [InlineData("PadLeft", PadButtons.DpadLeft)]
+ [InlineData("PadRight", PadButtons.DpadRight)]
+ [InlineData("PadLB", PadButtons.LeftShoulder)]
+ [InlineData("PadRB", PadButtons.RightShoulder)]
+ public void Button_DownHolds_UpReleases(string name, PadButtons button)
+ {
+ var down = GamepadScript.Apply(default, name, true)!.Value;
+ Assert.Equal(button, down.Buttons);
+ Assert.True(down.Held(button));
+ Assert.Equal(PadButtons.None, GamepadScript.Apply(down, name, false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Button_LeavesOtherButtonsAndSticksAlone()
+ {
+ var start = new GamepadState(new Vector2(0.5f, 0f), Vector2.Zero, 0f, 0f, PadButtons.B);
+ var next = GamepadScript.Apply(start, "PadA", true)!.Value;
+ Assert.Equal(PadButtons.A | PadButtons.B, next.Buttons);
+ Assert.Equal(new Vector2(0.5f, 0f), next.LeftStick);
+ Assert.Equal(PadButtons.A, GamepadScript.Apply(next, "PadB", false)!.Value.Buttons);
+ }
+
+ [Fact]
+ public void Triggers_GoFullOnAndOff()
+ {
+ var lt = GamepadScript.Apply(default, "PadLT", true)!.Value;
+ Assert.Equal(1f, lt.LeftTrigger);
+ Assert.Equal(0f, lt.RightTrigger);
+ Assert.Equal(0f, GamepadScript.Apply(lt, "PadLT", false)!.Value.LeftTrigger);
+ Assert.Equal(1f, GamepadScript.Apply(default, "PadRT", true)!.Value.RightTrigger);
+ }
+
+ [Fact]
+ public void Names_AreCaseInsensitive_UnknownIsNull()
+ {
+ Assert.True(GamepadScript.Apply(default, "padstart", true)!.Value.Held(PadButtons.Start));
+ Assert.Null(GamepadScript.Apply(default, "PadZ", true));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/InputScriptTests.cs b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
new file mode 100644
index 0000000..d208240
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/InputScriptTests.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class InputScriptTests
+{
+ [Fact]
+ public void Parse_AbsoluteRelativeHoldPressClick_SortedByTime()
+ {
+ var script = InputScript.Parse("""
+ # walk to the Elder
+ 1.0 hold W 2.5
+ +0.5 press E
+ 5 click Accept
+ 4.0 down Space
+ 4.2 up Space
+ """);
+
+ Assert.Equal(new[]
+ {
+ new ScriptEvent(1.0, ScriptAction.Down, "W"),
+ new ScriptEvent(1.5, ScriptAction.Down, "E"),
+ new ScriptEvent(1.5 + InputScript.PressSeconds, ScriptAction.Up, "E"),
+ new ScriptEvent(3.5, ScriptAction.Up, "W"),
+ new ScriptEvent(4.0, ScriptAction.Down, "Space"),
+ new ScriptEvent(4.2, ScriptAction.Up, "Space"),
+ new ScriptEvent(5.0, ScriptAction.Click, "Accept"),
+ }, script.Events);
+ }
+
+ [Fact]
+ public void Type_KeepsTheWholeText() =>
+ Assert.Equal(new ScriptEvent(1.0, ScriptAction.Type, "Ada Lovelace"), InputScript.Parse("1 type Ada Lovelace").Events.Single());
+
+ [Fact]
+ public void Click_KeepsTheWholeLabel() =>
+ Assert.Equal("Take all", InputScript.Parse("2 click Take all").Events.Single().Arg);
+
+ [Theory]
+ [InlineData("1.0 jump W")]
+ [InlineData("abc down W")]
+ [InlineData("1.0 down")]
+ [InlineData("1.0 hold W")]
+ [InlineData("+0.5 down W")]
+ public void Parse_BadLine_ReportsItsLineNumber(string bad)
+ {
+ var ex = Assert.Throws<FormatException>(() => InputScript.Parse("# ok\n" + bad));
+ Assert.Contains("line 2", ex.Message);
+ }
+
+ [Fact]
+ public void Due_ReturnsEachEventOnceWhenItsTimeComes()
+ {
+ var script = InputScript.Parse("1 down W\n2 up W\n2 press E");
+ var got = new List<ScriptEvent>();
+
+ script.Due(0.99, got);
+ Assert.Empty(got);
+ script.Due(1.0, got);
+ Assert.Single(got);
+ script.Due(2.0, got);
+ Assert.Equal(3, got.Count);
+ script.Due(10.0, got);
+ Assert.Equal(4, got.Count);
+ script.Due(20.0, got);
+ Assert.Equal(4, got.Count);
+ Assert.True(script.Finished);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Input/MenuCursorTests.cs b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
new file mode 100644
index 0000000..79a1184
--- /dev/null
+++ b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs
@@ -0,0 +1,42 @@
+using Godosa.Core.Input;
+using Xunit;
+
+namespace Godosa.Core.Tests.Input;
+
+public class MenuCursorTests
+{
+ [Fact]
+ public void Move_WrapsAround()
+ {
+ var c = new MenuCursor();
+ c.Clamp(3);
+
+ c.Move(-1, 3);
+ Assert.Equal(2, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(0, c.Index);
+ c.Move(+1, 3);
+ Assert.Equal(1, c.Index);
+ }
+
+ [Fact]
+ public void Clamp_KeepsIndexInsideAShrinkingList_AndEmptyMeansNone()
+ {
+ var c = new MenuCursor();
+ c.Clamp(5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+ c.Move(+1, 5);
+
+ c.Clamp(2);
+ Assert.Equal(1, c.Index);
+
+ c.Clamp(0);
+ Assert.Equal(-1, c.Index);
+ c.Move(+1, 0);
+ Assert.Equal(-1, c.Index);
+
+ c.Clamp(4);
+ Assert.Equal(0, c.Index);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
new file mode 100644
index 0000000..31295ab
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs
@@ -0,0 +1,116 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RouteLegsTests
+{
+ // 5×5 grid, rows top to bottom; '#' blocked, 's' soft (sealed by the game's scripts, opened later).
+ private static Func<long, long, bool> Grid(params string[] rows) => (x, y) => rows[y][(int)x] is '#' or 's';
+ private static Func<long, long, bool> Soft(params string[] rows) => (x, y) => rows[y][(int)x] == 's';
+
+ [Fact]
+ public void Path_GoesRoundAWall_StartExcluded()
+ {
+ var blocked = Grid(
+ ".#...",
+ ".#...",
+ ".#...",
+ ".....",
+ ".....");
+ // From 0,0 the only way past the wall is under it: down the left column to row 3, then up-right diagonally.
+ var path = RouteLegs.Path((0, 0), (2, 0), 5, 5, blocked);
+ Assert.NotNull(path);
+ Assert.Equal((2, 0), path![^1]);
+ Assert.DoesNotContain((0L, 0L), path);
+ Assert.Equal(6, path.Count); // 0,1 0,2 1,3 2,2 2,1 2,0
+ Assert.All(path, c => Assert.False(blocked(c.X, c.Y)));
+ }
+
+ [Fact]
+ public void Path_NoWay_Null()
+ {
+ var blocked = Grid(
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..",
+ "..#..");
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, blocked));
+ }
+
+ // The game's router takes a leg only within 2 cells (Chebyshev): from each stop the farthest accepted path cell.
+ [Fact]
+ public void Legs_FarthestAcceptedStopEachTime()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0) };
+ var legs = RouteLegs.Legs((0, 0), path, (a, b) => Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)) <= 2);
+ Assert.Equal([(2L, 0L), (4L, 0L), (6L, 0L), (7L, 0L)], legs);
+ }
+
+ [Fact]
+ public void Legs_RouterAcceptsNothing_NullWithTheStuckStop()
+ {
+ var path = new List<(long X, long Y)> { (1, 0), (2, 0) };
+ Assert.Null(RouteLegs.Legs((0, 0), path, (a, b) => b == (1, 0), out var stuck));
+ Assert.Equal((1, 0), stuck);
+ }
+
+ // Closed off only by a sealed (soft) cell: the seals a way would cross once open, start to goal.
+ [Fact]
+ public void Seals_ListedInOrder()
+ {
+ string[] rows =
+ [
+ "..s..",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, Grid(rows)));
+ Assert.Equal([(2L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(rows), Soft(rows)));
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 4), 5, 5, Grid(rows), Soft(rows))); // walled, not sealed
+ }
+
+ [Fact]
+ public void Seals_CappedAndNoneWhenOpenAlready()
+ {
+ string[] twice =
+ [
+ ".s.s.",
+ "#####",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Equal([(1L, 0L), (3L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice)));
+ Assert.Equal([(1L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice), max: 1));
+ // Open the long way round (row 2): a seal on the short way is no reason to wait.
+ string[] detour =
+ [
+ "..s..",
+ "#.#.#",
+ ".....",
+ ".....",
+ ".....",
+ ];
+ Assert.Empty(RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(detour), Soft(detour)));
+ }
+
+ // Hops: walk to a portal, click it, arrive at its exit; BFS over portal exits.
+ [Fact]
+ public void Hops_ThroughTwoPortals()
+ {
+ // Three rooms on a line, x 0–9 / 10–19 / 20–29 (walking stays inside a room); portal A in room 1 at 5 → 12, B in
+ // room 2 at 18 → 25; a dead-end portal C in room 1 at 2 → 40.
+ static int Room(long x) => (int)(x / 10);
+ bool Reaches(long from, long to) => Room(from) == Room(to);
+ var portals = new[] { ("A", 5L, 12L), ("B", 18L, 25L), ("C", 2L, 40L) };
+ var hops = RouteLegs.Hops(1L, 27L, portals, p => p.Item2, p => p.Item3, Reaches);
+ Assert.NotNull(hops);
+ Assert.Equal(["A", "B"], hops!.Select(p => p.Item1));
+ Assert.Null(RouteLegs.Hops(1L, 35L, portals, p => p.Item2, p => p.Item3, Reaches));
+ Assert.Empty(RouteLegs.Hops(1L, 7L, portals, p => p.Item2, p => p.Item3, Reaches)!);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/RunsTests.cs b/src/Godosa.Core.Tests/Runs/RunsTests.cs
new file mode 100644
index 0000000..1b6d6d4
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/RunsTests.cs
@@ -0,0 +1,149 @@
+using Godosa.Core.Runs;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs;
+
+public class RunsTests
+{
+ private static RunFlags Parse(params string[] args)
+ {
+ var flags = new RunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags), args[i]);
+ return flags;
+ }
+
+ [Fact]
+ public void Flags_DeterministicWithAndWithoutSeed()
+ {
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 7), Parse("--deterministic", "7"));
+ Assert.Equal(new RunFlags(Deterministic: true), Parse("--deterministic"));
+ Assert.Equal(1UL, Parse("--deterministic").SeedOr(1));
+ Assert.Equal(7UL, Parse("--deterministic", "7").SeedOr(1));
+ }
+
+ [Fact]
+ public void Flags_SeedNotTakenWhenNextIsAnOption()
+ {
+ string[] args = ["--deterministic", "--mute"];
+ var flags = new RunFlags();
+ var i = 0;
+ Assert.True(RunFlags.TryTake(args, ref i, ref flags));
+ Assert.Equal(0, i);
+ i = 1;
+ Assert.False(RunFlags.TryTake(args, ref i, ref flags));
+ }
+
+ [Fact]
+ public void Flags_DigestEveryAndHeadless()
+ {
+ Assert.Equal(new RunFlags(Digest: true), Parse("--digest"));
+ Assert.Equal(new RunFlags(Digest: true, DigestEvery: 60), Parse("--digest", "every=60"));
+ Assert.Equal(new RunFlags(Deterministic: true, Headless: true), Parse("--headless"));
+ Assert.Equal(new RunFlags(Deterministic: true, Seed: 3, Headless: true), Parse("--headless", "--deterministic", "3"));
+ }
+
+ [Fact]
+ public void Flags_BadDigestEvery_Throws() =>
+ Assert.Throws<ArgumentException>(() => Parse("--digest", "every=0"));
+
+ [Fact]
+ public void Clock_DeterministicFixedStep()
+ {
+ var clock = new FrameClock(true);
+ Assert.Equal(1.0 / 60, clock.Next(0.5));
+ Assert.Equal(1.0 / 60, clock.Next(0.001));
+ Assert.Equal(2, clock.Frame);
+ Assert.Equal(2.0 / 60, clock.Seconds, 12);
+ }
+
+ [Fact]
+ public void Clock_WallClamped()
+ {
+ var clock = new FrameClock(false);
+ Assert.Equal(0.1, clock.Next(0.5));
+ Assert.Equal(0.02, clock.Next(0.02));
+ Assert.Equal(0, clock.Next(-1));
+ Assert.Equal(3, clock.Frame);
+ }
+
+ [Fact]
+ public void Clock_Held_NoTimeNoFrame()
+ {
+ var clock = new FrameClock(true) { Held = true };
+ Assert.Equal(0, clock.Next(1.0 / 60));
+ Assert.Equal(0, clock.Frame);
+ Assert.Equal(0, clock.Seconds);
+ clock.Held = false;
+ clock.Next(0);
+ Assert.Equal(1, clock.Frame);
+ }
+
+ // Published FNV-1a 64 vectors (isthe.com/chongo/tech/comp/fnv).
+ [Theory]
+ [InlineData("", 0xcbf29ce484222325UL)]
+ [InlineData("a", 0xaf63dc4c8601ec8cUL)]
+ [InlineData("foobar", 0x85944171f73967e8UL)]
+ public void Digest_FnvVectors(string text, ulong expected)
+ {
+ var d = new StateDigest();
+ d.Add(text);
+ Assert.Equal(expected, d.Value);
+ Assert.Equal(expected, StateDigest.Of(System.Text.Encoding.UTF8.GetBytes(text)));
+ }
+
+ // Hand-checked: FNV-1a 64 of the little-endian bytes 01 00 00 00 and of 1.5 as an IEEE double.
+ [Fact]
+ public void Digest_NumbersLittleEndian()
+ {
+ var u = new StateDigest();
+ u.Add(1u);
+ Assert.Equal(0xad2aca7747985764UL, u.Value);
+ var d = new StateDigest();
+ d.Add(1.5);
+ Assert.Equal(0xaa95e93229a27c80UL, d.Value);
+ Assert.Equal("aa95e93229a27c80", d.Hex);
+ }
+
+ [Fact]
+ public void Line_Format() =>
+ Assert.Equal("digest 120 3.5 000000000000beef", DigestLine.Format(120, 3.5, 0xbeef));
+
+ [Fact]
+ public void Line_FormatRoundsGameTime() =>
+ Assert.Equal("digest 1 0.017 0000000000000001", DigestLine.Format(1, 1.0 / 60, 1));
+
+ [Fact]
+ public void Line_Due()
+ {
+ Assert.True(DigestLine.Due(60, 60));
+ Assert.False(DigestLine.Due(59, 60));
+ Assert.False(DigestLine.Due(0, 60));
+ Assert.False(DigestLine.Due(60, 0));
+ }
+
+ // SplitMix64 seeded 0: outputs e220a8397b1dcdaf, 6e789e6aa1b965f4 (published reference sequence).
+ [Fact]
+ public void Seeds_SplitMixReference()
+ {
+ Assert.Equal(0xe220a8397b1dcdafUL, SeedStreams.Of(0, 0));
+ Assert.Equal(0x6e789e6aa1b965f4UL, SeedStreams.Of(0, 1));
+ Assert.Equal(0x63cbe1e459320dd7UL, SeedStreams.Of(7, 0));
+ }
+
+ [Fact]
+ public void Loop_StopsWhenFrameReturnsFalse()
+ {
+ var seen = new List<long>();
+ var (frames, capped) = HeadlessLoop.Run(n => { seen.Add(n); return n < 3; }, null);
+ Assert.Equal([1L, 2, 3], seen);
+ Assert.Equal(3, frames);
+ Assert.False(capped);
+ }
+
+ [Fact]
+ public void Loop_FramesLimit() => Assert.Equal((5L, false), HeadlessLoop.Run(_ => true, 5));
+
+ [Fact]
+ public void Loop_Cap() => Assert.Equal((10L, true), HeadlessLoop.Run(_ => true, null, cap: 10));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
new file mode 100644
index 0000000..1b53910
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs
@@ -0,0 +1,47 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from
+/// the next frame (its frame 0), the action is not polled until it is sent.</summary>
+public sealed class ActionInputTests
+{
+ [Fact]
+ public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain()
+ {
+ var g = new FakeGame();
+ var polls = new List<int>();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])),
+ n =>
+ {
+ polls.Add(n);
+ return n == 1 ? StepStatus.Input(follow) : StepStatus.Done;
+ });
+ var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g);
+
+ Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output));
+ Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key));
+ var a = g.Sent[0].Frame;
+ Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame
+ Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2
+ Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent
+ Assert.Contains("done", output);
+ }
+
+ [Fact]
+ public void PollInput_HalfSent_ReleasedOnFailure()
+ {
+ var g = new FakeGame();
+ var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]);
+ g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow));
+ var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 });
+
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
new file mode 100644
index 0000000..a84cf94
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs
@@ -0,0 +1,185 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class CheckpointCacheTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g,
+ o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game,
+ // the window froze), nothing cached; a later run resumes from nothing.
+ [Fact]
+ public void NoSnapshots_CheckpointsNeitherDigestNorCache()
+ {
+ var g = new FakeGame();
+ var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false });
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Equal(0, g.Digests);
+ Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source));
+ Assert.Contains("checkpoint two f=4", output);
+ Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any());
+ var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames));
+ }
+
+ [Fact]
+ public void SecondRun_ResumesAtTheLastCachedCheckpoint()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ var (r1, _, _) = Run(Script, first);
+ Assert.Equal(6, r1.Report!.Frames);
+ Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source));
+
+ var second = new FakeGame();
+ second.OnAdvance = f => second.State += 1;
+ var (r2, res, output) = Run(Script, second);
+ Assert.Equal(RunOutcome.Passed, res.Outcome);
+ Assert.Contains("resumed at two (cache) f=4", output);
+ Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key));
+ Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6
+ Assert.Equal(1, r2.Report!.CacheHits);
+ }
+
+ // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame
+ // passes, and the run ends as an immediate restore would.
+ [Fact]
+ public void DeferredRestore_PausesOneFrame_SameResult()
+ {
+ var first = new FakeGame();
+ first.OnAdvance = f => first.State += 1;
+ Run(Script, first);
+
+ var second = new FakeGame { RestoreDeferred = true };
+ second.OnAdvance = f => second.State += 1;
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { });
+ runner.BeforeFrame();
+ Assert.True(runner.Restoring);
+ Assert.Empty(second.Sent);
+
+ var third = new FakeGame { RestoreDeferred = true };
+ third.OnAdvance = f => third.State += 1;
+ var (r3, res, _) = Run(Script, third);
+ Assert.False(r3.Restoring);
+ Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing
+ }
+
+ [Fact]
+ public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot()
+ {
+ Run(Script, new FakeGame());
+ var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame());
+ Assert.Contains("resumed at one (cache) f=2", output);
+ var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output3);
+ }
+
+ [Fact]
+ public void CommentEdits_KeepTheCache()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame());
+ Assert.Contains("resumed at two (cache) f=4", output);
+ }
+
+ [Fact]
+ public void SeedContentOrVars_InvalidateEverything()
+ {
+ Run(Script, new FakeGame());
+ var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" });
+ Assert.DoesNotContain(output2, l => l.StartsWith("resumed"));
+ }
+
+ [Fact]
+ public void NoCache_ReplaysAndWarnsOnDigestMismatch()
+ {
+ Run(Script, new FakeGame());
+ var drift = new FakeGame { State = 100 };
+ var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache"));
+ // Cache replaced: a cached run now resumes from the new state.
+ var after = new FakeGame();
+ Run(Script, after);
+ Assert.Equal(100, after.State);
+ }
+
+ [Fact]
+ public void From_RequiresACachedCheckpoint_Until_StopsEarly()
+ {
+ var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" });
+ Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode));
+ Assert.Contains("checkpoint two not cached", out1);
+ var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" });
+ Assert.Contains("unknown checkpoint nope", out2);
+ var g = new FakeGame();
+ var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" });
+ Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames));
+ var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" });
+ Assert.Equal(RunOutcome.Passed, from.Outcome);
+ Assert.Contains("resumed at one (cache) f=2", outFrom);
+ }
+
+ // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels.
+ [Fact]
+ public void Resume_InsideIncludeAndCall_RestoresScopes()
+ {
+ var files = new Dictionary<string, string>
+ {
+ ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q",
+ };
+ const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z";
+ var (_, _, replay) = Run(main, new FakeGame(), files: files);
+ var (_, _, resumed) = Run(main, new FakeGame(), files: files);
+ Assert.Contains("resumed at deep (cache) f=0", resumed);
+ string[] Tail(List<string> o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))];
+ Assert.Equal(Tail(replay), Tail(resumed));
+ Assert.Contains("gil gil-x T", resumed);
+ Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored
+ }
+
+ [Fact]
+ public void Eviction_KeepsTotalUnderTheCap()
+ {
+ var cache = new CheckpointCache(_dir, capBytes: 300);
+ var state = new RunnerState(0, 0, [], []);
+ for (var i = 0; i < 5; i++)
+ {
+ cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100]));
+ File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc));
+ }
+ var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList();
+ Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300);
+ Assert.Contains("k4", left);
+ Assert.DoesNotContain("k0", left);
+ }
+
+ [Fact]
+ public void NoSnapshotSupport_NoCache()
+ {
+ var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false });
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0);
+ Assert.Equal(0, r.Report!.CacheMisses);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
new file mode 100644
index 0000000..d14ea6a
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ConditionTests
+{
+ private sealed class Ctx : IConditionContext
+ {
+ public Dictionary<string, RunValue> Values { get; } = new()
+ {
+ ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true),
+ ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false),
+ };
+
+ public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive";
+
+ public RunValue Probe(string name, IReadOnlyList<string> args) =>
+ Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"];
+
+ public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null };
+ }
+
+ private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx());
+
+ [Theory]
+ [InlineData("quest(1) >= accepted", true)]
+ [InlineData("quest(1) = completed", false)]
+ [InlineData("map = \"tarant\"", true)]
+ [InlineData("map != \"ashbury\"", true)]
+ [InlineData("dialog-open", true)]
+ [InlineData("alive", false)]
+ [InlineData("not alive", true)]
+ [InlineData("dialog-open and global(gold) > 499", true)]
+ [InlineData("alive or global(gold) < 100", false)]
+ [InlineData("not (alive or dialog-open)", false)]
+ [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or
+ [InlineData("global(gold) <= 500", true)]
+ public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text));
+
+ [Fact]
+ public void MismatchedTypes_Fault() =>
+ Assert.Equal("cannot compare tarant with 3", Assert.Throws<ScriptFault>(() => Test("map > 3")).Message);
+
+ [Theory]
+ [InlineData("quest(1) >=")]
+ [InlineData("(alive")]
+ [InlineData("alive alive")]
+ [InlineData("\"open")]
+ public void BadSyntax_Throws(string text) => Assert.Throws<FormatException>(() => ConditionParser.Parse(text));
+
+ [Fact]
+ public void ListsProbesAndBareNames()
+ {
+ var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open");
+ Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})"));
+ Assert.Equal(["accepted", "dialog-open"], c.Bare());
+ }
+
+ [Fact]
+ public void Describe_ListsProbeValues() =>
+ Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx()));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
new file mode 100644
index 0000000..8318148
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs
@@ -0,0 +1,100 @@
+using Godosa.Core.Runs.Scripts;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the
+/// frame it arrived on.</summary>
+public sealed class FakeGame : IRunGame, IInputRouter
+{
+ public long Frame { get; private set; }
+ public List<(long Frame, InputEvent Event)> Sent { get; } = [];
+ public Dictionary<string, Func<IReadOnlyList<string>, RunValue>> ProbeFns { get; } = new();
+ public Dictionary<string, IRunAction> ActionTable { get; } = new();
+ public Dictionary<string, (int X, int Y)> Objects { get; } = new();
+ public Dictionary<string, long> ConstantTable { get; } = new() { ["accepted"] = 2 };
+ public Action<long>? OnAdvance { get; set; }
+ public int State { get; set; }
+ public FakeRng? RngImpl { get; set; } = new();
+
+ public string Name => "fake";
+ public string ContentVersion { get; set; } = "c1";
+ public IInputRouter Input => this;
+ public IReadOnlyDictionary<string, IProbe> Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value));
+ public IReadOnlyDictionary<string, long> Constants => ConstantTable;
+ public IReadOnlyDictionary<string, ILocator> Locators => new Dictionary<string, ILocator> { ["obj"] = new ObjLocator(this) };
+ public IReadOnlyDictionary<string, IRunAction> Actions => ActionTable;
+ public IRunRng? Rng => RngImpl;
+ public bool SupportsSnapshot { get; set; } = true;
+ public bool RestoreDeferred { get; set; }
+
+ public Action<InputEvent>? OnSend { get; set; }
+ public bool RestoreThrows { get; set; }
+ public string? Refusal { get; set; }
+ public string? SnapshotRefusal() => Refusal;
+
+ public void Send(InputEvent e)
+ {
+ OnSend?.Invoke(e);
+ Sent.Add((Frame, e));
+ }
+
+ public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray();
+
+ public void SnapshotRestore(byte[] data)
+ {
+ if (RestoreThrows)
+ throw new IOException("restore broke");
+ State = BitConverter.ToInt32(data, 0);
+ Frame = BitConverter.ToInt64(data, 4);
+ }
+
+ public int Digests { get; private set; }
+ public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; }
+ public string Dump() => $"state={State} frame={Frame}";
+
+ public void Advance()
+ {
+ Frame++;
+ OnAdvance?.Invoke(Frame);
+ }
+
+ private sealed class FnProbe(Func<IReadOnlyList<string>, RunValue> fn) : IProbe
+ {
+ public int MinArgs => 0;
+ public int MaxArgs => 4;
+ public RunValue Eval(IReadOnlyList<string> args) => fn(args);
+ }
+
+ private sealed class ObjLocator(FakeGame g) : ILocator
+ {
+ public LocatorResult Locate(IReadOnlyList<string> args) =>
+ g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen");
+ }
+}
+
+public sealed class FakeRng : IRunRng
+{
+ public List<string> Calls { get; } = [];
+ public void Seed(long seed) => Calls.Add($"seed {seed}");
+ public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}");
+}
+
+/// <summary>An action: optional input plan, then Poll() answers from a function of the frames polled so far.</summary>
+public sealed class FakeAction(VerbKind kind, Func<IReadOnlyList<string>, ActionStart> begin, Func<int, StepStatus>? poll = null,
+ int min = 0, int max = 4) : IRunAction
+{
+ private int _polls;
+ public VerbKind Kind => kind;
+ public int MinArgs => min;
+ public int MaxArgs => max;
+ public List<IReadOnlyList<string>> Begun { get; } = [];
+
+ public ActionStart Begin(IReadOnlyList<string> args)
+ {
+ Begun.Add(args);
+ _polls = 0;
+ return begin(args);
+ }
+
+ public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
new file mode 100644
index 0000000..1b7257b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs
@@ -0,0 +1,157 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+/// <summary>Final-review findings (lib core): each test failed before its fix.</summary>
+public sealed class ReviewFixTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C";
+
+ [Fact]
+ public void NoArgVerb_AndEmptySet_Run()
+ {
+ var g = new FakeGame();
+ g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0);
+ var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("[]", output);
+ }
+
+ // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first.
+ [Fact]
+ public void QuotedArgument_ValueWithQuotes_StaysOneToken()
+ {
+ var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame());
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains("R: \"Hi,\" he said", output);
+ }
+
+ [Fact]
+ public void SubstitutedValues_AreNotRescanned()
+ {
+ var values = new Dictionary<string, string> { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" };
+ Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null));
+ Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null));
+ }
+
+ [Theory]
+ [InlineData("log Done:", "log", "Done:")]
+ [InlineData("type name:", "type", "name:")]
+ public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args)
+ {
+ var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}");
+ Assert.Empty(f.Errors);
+ var v = Assert.IsType<VerbNode>(f.Body.Single());
+ Assert.Equal((verb, args), (v.Verb, v.Args));
+ }
+
+ [Fact]
+ public void ThrowingInputRouter_FailsTheRun()
+ {
+ var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") };
+ var (r, output) = Run("format 1\nmode player\nkey R", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down");
+ }
+
+ [Fact]
+ public void CorruptCacheEntry_IsAMiss()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ foreach (var f in Directory.GetFiles(_dir, "*.snap"))
+ File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']);
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.DoesNotContain(output, l => l.StartsWith("resumed"));
+ }
+
+ // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed.
+ [Fact]
+ public void RefusedSnapshot_CheckpointNotCached()
+ {
+ var g = new FakeGame { Refusal = "dialog open" };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = Script,
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add);
+ Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome);
+ Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings);
+ Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0);
+ }
+
+ [Fact]
+ public void FailingRestore_FailsTheRun()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir });
+ var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir });
+ Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode));
+ Assert.Contains(output, l => l.Contains("IOException: restore broke"));
+ }
+
+ [Fact]
+ public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing()
+ {
+ Directory.CreateDirectory(_dir);
+ var file = Path.Combine(_dir, "file");
+ File.WriteAllText(file, "x");
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string>
+ {
+ ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no",
+ }), new Dictionary<string, string>()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add);
+ var r = runner.Run(1000);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:"));
+ Assert.Contains(output, l => l.StartsWith("warning: report not written:"));
+ }
+
+ [Fact]
+ public void Until_UnknownName_Invalid()
+ {
+ var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" });
+ Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode));
+ Assert.Contains("unknown label or checkpoint nowhere", output);
+ }
+
+ [Fact]
+ public void Until_ResumesOnlyBeforeIt()
+ {
+ Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two
+ var output = new List<string>();
+ var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = Script }),
+ new Dictionary<string, string>()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000);
+ Assert.Contains("resumed at one (cache) f=2", output);
+ Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames));
+ }
+
+ [Fact]
+ public void ScriptSeedHeader_SeedsTheGame_CommandLineWins()
+ {
+ var g = new FakeGame();
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g, new ScriptRunOptions(), output.Add);
+ runner.Run(1000);
+ Assert.Equal(["seed 7"], g.RngImpl!.Calls);
+ Assert.Equal(7UL, runner.Report!.Seed);
+ var g2 = new FakeGame();
+ var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }),
+ new Dictionary<string, string>()), g2, new ScriptRunOptions { Seed = 9 }, output.Add);
+ runner2.Run(1000);
+ Assert.Equal(["seed 9"], g2.RngImpl!.Calls);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
new file mode 100644
index 0000000..00ee4b0
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public sealed class RunReportTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir))
+ Directory.Delete(_dir, recursive: true);
+ }
+
+ private static (ScriptRunner Runner, RunResult Result, List<string> Output) Run(string main, FakeGame g, ScriptRunOptions o, Dictionary<string, string>? files = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var output = new List<string>();
+ var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary<string, string>()), g, o, output.Add);
+ return (runner, runner.Run(10_000), output);
+ }
+
+ [Fact]
+ public void Failure_ReportHoldsChainStepReasonRecentDumpVars()
+ {
+ var g = new FakeGame { State = 7 };
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir },
+ new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" });
+ var f = runner.Report!.Failure!;
+ Assert.Equal("act2.steps:4", f.At);
+ Assert.Equal(["main.steps:5"], f.Chain);
+ Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label));
+ Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason);
+ Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent);
+ Assert.Equal("state=7 frame=4", f.Dump);
+ Assert.Equal("Gilbert", f.Vars["who"]);
+ Assert.True(File.Exists(Path.Combine(_dir, "report.txt")));
+ Assert.True(File.Exists(Path.Combine(_dir, "fail.snap")));
+ var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json")));
+ Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString());
+ var text = File.ReadAllText(Path.Combine(_dir, "report.txt"));
+ Assert.Contains("main.steps:5 → act2.steps:4", text);
+ Assert.Contains("state=7 frame=4", text);
+ }
+
+ [Fact]
+ public void Recent_KeepsLast20()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["no"] = _ => RunValue.Of(false);
+ var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}"));
+ var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions());
+ var recent = runner.Report!.Failure!.Recent;
+ Assert.Equal(20, recent.Count);
+ Assert.StartsWith("main.steps:8 key K6", recent[0]);
+ }
+
+ [Fact]
+ public void Summary_StepsFramesCheckpointsDigests()
+ {
+ var g = new FakeGame { State = 3 };
+ var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 });
+ var rep = runner.Report!;
+ Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed));
+ // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011.
+ Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints);
+ Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output);
+ Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step
+ }
+
+ [Fact]
+ public void NonDeterministic_NoDigestLines_FlagInReport()
+ {
+ var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false });
+ Assert.DoesNotContain(output, l => l.StartsWith("digest "));
+ Assert.False(runner.Report!.Deterministic);
+ Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report));
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
new file mode 100644
index 0000000..f28f330
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs
@@ -0,0 +1,103 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptLoaderTests
+{
+ private static ScriptProgram Load(Dictionary<string, string> files, Dictionary<string, string>? vars = null) =>
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []);
+
+ private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString()));
+
+ private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch
+ {
+ Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}",
+ Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")",
+ _ => i.Op.ToString().ToLowerInvariant(),
+ }));
+
+ [Fact]
+ public void Flattens_IncludesDefsBlocks()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done",
+ ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps",
+ ["common.steps"] = "format 1\ntry:\n key Q",
+ });
+ Assert.Equal("", Errors(p));
+ Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p));
+ var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin);
+ Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op);
+ var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q");
+ Assert.Equal("common.steps:3", q.At.ToString());
+ Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString()));
+ var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars);
+ Assert.Equal([new KeyValuePair<string, string>("h", "8")], bind.Bindings);
+ }
+
+ [Fact]
+ public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}",
+ ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd",
+ }, new() { ["res"] = "hd" });
+ Assert.Equal("", Errors(p));
+ Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"]));
+ Assert.Contains("res", p.Locked);
+ }
+
+ [Theory]
+ [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")]
+ [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")]
+ [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")]
+ [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")]
+ [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")]
+ [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")]
+ [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")]
+ [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")]
+ [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")]
+ public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main })));
+
+ [Fact]
+ public void IncludeCycle_NamesThePath() =>
+ Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\ninclude b.steps",
+ ["b.steps"] = "format 1\ninclude a.steps",
+ })));
+
+ [Fact]
+ public void IncludedFile_ModeOrSetup_Rejected()
+ {
+ var p = Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\ninclude a.steps",
+ ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x",
+ });
+ Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p));
+ Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p));
+ }
+
+ [Fact]
+ public void LockedVariable_SetIsAnError() =>
+ Assert.Contains("main.steps:3: variable res is set on the command line",
+ Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" })));
+
+ [Fact]
+ public void DefParamsAndEarlierSets_AreKnownVariables() =>
+ Assert.Equal("", Errors(Load(new()
+ {
+ ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)",
+ })));
+
+ [Fact]
+ public void IncludedFileKeepsItsOwnFormat() =>
+ Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" })
+ .Code.Single(i => i.Op == Op.Verb).Format);
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
new file mode 100644
index 0000000..352ff03
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs
@@ -0,0 +1,111 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptParserTests
+{
+ private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text);
+
+ private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString()));
+
+ [Fact]
+ public void Header_AndStatements()
+ {
+ var f = Parse("""
+ # comment
+ format 1
+ mode player
+ seed 7
+ vars ui/${res}.vars
+ click 10,20
+ wait dialog-open 2s
+ expect quest(1) >= accepted
+ set x = "a b"
+ include ch/one.steps
+ call rest(8, inn)
+ checkpoint done
+ """);
+ Assert.Equal("", Errors(f));
+ Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value));
+ Assert.Equal(["ui/${res}.vars"], f.VarFiles);
+ var v = Assert.IsType<VerbNode>(f.Body[0]);
+ Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line));
+ var w = Assert.IsType<WaitNode>(f.Body[1]);
+ Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout));
+ Assert.Equal("quest(1) >= accepted", Assert.IsType<ExpectNode>(f.Body[2]).Condition);
+ var s = Assert.IsType<SetNode>(f.Body[3]);
+ Assert.Equal(("x", "a b"), (s.Name, s.Value));
+ Assert.Equal("ch/one.steps", Assert.IsType<IncludeNode>(f.Body[4]).Path);
+ var c = Assert.IsType<CallNode>(f.Body[5]);
+ Assert.Equal("rest", c.Name);
+ Assert.Equal(["8", "inn"], c.Args);
+ Assert.Equal("done", Assert.IsType<CheckpointNode>(f.Body[6]).Name);
+ }
+
+ [Fact]
+ public void Blocks_NestByIndent()
+ {
+ var f = Parse("""
+ format 1
+ mode debug
+ setup:
+ cheat gold 5
+ step talk-virgil:
+ click 1,2
+ try:
+ key R
+ def rest(hours):
+ key R
+ wait-frames ${hours}
+ key Q
+ """);
+ Assert.Equal("", Errors(f));
+ var setup = Assert.IsType<BlockNode>(f.Body[0]);
+ Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count));
+ var step = Assert.IsType<BlockNode>(f.Body[1]);
+ Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count));
+ Assert.Equal(BlockKind.Try, Assert.IsType<BlockNode>(step.Body[1]).Kind);
+ var def = Assert.IsType<DefNode>(f.Body[2]);
+ Assert.Equal(("rest", 2), (def.Name, def.Body.Count));
+ Assert.Equal(["hours"], def.Params);
+ Assert.Equal("Q", Assert.IsType<VerbNode>(f.Body[3]).Args);
+ }
+
+ [Theory]
+ [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")]
+ [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")]
+ [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")]
+ [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")]
+ [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")]
+ [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")]
+ [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")]
+ [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")]
+ [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")]
+ [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")]
+ [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")]
+ [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")]
+ [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")]
+ public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text)));
+
+ // Review focus 5: CRLF, BOM and trailing spaces parse as LF.
+ [Fact]
+ public void CrlfBomTrailingSpaces_SameAsLf()
+ {
+ var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n");
+ var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n");
+ Assert.Equal("", Errors(crlf));
+ var a = Assert.IsType<BlockNode>(lf.Body[0]);
+ var b = Assert.IsType<BlockNode>(crlf.Body[0]);
+ Assert.Equal(a.Text, b.Text);
+ Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text);
+ Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At);
+ }
+
+ [Fact]
+ public void ReportsEveryError_NotOnlyTheFirst()
+ {
+ var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R");
+ Assert.Equal(3, f.Errors.Count);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
new file mode 100644
index 0000000..6f3be4e
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs
@@ -0,0 +1,54 @@
+using Godosa.Core.Runs;
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunFlagsTests
+{
+ private static ScriptRunFlags Parse(params string[] args)
+ {
+ var f = new ScriptRunFlags();
+ for (var i = 0; i < args.Length; i++)
+ Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]);
+ return f;
+ }
+
+ [Fact]
+ public void ParsesEveryOption()
+ {
+ var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace",
+ "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a");
+ Assert.True(f.NoSnapshots);
+ Assert.False(f.ToOptions(new RunFlags(), null).Snapshots);
+ Assert.Equal("main.steps", f.Script);
+ Assert.Equal(new Dictionary<string, string> { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars);
+ Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir));
+ }
+
+ [Fact]
+ public void NotOurs_ReturnsFalse_IndexUnchanged()
+ {
+ var i = 0;
+ Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags()));
+ Assert.Equal(0, i);
+ }
+
+ [Theory]
+ [InlineData("--var", "novalue")]
+ [InlineData("--output", "loud")]
+ [InlineData("--step-cap", "x")]
+ public void BadValues_Throw(string option, string value)
+ {
+ var i = 0;
+ Assert.Throws<ArgumentException>(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags()));
+ }
+
+ [Fact]
+ public void ToOptions_DeterministicSeedOutput()
+ {
+ var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache");
+ Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir));
+ Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
new file mode 100644
index 0000000..9129456
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs
@@ -0,0 +1,85 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerStructureTests
+{
+ [Fact]
+ public void Output_DefaultLabels_StepsLevelShowsEveryStep()
+ {
+ var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame());
+ Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels);
+ var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame());
+ Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps);
+ }
+
+ [Fact]
+ public void Output_ScopedToIncludedFile()
+ {
+ var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(),
+ files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" });
+ Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent
+ }
+
+ [Fact]
+ public void Output_FloorCannotBeLowered()
+ {
+ var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps });
+ Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output);
+ }
+
+ [Fact]
+ public void DefCall_BindsParams_ParamUnknownOutside()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Equal(["gold", "in"], a.Begun[0]);
+ var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g);
+ Assert.Equal(RunOutcome.Invalid, bad.Outcome);
+ Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}");
+ }
+
+ [Fact]
+ public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false });
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)");
+ Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A");
+ Assert.Contains(g.Sent, s => s.Event.Key == "B");
+ }
+
+ // Review focus 1: a fault while a button is down releases it.
+ [Fact]
+ public void Fault_ReleasesHeldButtonAndKey()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["never"] = _ => RunValue.Of(false);
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player,
+ _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)),
+ new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])),
+ polls => StepStatus.Running, 1, 1);
+ g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); };
+ var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9));
+ Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift"));
+ }
+
+ [Fact]
+ public void Setup_RunsFirstAtFrameZero()
+ {
+ var g = new FakeGame();
+ var order = new List<string>();
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3);
+ Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g);
+ Assert.Equal(["cheat@0"], order);
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
new file mode 100644
index 0000000..0738f7b
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs
@@ -0,0 +1,224 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptRunnerTests
+{
+ internal static (RunResult Result, List<string> Output) Run(string main, FakeGame game, ScriptRunOptions? options = null,
+ Dictionary<string, string>? files = null, Dictionary<string, string>? vars = null)
+ {
+ var all = new Dictionary<string, string>(files ?? []) { ["main.steps"] = main };
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []);
+ var output = new List<string>();
+ var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add);
+ return (runner.Run(cap: 10_000), output);
+ }
+
+ [Fact]
+ public void Click_DownAtStepFrameUpNextFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g);
+ Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode));
+ // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up.
+ Assert.Equal(
+ [
+ (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)),
+ (1L, new InputEvent(InputKind.Up, 100, 200)),
+ (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")),
+ ], g.Sent);
+ Assert.Equal(4, r.Frames);
+ }
+
+ // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw.
+ [Fact]
+ public void StepsDone_CountsCompletedSteps()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ Assert.Equal(0, runner.StepsDone);
+ runner.Run(cap: 10_000);
+ Assert.Equal(2, runner.StepsDone);
+ }
+
+ // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then
+ // would aim on the draw from before): the frame counts, the step is polled on the next one.
+ [Fact]
+ public void AfterFrame_NoPoll_CountsTheFrameOnly()
+ {
+ var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary<string, string>());
+ var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { });
+ runner.BeforeFrame();
+ runner.AfterFrame(poll: false);
+ Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone));
+ runner.BeforeFrame();
+ runner.AfterFrame();
+ Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone));
+ }
+
+ [Fact]
+ public void Locator_AimsTheClick_OffScreenFaults()
+ {
+ var g = new FakeGame();
+ g.Objects["Virgil"] = (640, 360);
+ var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g);
+ Assert.Equal(RunOutcome.Passed, ok.Outcome);
+ Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent);
+ var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame());
+ Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen");
+ }
+
+ [Fact]
+ public void Wait_HoldsUntilConditionOrTimesOut()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5
+
+ var g2 = new FakeGame();
+ g2.ProbeFns["ready"] = _ => RunValue.Of(false);
+ g2.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2);
+ Assert.Equal(RunOutcome.Failed, t.Outcome);
+ Assert.Equal(4, t.Frames);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)");
+ }
+
+ [Fact]
+ public void Wait_AlreadyTrue_TakesNoFrame()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["ready"] = _ => RunValue.Of(true);
+ var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g);
+ Assert.Equal(0L, g.Sent[0].Frame);
+ Assert.Equal(2, r.Frames);
+ }
+
+ [Fact]
+ public void Expect_FailsWithValues()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(1);
+ var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)");
+ }
+
+ [Fact]
+ public void Action_PlanThenPollUntilDone()
+ {
+ var g = new FakeGame();
+ var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])),
+ polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1);
+ g.ActionTable["talk"] = talk;
+ var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g);
+ Assert.Equal(RunOutcome.Passed, r.Outcome);
+ Assert.Equal(["@obj(x)"], talk.Begun.Single());
+ // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4.
+ Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame);
+ }
+
+ [Fact]
+ public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame()
+ {
+ var g = new FakeGame();
+ var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3);
+ g.ActionTable["cheat"] = a;
+ Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g);
+ Assert.Equal(["gold", "50"], a.Begun.Single());
+ }
+
+ [Fact]
+ public void Action_FaultStatusFailsTheRun()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g);
+ Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4");
+ }
+
+ // Review focus 2: a hook that never finishes hits the step cap.
+ [Fact]
+ public void StepCap_StopsAHangingAction()
+ {
+ var g = new FakeGame();
+ g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1);
+ var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 });
+ Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames));
+ Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames");
+ }
+
+ [Fact]
+ public void GameHookException_IsAFailureNotACrash()
+ {
+ var g = new FakeGame();
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1);
+ var (r, output) = Run("format 1\nmode player\ntalk x", g);
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom");
+ }
+
+ [Fact]
+ public void InvalidScript_Exit2_NoFrames()
+ {
+ var g = new FakeGame();
+ var (r, output) = Run("format 1\nmode player\ndance", g);
+ Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames));
+ Assert.Contains(output, l => l == "main.steps:3: unknown verb dance");
+ Assert.Equal(0, g.Frame);
+ }
+
+ [Fact]
+ public void Rng_CallsTheGameHook_NoFrame()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g);
+ Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames));
+ Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls);
+ }
+
+ [Fact]
+ public void WaitFrames_CountsFrames()
+ {
+ var g = new FakeGame();
+ var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g);
+ Assert.Equal(37, r.Frames);
+ }
+
+ [Theory]
+ [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")]
+ [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")]
+ [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")]
+ [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")]
+ [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")]
+ [InlineData("type \"hi there\"", "Text(hi there)@0")]
+ [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")]
+ public void InputPlans_Shapes(string line, string expected)
+ {
+ var g = new FakeGame();
+ Run($"format 1\nmode player\n{line}", g);
+ string Show((long Frame, InputEvent E) s) => s.E.Kind switch
+ {
+ InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}",
+ InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}",
+ InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}",
+ InputKind.Text => $"Text({s.E.Text})@{s.Frame}",
+ InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}",
+ _ => $"{s.E.Kind}@{s.Frame}",
+ };
+ Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show)));
+ }
+
+ [Fact]
+ public void BadPosition_Faults()
+ {
+ var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame());
+ Assert.Equal(RunOutcome.Failed, r.Outcome);
+ Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'");
+ }
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
new file mode 100644
index 0000000..98dcc73
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs
@@ -0,0 +1,63 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class ScriptValidatorTests
+{
+ private static FakeGame Game()
+ {
+ var g = new FakeGame();
+ g.ProbeFns["quest"] = _ => RunValue.Of(0);
+ g.ProbeFns["dialog-open"] = _ => RunValue.Of(false);
+ g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1);
+ g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20);
+ return g;
+ }
+
+ private static string Validate(string main, bool deterministic = true, FakeGame? game = null) =>
+ string.Join(" | ", ScriptValidator.Validate(
+ ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary<string, string> { ["main.steps"] = main }), new Dictionary<string, string>()),
+ game ?? Game(), deterministic).Select(e => e.ToString()));
+
+ [Fact]
+ public void ValidPlayerScript_NoErrors() =>
+ Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\""));
+
+ [Theory]
+ [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")]
+ [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")]
+ [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")]
+ [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")]
+ [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")]
+ [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")]
+ [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")]
+ [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")]
+ [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")]
+ public void Errors(string main, string error) => Assert.Contains(error, Validate(main));
+
+ [Fact]
+ public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R"));
+
+ [Fact]
+ public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false));
+
+ [Fact]
+ public void Rng_NeedsTheGameHook()
+ {
+ var g = Game();
+ g.RngImpl = null;
+ Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g));
+ }
+
+ [Fact]
+ public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected()
+ {
+ var g = Game();
+ g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart());
+ Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g));
+ }
+
+ [Fact]
+ public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()"));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
new file mode 100644
index 0000000..0f787dc
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs
@@ -0,0 +1,58 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class StepLexerTests
+{
+ [Theory]
+ [InlineData("click 10,20 # aim", "click 10,20 ")]
+ [InlineData("log \"a # b\" # c", "log \"a # b\" ")]
+ [InlineData("# only", "")]
+ [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")]
+ public void StripComment_OutsideQuotesOnly(string line, string expected) =>
+ Assert.Equal(expected, StepLexer.StripComment(line));
+
+ [Fact]
+ public void Tokens_SplitOnSpaceQuotedStaysWhole()
+ {
+ var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\"");
+ Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text));
+ Assert.Equal([false, false, false, true], t.Select(x => x.Quoted));
+ }
+
+ [Fact]
+ public void Tokens_EscapesInQuotes()
+ {
+ var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\"");
+ Assert.Equal("a \"b\" \\ c", t[1].Text);
+ }
+
+ [Fact]
+ public void Tokens_ParenthesesKeepSpacesInOneToken()
+ {
+ // locator args may contain spaces: @npc(Gilbert Bates)
+ var t = StepLexer.Tokens("talk @npc(Gilbert Bates)");
+ Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text));
+ }
+
+ [Fact]
+ public void Tokens_UnterminatedQuote_Throws() =>
+ Assert.Equal("unterminated quote", Assert.Throws<FormatException>(() => StepLexer.Tokens("log \"abc")).Message);
+
+ [Theory]
+ [InlineData("120", 120)]
+ [InlineData("2s", 120)]
+ [InlineData("0.5s", 30)]
+ [InlineData("0", 0)]
+ [InlineData("1.25s", 75)]
+ public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text));
+
+ [Theory]
+ [InlineData("0.01s")] // 0.6 frames: not whole
+ [InlineData("-5")]
+ [InlineData("2m")]
+ [InlineData("")]
+ [InlineData("1e3")]
+ public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text));
+}
diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
new file mode 100644
index 0000000..c6d3286
--- /dev/null
+++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs
@@ -0,0 +1,84 @@
+using Godosa.Core.Runs.Scripts;
+using Xunit;
+
+namespace Godosa.Core.Tests.Runs.Scripts;
+
+public class VarsTests
+{
+ private static readonly Dictionary<string, string> Values = new()
+ {
+ ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep",
+ };
+
+ private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n),
+ p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}"));
+
+ [Theory]
+ [InlineData("ui/${res}.vars", "ui/1920x1080.vars")]
+ [InlineData("click ${ui.rest.${hours}}", "click 400,300")]
+ [InlineData("${${a}}", "deep")]
+ [InlineData("gold ${probe:global(gold)}", "gold 1250")]
+ [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal
+ [InlineData("no vars", "no vars")]
+ public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text));
+
+ [Theory]
+ [InlineData("${missing}", "unresolved variable ${missing}")]
+ [InlineData("x ${a", "unclosed ${ in 'x ${a'")]
+ [InlineData("${}", "empty ${}")]
+ public void Expand_Errors_AreFaults(string text, string message) =>
+ Assert.Equal(message, Assert.Throws<ScriptFault>(() => Expand(text)).Message);
+
+ [Fact]
+ public void Names_InnermostPlainOnly() =>
+ Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order());
+
+ [Fact]
+ public void VarsFile_ParsesAndReportsBadLines()
+ {
+ var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n");
+ Assert.Equal("412,520", values["ui.answer1"]);
+ Assert.Equal("400,300", values["ui.rest.8"]);
+ Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString()));
+ }
+
+ [Fact]
+ public void Scopes_SetGoesToLocalOrRoot()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: false, OutputLevel.Labels, []); // include
+ s.Set("inc", "2"); // global: root
+ s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call
+ s.Set("loc", "3");
+ Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p")));
+ s.Pop();
+ Assert.Null(s.Get("loc"));
+ Assert.Null(s.Get("p"));
+ s.Pop();
+ Assert.Equal("2", s.Get("inc"));
+ }
+
+ [Fact]
+ public void Scopes_OutputLevelRestoredOnPop()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Push(local: false, s.Level, []);
+ s.Level = OutputLevel.Trace;
+ s.Pop();
+ Assert.Equal(OutputLevel.Labels, s.Level);
+ }
+
+ [Fact]
+ public void Scopes_SnapshotRestore_RoundTrips()
+ {
+ var s = new VarScopes(OutputLevel.Labels);
+ s.Set("g", "1");
+ s.Push(local: true, OutputLevel.Steps, [new("p", "x")]);
+ var copy = new VarScopes(OutputLevel.Quiet);
+ copy.Restore(s.Snapshot());
+ Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level));
+ s.Set("p", "changed");
+ Assert.Equal("x", copy.Get("p")); // deep copy
+ }
+}
diff --git a/src/Godosa.Core.Tests/SettingsFileTests.cs b/src/Godosa.Core.Tests/SettingsFileTests.cs
new file mode 100644
index 0000000..7308c2b
--- /dev/null
+++ b/src/Godosa.Core.Tests/SettingsFileTests.cs
@@ -0,0 +1,90 @@
+using Xunit;
+
+namespace Godosa.Core.Tests;
+
+public sealed class SettingsFileTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), "godosa-settings-" + Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
+ }
+
+ private readonly List<string> _reports = new();
+
+ private SettingsFile<string> Store(string path, Func<string, string>? parse = null) =>
+ new(path, parse ?? (text => "parsed:" + text), value => value, () => "defaults", _reports.Add);
+
+ [Fact]
+ public void Missing_GivesDefaults()
+ {
+ var file = Store(Path.Combine(_dir, "settings.json"), _ => throw new InvalidOperationException("parsed"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Empty(_reports); // a first run is not a problem
+ }
+
+ [Fact]
+ public void Save_CreatesFolder_ThenLoadParsesWhatWasWritten_NoTempLeft()
+ {
+ string path = Path.Combine(_dir, "sub", "settings.json");
+ var file = Store(path);
+ file.Save("{\"a\":1}");
+ Assert.Equal("{\"a\":1}", System.IO.File.ReadAllText(path));
+ Assert.Equal("parsed:{\"a\":1}", file.Load());
+ Assert.False(System.IO.File.Exists(path + ".tmp"));
+ }
+
+ [Fact]
+ public void ParseFailure_FallsBackToDefaults()
+ {
+ string path = Path.Combine(_dir, "settings.json");
+ Directory.CreateDirectory(_dir);
+ System.IO.File.WriteAllText(path, "\u0000junk");
+ var file = Store(path, _ => throw new FormatException("bad"));
+ Assert.Equal("defaults", file.Load());
+ Assert.Contains("load failed", Assert.Single(_reports));
+ }
+
+ [Fact]
+ public void SaveFailure_DoesNotThrow()
+ {
+ Directory.CreateDirectory(Path.Combine(_dir, "settings.json")); // a folder where the file should go
+ Store(Path.Combine(_dir, "settings.json")).Save("x");
+ Assert.Contains("save failed", Assert.Single(_reports));
+ Assert.False(System.IO.File.Exists(Path.Combine(_dir, "settings.json.tmp")), "temp file cleaned up");
+ }
+
+ [Fact]
+ public void BareRelativePath_SavesInTheWorkingFolder()
+ {
+ string name = "godosa-settings-" + Guid.NewGuid().ToString("N") + ".json";
+ try
+ {
+ Store(name).Save("x");
+ Assert.Empty(_reports);
+ Assert.Equal("x", System.IO.File.ReadAllText(Path.GetFullPath(name)));
+ }
+ finally
+ {
+ System.IO.File.Delete(Path.GetFullPath(name));
+ }
+ }
+
+ [Fact]
+ public void DefaultPath_UsesOverrideWhenSet_ElseAppDataFolder()
+ {
+ string variable = "GODOSA_TEST_SETTINGS_" + Guid.NewGuid().ToString("N");
+ string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ Assert.Equal(Path.Combine(appData, "some-game", "settings.json"), SettingsFile<string>.DefaultPath(variable, "some-game"));
+ Environment.SetEnvironmentVariable(variable, "/tmp/x.json");
+ try
+ {
+ Assert.Equal("/tmp/x.json", SettingsFile<string>.DefaultPath(variable, "some-game"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+}
diff --git a/src/Godosa.Core/Audio/AudioTypes.cs b/src/Godosa.Core/Audio/AudioTypes.cs
new file mode 100644
index 0000000..1ef0730
--- /dev/null
+++ b/src/Godosa.Core/Audio/AudioTypes.cs
@@ -0,0 +1,45 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>An endless stereo source the audio thread pulls blocks from.</summary>
+public interface IStereoStream
+{
+ /// <summary>Fills <paramref name="frames"/> interleaved L/R frames, −1…1.</summary>
+ void Render(float[] interleaved, int frames);
+}
+
+/// <summary>Headphone 3D (binaural). Auto lets OpenAL Soft decide from the output device.</summary>
+public enum HrtfMode { Auto, On, Off }
+
+/// <summary>Why the game is (not) paused, as far as sound cares.</summary>
+public enum AudioPauseState { Running, Menu, Inactive }
+
+/// <summary>The game's mix policy, read by the audio thread every update (≈ 10 ms).
+/// Channels are the game's own small ints (cast its enum).</summary>
+public interface IAudioMix
+{
+ HrtfMode Hrtf { get; }
+ float ChannelGain(int channel, AudioPauseState state);
+ /// <summary>True if sounds on this channel pause with the game world.</summary>
+ bool Pausable(int channel);
+ /// <summary>True if the game world (pausable channels) is paused in this state.</summary>
+ bool WorldPaused(AudioPauseState state);
+ /// <summary>The channel's name in debug logs (default: its number).</summary>
+ string ChannelName(int channel) => channel.ToString(System.Globalization.CultureInfo.InvariantCulture);
+}
+
+/// <summary>EFX standard reverb parameters (OpenAL Soft preset values, efx-presets.h).</summary>
+public readonly record struct ReverbPreset(
+ float Density, float Diffusion, float Gain, float GainHF, float DecayTime, float DecayHFRatio,
+ float ReflectionsGain, float ReflectionsDelay, float LateReverbGain, float LateReverbDelay,
+ float AirAbsorptionGainHF, float RoomRolloffFactor, bool DecayHFLimit)
+{
+ public static readonly ReverbPreset Forest = new(1f, 0.3f, 0.3162f, 0.0224f, 1.49f, 0.54f,
+ 0.0525f, 0.162f, 0.7682f, 0.088f, 0.9943f, 0f, false);
+
+ public static readonly ReverbPreset Plain = new(1f, 0.21f, 0.3162f, 0.1f, 1.49f, 0.5f,
+ 0.0585f, 0.179f, 0.1089f, 0.1f, 0.9943f, 0f, true);
+
+ /// <summary>EFX_REVERB_PRESET_CAVE: long, bright, dense.</summary>
+ public static readonly ReverbPreset Cave = new(1f, 1f, 0.3162f, 1f, 2.91f, 1.3f,
+ 0.5f, 0.015f, 0.7063f, 0.022f, 0.9943f, 0f, false);
+}
diff --git a/src/Godosa.Core/Audio/BiquadFilter.cs b/src/Godosa.Core/Audio/BiquadFilter.cs
new file mode 100644
index 0000000..d0c0f5e
--- /dev/null
+++ b/src/Godosa.Core/Audio/BiquadFilter.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+public enum FilterKind { LowPass, HighPass, BandPass }
+
+/// <summary>Stateful RBJ-cookbook biquad (transposed direct form II): keeps its state between
+/// calls, so a stream filters block after block without seams.</summary>
+public sealed class BiquadFilter
+{
+ private float _b0, _b1, _b2, _a1, _a2, _z1, _z2;
+
+ public void Set(FilterKind kind, float hz, float q) => SetAt(Pcm.SampleRate, kind, hz, q);
+
+ /// <summary>For a filter run at another rate (e.g. a control-rate modulator).</summary>
+ public void SetAt(float sampleRate, FilterKind kind, float hz, float q)
+ {
+ float f = Math.Clamp(hz, 1f, sampleRate * 0.45f);
+ float w = 2f * MathF.PI * f / sampleRate, cos = MathF.Cos(w), alpha = MathF.Sin(w) / (2f * q);
+ float a0 = 1f + alpha;
+ var (b0, b1, b2) = kind switch
+ {
+ FilterKind.LowPass => ((1f - cos) / 2f, 1f - cos, (1f - cos) / 2f),
+ FilterKind.HighPass => ((1f + cos) / 2f, -(1f + cos), (1f + cos) / 2f),
+ _ => (alpha, 0f, -alpha), // band-pass, 0 dB peak
+ };
+ (_b0, _b1, _b2, _a1, _a2) = (b0 / a0, b1 / a0, b2 / a0, -2f * cos / a0, (1f - alpha) / a0);
+ }
+
+ public float Process(float x)
+ {
+ float y = _b0 * x + _z1;
+ _z1 = _b1 * x - _a1 * y + _z2;
+ _z2 = _b2 * x - _a2 * y;
+ return y;
+ }
+}
diff --git a/src/Godosa.Core/Audio/Mp3.cs b/src/Godosa.Core/Audio/Mp3.cs
new file mode 100644
index 0000000..8ca1b57
--- /dev/null
+++ b/src/Godosa.Core/Audio/Mp3.cs
@@ -0,0 +1,133 @@
+using System;
+using System.IO;
+using NLayer;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Whole MP3 files decoded at once (NLayer), downmixed to mono at the file's rate like
+/// <see cref="WavReader"/>: for positioned sounds and loop emitters. Undecodable → null.</summary>
+public static class Mp3Reader
+{
+ /// <summary>Length in seconds from the frame headers (NLayer's scan, no decoding: ~1/60 of <see cref="Read"/>'s cost),
+ /// so a caller can time a sound and decode it elsewhere. Undecodable → null.</summary>
+ public static double? Seconds(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var samples = mp3.Length / (sizeof(float) * Math.Max(1, mp3.Channels));
+ return samples > 0 && mp3.SampleRate > 0 ? samples / (double)mp3.SampleRate : null;
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+
+ public static PcmClip? Read(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var channels = Math.Max(1, mp3.Channels);
+ var samples = new System.Collections.Generic.List<float>();
+ var block = new float[4096 * channels];
+ int read;
+ while ((read = mp3.ReadSamples(block, 0, block.Length)) > 0)
+ samples.AddRange(block.AsSpan(0, read - read % channels).ToArray());
+ return samples.Count == 0 ? null : new PcmClip(Pcm.Downmix([.. samples], channels), mp3.SampleRate);
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+}
+
+/// <summary>An MP3 as a stereo stream (NLayer decode on the audio thread; lifted from an earlier game of the same author): mono is duplicated,
+/// other sample rates are resampled linearly to <see cref="Pcm.SampleRate"/>; a once-only stream goes silent and reports
+/// <see cref="Finished"/>. For music and speech.</summary>
+public sealed class Mp3Stream : IStereoStream, IDisposable
+{
+ private readonly MpegFile _file;
+ private readonly bool _loop;
+ private readonly int _channels;
+ private readonly double _step;
+ private float[] _buffer = [];
+ private int _available, _read;
+ private double _position;
+ private volatile bool _finished;
+
+ public Mp3Stream(byte[] data, bool loop)
+ {
+ _file = new MpegFile(new MemoryStream(data, writable: false));
+ _loop = loop;
+ _channels = Math.Max(1, _file.Channels);
+ _step = _file.SampleRate / (double)Pcm.SampleRate;
+ }
+
+ public bool Finished => _finished;
+
+ public void Render(float[] interleaved, int frames)
+ {
+ for (var f = 0; f < frames; f++)
+ {
+ if (!Frame(out var left, out var right))
+ {
+ Array.Clear(interleaved, 2 * f, 2 * (frames - f));
+ return;
+ }
+ interleaved[2 * f] = left;
+ interleaved[2 * f + 1] = right;
+ _position += _step;
+ }
+ }
+
+ private bool Frame(out float left, out float right)
+ {
+ left = right = 0f;
+ var index = (int)_position;
+ while (index >= _available)
+ {
+ if (!Fill())
+ return false;
+ index = (int)_position;
+ }
+ var next = Math.Min(index + 1, _available - 1);
+ var t = (float)(_position - index);
+ left = Lerp(_buffer[index * _channels], _buffer[next * _channels], t);
+ right = _channels > 1 ? Lerp(_buffer[index * _channels + 1], _buffer[next * _channels + 1], t) : left;
+ return true;
+ }
+
+ private static float Lerp(float a, float b, float t) => a + (b - a) * t;
+
+ private bool Fill()
+ {
+ if (_finished)
+ return false;
+ _position -= _available;
+ if (_buffer.Length == 0)
+ _buffer = new float[4096 * _channels];
+ var read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ if (read <= 0 && _loop && _read > 0)
+ {
+ _file.Position = 0;
+ _read = 0;
+ read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ }
+ if (read <= 0)
+ {
+ _finished = true;
+ _available = 0;
+ return false;
+ }
+ _read += read;
+ _available = read / _channels;
+ return true;
+ }
+
+ public void Dispose() => _file.Dispose();
+}
diff --git a/src/Godosa.Core/Audio/Pcm.cs b/src/Godosa.Core/Audio/Pcm.cs
new file mode 100644
index 0000000..2af554a
--- /dev/null
+++ b/src/Godosa.Core/Audio/Pcm.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>16-bit mono PCM helpers.</summary>
+public static class Pcm
+{
+ public const int SampleRate = 44100;
+
+ /// <summary>Interleaved float frames (e.g. decoded OGG) → mono 16-bit.</summary>
+ public static short[] Downmix(float[] interleaved, int channels)
+ {
+ var pcm = new short[interleaved.Length / channels];
+ for (int f = 0; f < pcm.Length; f++)
+ {
+ float sum = 0f;
+ for (int c = 0; c < channels; c++) sum += interleaved[f * channels + c];
+ pcm[f] = ToShort(sum / channels);
+ }
+ return pcm;
+ }
+
+ public static short ToShort(float sample) => (short)Math.Clamp(MathF.Round(sample * short.MaxValue), -short.MaxValue, short.MaxValue);
+}
diff --git a/src/Godosa.Core/Audio/SignalStats.cs b/src/Godosa.Core/Audio/SignalStats.cs
new file mode 100644
index 0000000..34f30df
--- /dev/null
+++ b/src/Godosa.Core/Audio/SignalStats.cs
@@ -0,0 +1,51 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>Signal measurements for audio tests and reports: zero crossings, RMS, spectral centroid.</summary>
+public static class SignalStats
+{
+ public static int ZeroCrossings(float[] x, int from = 0, int to = -1)
+ {
+ if (to < 0) to = x.Length;
+ int n = 0;
+ for (int i = from + 1; i < to; i++) if ((x[i - 1] < 0) != (x[i] < 0)) n++;
+ return n;
+ }
+
+ public static float Rms(float[] x, int from = 0)
+ {
+ double sum = 0;
+ for (int i = from; i < x.Length; i++) sum += x[i] * x[i];
+ return (float)Math.Sqrt(sum / (x.Length - from));
+ }
+
+ /// <summary>Power-weighted mean frequency (where the energy is) of the loudest 2048-sample
+ /// window, naive DFT. Magnitude weighting over-counts faint broadband tails.</summary>
+ public static float Centroid(short[] pcm, int rate = Pcm.SampleRate)
+ {
+ const int n = 2048;
+ int best = 0;
+ double bestEnergy = -1;
+ for (int start = 0; start + n <= Math.Max(pcm.Length, n); start += n / 2)
+ {
+ double e = 0;
+ for (int i = start; i < Math.Min(pcm.Length, start + n); i++) e += (double)pcm[i] * pcm[i];
+ if (e > bestEnergy) (bestEnergy, best) = (e, start);
+ }
+ double weighted = 0, total = 0;
+ for (int k = 1; k < n / 2; k++)
+ {
+ double re = 0, im = 0;
+ for (int i = 0; i < n; i++)
+ {
+ double v = best + i < pcm.Length ? pcm[best + i] : 0;
+ double a = 2 * Math.PI * k * i / n;
+ re += v * Math.Cos(a);
+ im -= v * Math.Sin(a);
+ }
+ double power = re * re + im * im;
+ weighted += power * k * rate / (double)n;
+ total += power;
+ }
+ return (float)(weighted / total);
+ }
+}
diff --git a/src/Godosa.Core/Audio/Synth.cs b/src/Godosa.Core/Audio/Synth.cs
new file mode 100644
index 0000000..cb663a9
--- /dev/null
+++ b/src/Godosa.Core/Audio/Synth.cs
@@ -0,0 +1,189 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Deterministic xorshift32 for sound variants and seeded songs. The seed is mixed first
+/// (murmur3 fmix32): raw xorshift's first draws barely change between nearby seeds.</summary>
+public sealed class SynthRandom(uint seed)
+{
+ private uint _state = Mix(seed) is var s && s != 0 ? s : 0x9E3779B9u;
+
+ public static uint Mix(uint h)
+ {
+ h ^= h >> 16;
+ h *= 0x85EBCA6Bu;
+ h ^= h >> 13;
+ h *= 0xC2B2AE35u;
+ h ^= h >> 16;
+ return h;
+ }
+
+ public float Next01()
+ {
+ _state ^= _state << 13;
+ _state ^= _state >> 17;
+ _state ^= _state << 5;
+ return (_state >> 8) / 16777216f;
+ }
+
+ public float Range(float min, float max) => min + (max - min) * Next01();
+
+ /// <summary>Uniform in [−1, 1).</summary>
+ public float Signed() => Next01() * 2f - 1f;
+
+ /// <summary>FNV-1a: stable across runs (string.GetHashCode isn't).</summary>
+ public static uint Hash(string text)
+ {
+ uint h = 2166136261;
+ foreach (char c in text) h = (h ^ c) * 16777619;
+ return h;
+ }
+}
+
+public enum WaveShape { Sine, Saw, Square, Triangle }
+
+/// <summary>Placeholder sound synthesis (audio spec §3): float buffers at 44.1 kHz,
+/// oscillators with exponential sweeps, noise, envelopes, swept RBJ biquads.</summary>
+public static class Synth
+{
+ public const int Rate = Pcm.SampleRate;
+ private const int FilterBlock = 32;
+
+ public static int Samples(float seconds) => Math.Max(1, (int)(seconds * Rate));
+
+ public static float[] Sine(float seconds, float f0, float? f1 = null) => Wave(WaveShape.Sine, seconds, f0, f1 ?? f0);
+
+ /// <summary>Oscillator whose pitch sweeps exponentially from f0 to f1 over the buffer.</summary>
+ public static float[] Wave(WaveShape shape, float seconds, float f0, float f1)
+ {
+ var x = new float[Samples(seconds)];
+ double phase = 0, ratio = f1 / f0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ double f = f0 * Math.Pow(ratio, i / (double)x.Length);
+ float p = (float)(phase - Math.Floor(phase));
+ x[i] = shape switch
+ {
+ WaveShape.Saw => 2f * p - 1f,
+ WaveShape.Square => p < 0.5f ? 1f : -1f,
+ WaveShape.Triangle => 1f - 4f * MathF.Abs(p - 0.5f),
+ _ => MathF.Sin(2f * MathF.PI * p),
+ };
+ phase += f / Rate;
+ }
+ return x;
+ }
+
+ /// <summary>FM: carrier f, modulator at f·ratio, index sweeping index0 → index1 (bells, chirps).</summary>
+ public static float[] Fm(float seconds, float carrier, float ratio, float index0, float index1, float? carrierEnd = null)
+ {
+ var x = new float[Samples(seconds)];
+ double pc = 0, pm = 0, cRatio = (carrierEnd ?? carrier) / carrier;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)x.Length;
+ double c = carrier * Math.Pow(cRatio, t);
+ float index = index0 + (index1 - index0) * t;
+ x[i] = (float)Math.Sin(2 * Math.PI * pc + index * Math.Sin(2 * Math.PI * pm));
+ pc += c / Rate;
+ pm += c * ratio / Rate;
+ }
+ return x;
+ }
+
+ public static float[] White(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ for (int i = 0; i < x.Length; i++) x[i] = rng.Signed();
+ return x;
+ }
+
+ /// <summary>Pink-ish noise (Paul Kellet's economy filter): softer, like wind and leaves.</summary>
+ public static float[] Pink(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ float b0 = 0, b1 = 0, b2 = 0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float w = rng.Signed();
+ b0 = 0.99765f * b0 + w * 0.0990460f;
+ b1 = 0.96300f * b1 + w * 0.2965164f;
+ b2 = 0.57000f * b2 + w * 1.0526913f;
+ x[i] = (b0 + b1 + b2 + w * 0.1848f) * 0.25f;
+ }
+ return x;
+ }
+
+ /// <summary>Linear attack to 1, then exponential decay with time constant <paramref name="decay"/>.</summary>
+ public static float[] Perc(this float[] x, float attack, float decay)
+ {
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)Rate;
+ x[i] *= t < attack ? t / attack : MathF.Exp(-(t - attack) / decay);
+ }
+ return x;
+ }
+
+ public static float[] Gain(this float[] x, float gain)
+ {
+ for (int i = 0; i < x.Length; i++) x[i] *= gain;
+ return x;
+ }
+
+ /// <summary>Adds <paramref name="y"/> into <paramref name="into"/> at a time offset (clipped to fit).</summary>
+ public static float[] Add(this float[] into, float[] y, float atSeconds = 0f, float gain = 1f)
+ {
+ int offset = (int)(atSeconds * Rate);
+ for (int i = 0; i < y.Length && offset + i < into.Length; i++) into[offset + i] += y[i] * gain;
+ return into;
+ }
+
+ public static float[] LowPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.LowPass, f0, f1 ?? f0, q);
+ public static float[] HighPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.HighPass, f0, f1 ?? f0, q);
+ public static float[] BandPass(this float[] x, float f0, float q, float? f1 = null) => Filter(x, FilterKind.BandPass, f0, f1 ?? f0, q);
+
+ /// <summary>Biquad with its cutoff swept exponentially f0 → f1, retuned every 32 samples. In place.</summary>
+ private static float[] Filter(float[] x, FilterKind kind, float f0, float f1, float q)
+ {
+ var filter = new BiquadFilter();
+ for (int i = 0; i < x.Length; i++)
+ {
+ if (i % FilterBlock == 0) filter.Set(kind, f0 * MathF.Pow(f1 / f0, i / (float)x.Length), q);
+ x[i] = filter.Process(x[i]);
+ }
+ return x;
+ }
+
+ /// <summary>Makes a loop seamless: the extra <paramref name="overlapSamples"/> at the end fade
+ /// into the start, so the result wraps like any adjacent pair of samples.</summary>
+ public static float[] Loop(float[] x, int overlapSamples)
+ {
+ int overlap = overlapSamples, n = x.Length - overlap;
+ var y = new float[n];
+ Array.Copy(x, y, n);
+ for (int i = 0; i < overlap; i++)
+ {
+ float t = i / (float)overlap;
+ y[i] = x[i] * t + x[n + i] * (1f - t);
+ }
+ return y;
+ }
+
+ /// <summary>Normalises the peak to <paramref name="level"/> (0–1 FS) and, unless it's a loop,
+ /// fades 2 ms at both ends so starts and stops never click.</summary>
+ public static short[] ToPcm(float[] x, float level, bool fadeEnds = true)
+ {
+ float peak = 0f;
+ foreach (float v in x) peak = MathF.Max(peak, MathF.Abs(v));
+ float scale = peak > 0f ? level / peak : 0f;
+ int fade = fadeEnds ? Math.Min(x.Length / 2, Samples(0.002f)) : 0;
+ var pcm = new short[x.Length];
+ for (int i = 0; i < x.Length; i++)
+ {
+ float env = fade == 0 ? 1f : Math.Min(1f, Math.Min(i, x.Length - 1 - i) / (float)fade);
+ pcm[i] = Pcm.ToShort(x[i] * scale * env);
+ }
+ return pcm;
+ }
+}
diff --git a/src/Godosa.Core/Audio/WavReader.cs b/src/Godosa.Core/Audio/WavReader.cs
new file mode 100644
index 0000000..f587109
--- /dev/null
+++ b/src/Godosa.Core/Audio/WavReader.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Godosa.Core.Audio;
+
+public readonly record struct PcmClip(short[] Samples, int SampleRate);
+
+/// <summary>WAV files for sound overrides (audio spec §3): 8/16/24-bit PCM, 32-bit float or
+/// Microsoft ADPCM (4-bit, format 2), plain or extensible header, any channel count, downmixed
+/// to mono (OpenAL only spatialises mono). Anything else → null.</summary>
+public static class WavReader
+{
+ private const int FormatPcm = 1, FormatMsAdpcm = 2, FormatFloat = 3, FormatExtensible = 0xFFFE;
+
+ public static PcmClip? Read(ReadOnlySpan<byte> file)
+ {
+ if (file.Length < 12 || !file[..4].SequenceEqual("RIFF"u8) || !file[8..12].SequenceEqual("WAVE"u8)) return null;
+ int pos = 12, format = 0, channels = 0, rate = 0, bits = 0, blockAlign = 0, factFrames = -1;
+ var fmt = ReadOnlySpan<byte>.Empty;
+ while (pos + 8 <= file.Length)
+ {
+ var id = file.Slice(pos, 4);
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ int body = pos + 8;
+ if (size < 0 || body > file.Length) return null;
+ if (id.SequenceEqual("fmt "u8) && size >= 16 && body + 16 <= file.Length)
+ {
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body, 2));
+ channels = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 2, 2));
+ rate = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body + 4, 4));
+ blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 12, 2));
+ bits = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 14, 2));
+ fmt = file.Slice(body, Math.Min(size, file.Length - body));
+ if (format == FormatExtensible && size >= 26 && body + 26 <= file.Length)
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 24, 2));
+ }
+ else if (id.SequenceEqual("fact"u8) && size >= 4 && body + 4 <= file.Length)
+ factFrames = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body, 4));
+ else if (id.SequenceEqual("data"u8))
+ {
+ if (channels < 1 || rate < 1) return null;
+ var data = file.Slice(body, Math.Min(size, file.Length - body));
+ // fact (sample count) usually follows fmt, but may come after data: look ahead for it.
+ if (factFrames < 0 && FindFact(file, body + size + (size & 1)) is { } later) factFrames = later;
+ var pcm = format == FormatMsAdpcm ? MsAdpcm(data, fmt, channels, blockAlign) : Decode(data, format, channels, bits);
+ if (pcm != null && factFrames >= 0 && factFrames < pcm.Length) pcm = pcm[..factFrames];
+ return pcm is null ? null : new PcmClip(pcm, rate);
+ }
+ pos = body + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static int? FindFact(ReadOnlySpan<byte> file, int pos)
+ {
+ while (pos + 8 <= file.Length)
+ {
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ if (size < 0) return null;
+ if (file.Slice(pos, 4).SequenceEqual("fact"u8) && size >= 4 && pos + 12 <= file.Length)
+ return BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 8, 4));
+ pos += 8 + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static readonly int[] AdpcmAdaptation = [230, 230, 230, 230, 307, 409, 512, 614, 768, 614, 512, 409, 307, 230, 230, 230];
+
+ /// <summary>Microsoft ADPCM: per block and channel a predictor index, delta, and two history samples (output
+ /// oldest first), then 4-bit codes, high nibble first, channels interleaved per nibble. Coefficient pairs come from
+ /// the fmt extension (cbSize, samples per block, count, pairs). A trailing partial block decodes what it holds.</summary>
+ private static short[]? MsAdpcm(ReadOnlySpan<byte> data, ReadOnlySpan<byte> fmt, int channels, int blockAlign)
+ {
+ if (channels > 2 || fmt.Length < 22 || blockAlign < 7 * channels) return null;
+ int count = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(20, 2));
+ if (count < 1 || fmt.Length < 22 + 4 * count) return null;
+ var coef = new (int C1, int C2)[count];
+ for (int i = 0; i < count; i++)
+ coef[i] = (BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(22 + 4 * i, 2)), BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(24 + 4 * i, 2)));
+ var mono = new System.Collections.Generic.List<short>();
+ Span<int> c1 = stackalloc int[2], c2 = stackalloc int[2], delta = stackalloc int[2], s1 = stackalloc int[2], s2 = stackalloc int[2],
+ frame = stackalloc int[2];
+ for (int start = 0; start + 7 * channels <= data.Length; start += blockAlign)
+ {
+ var block = data.Slice(start, Math.Min(blockAlign, data.Length - start));
+ int p = 0;
+ for (int c = 0; c < channels; c++)
+ {
+ int index = block[p++];
+ if (index >= count) return null;
+ (c1[c], c2[c]) = coef[index];
+ }
+ for (int c = 0; c < channels; c++, p += 2) delta[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s1[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s2[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ mono.Add(Mix(s2, channels));
+ mono.Add(Mix(s1, channels));
+ int ch = 0;
+ for (; p < block.Length; p++)
+ for (int shift = 4; shift >= 0; shift -= 4)
+ {
+ int code = (block[p] >> shift) & 0xF;
+ int signed = code >= 8 ? code - 16 : code;
+ int predicted = ((s1[ch] * c1[ch]) + (s2[ch] * c2[ch])) >> 8;
+ int sample = Math.Clamp(predicted + signed * delta[ch], short.MinValue, short.MaxValue);
+ (s2[ch], s1[ch]) = (s1[ch], sample);
+ delta[ch] = Math.Max(16, (AdpcmAdaptation[code] * delta[ch]) >> 8);
+ frame[ch] = sample;
+ if (++ch == channels)
+ {
+ mono.Add(Mix(frame, channels));
+ ch = 0;
+ }
+ }
+ }
+ return [.. mono];
+ }
+
+ private static short Mix(ReadOnlySpan<int> frame, int channels) => (short)(channels == 1 ? frame[0] : (frame[0] + frame[1]) / 2);
+
+ private static short[]? Decode(ReadOnlySpan<byte> data, int format, int channels, int bits)
+ {
+ int bytes = bits / 8;
+ bool supported = (format == FormatPcm && bits is 8 or 16 or 24) || (format == FormatFloat && bits == 32);
+ if (!supported) return null;
+ int frames = data.Length / (bytes * channels);
+ var pcm = new short[frames];
+ for (int f = 0; f < frames; f++)
+ {
+ if (bits == 8)
+ {
+ int sum8 = 0;
+ for (int c = 0; c < channels; c++) sum8 += (data[f * channels + c] - 128) << 8; // unsigned, 128 = 0
+ pcm[f] = (short)(sum8 / channels);
+ continue;
+ }
+ if (bits == 16)
+ {
+ int sum = 0;
+ for (int c = 0; c < channels; c++)
+ sum += BinaryPrimitives.ReadInt16LittleEndian(data.Slice((f * channels + c) * 2, 2));
+ pcm[f] = (short)(sum / channels);
+ continue;
+ }
+ float mix = 0f;
+ for (int c = 0; c < channels; c++)
+ {
+ var s = data.Slice((f * channels + c) * bytes, bytes);
+ mix += bits == 24
+ ? ((s[0] | s[1] << 8 | (sbyte)s[2] << 16) / 8388608f)
+ : BinaryPrimitives.ReadSingleLittleEndian(s);
+ }
+ pcm[f] = Pcm.ToShort(mix / channels);
+ }
+ return pcm;
+ }
+}
diff --git a/src/Godosa.Core/Diagnostics/DebugFlags.cs b/src/Godosa.Core/Diagnostics/DebugFlags.cs
new file mode 100644
index 0000000..27b63bf
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/DebugFlags.cs
@@ -0,0 +1,20 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Opt-in debug categories from one environment value: a comma list,
+/// case-insensitive; <c>all</c> turns on every category but not <see cref="Exact"/>
+/// switches (perf experiments like <c>nohud</c>).</summary>
+public sealed class DebugFlags
+{
+ private readonly string[] _names;
+
+ public DebugFlags(string? value) =>
+ _names = (value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ public bool On(string category) =>
+ Array.Exists(_names, n =>
+ n.Equals(category, StringComparison.OrdinalIgnoreCase) ||
+ n.Equals("all", StringComparison.OrdinalIgnoreCase));
+
+ public bool Exact(string name) =>
+ Array.Exists(_names, n => n.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Godosa.Core/Diagnostics/EngineDebug.cs b/src/Godosa.Core/Diagnostics/EngineDebug.cs
new file mode 100644
index 0000000..3cad819
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/EngineDebug.cs
@@ -0,0 +1,25 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>The lib's own debug categories (<c>input</c>, <c>script</c>, <c>audio</c>), read
+/// from the game's variable: call <see cref="Configure"/> first in <c>Main</c>, before any lib
+/// code runs. Gate each call on its flag so disabled logging costs one bool check and no
+/// string formatting.</summary>
+public static class EngineDebug
+{
+ public static bool Input { get; private set; }
+ public static bool Script { get; private set; }
+ public static bool Audio { get; private set; }
+
+ public static void Configure(string environmentVariable) =>
+ Apply(new DebugFlags(Environment.GetEnvironmentVariable(environmentVariable)));
+
+ public static void Apply(DebugFlags flags)
+ {
+ Input = flags.On("input");
+ Script = flags.On("script");
+ Audio = flags.On("audio");
+ }
+
+ public static void Write(string category, string message) =>
+ Console.Error.WriteLine($"[{category}] {message}");
+}
diff --git a/src/Godosa.Core/Diagnostics/FrameTimeStats.cs b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
new file mode 100644
index 0000000..2d1945c
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
@@ -0,0 +1,139 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>One snapshot of the rolling frame window (game-loop spec §5). Times in ms.</summary>
+public readonly record struct FrameStatsSummary(
+ int Frames,
+ double Fps,
+ double AvgMs,
+ double P50Ms,
+ double P95Ms,
+ double P99Ms,
+ double MaxMs,
+ double OnePercentLowFps,
+ int Hitches,
+ double AvgWorkMs,
+ double SimTickRate,
+ double AvgTickMs,
+ int GpuSamples = 0,
+ double AvgGpuMs = 0);
+
+/// <summary>
+/// Rolling window of render-frame timings (game-loop spec §5). FPS is frames ÷
+/// window time, percentiles are nearest-rank over frame times, 1% low is the
+/// mean of the slowest 1% of frames, and a hitch is a frame over 2× the median.
+/// Buffers are preallocated so recording and summarizing don't allocate.
+/// </summary>
+public sealed class FrameTimeStats
+{
+ private readonly double[] _intervals;
+ private readonly double[] _work;
+ private readonly int[] _ticks;
+ private readonly double[] _sim;
+ private readonly double[] _sorted;
+ private readonly double[] _gpu;
+ private int _next, _gpuNext, _gpuCount;
+
+ public FrameTimeStats(int capacity = 240)
+ {
+ _intervals = new double[capacity];
+ _work = new double[capacity];
+ _ticks = new int[capacity];
+ _sim = new double[capacity];
+ _sorted = new double[capacity];
+ _gpu = new double[capacity];
+ }
+
+ /// <summary>GPU time of one frame (timer queries report a few frames late, so these
+ /// roll separately from <see cref="Record"/>).</summary>
+ public void RecordGpu(double seconds)
+ {
+ _gpu[_gpuNext] = seconds;
+ _gpuNext = (_gpuNext + 1) % Capacity;
+ if (_gpuCount < Capacity) _gpuCount++;
+ }
+
+ public int Capacity => _intervals.Length;
+ public int Count { get; private set; }
+
+ /// <param name="intervalSeconds">Time since the previous frame started.</param>
+ /// <param name="workSeconds">CPU time spent inside this frame's render callback.</param>
+ /// <param name="simTicks">Simulation ticks run this frame.</param>
+ /// <param name="simSeconds">CPU time those ticks took.</param>
+ public void Record(double intervalSeconds, double workSeconds, int simTicks, double simSeconds)
+ {
+ _intervals[_next] = intervalSeconds;
+ _work[_next] = workSeconds;
+ _ticks[_next] = simTicks;
+ _sim[_next] = simSeconds;
+ _next = (_next + 1) % Capacity;
+ if (Count < Capacity) Count++;
+ }
+
+ /// <summary>Copies frame intervals in ms, oldest first; returns how many were written.</summary>
+ public int CopyIntervalsMs(Span<float> destination)
+ {
+ int n = Math.Min(Count, destination.Length);
+ int start = (_next - n + Capacity) % Capacity;
+ for (int i = 0; i < n; i++)
+ destination[i] = (float)(_intervals[(start + i) % Capacity] * 1000.0);
+ return n;
+ }
+
+ public FrameStatsSummary Summarize()
+ {
+ int n = Count;
+ if (n == 0) return default(FrameStatsSummary) with { GpuSamples = _gpuCount, AvgGpuMs = GpuAverageMs() };
+
+ double total = 0, work = 0, sim = 0;
+ int ticks = 0;
+ for (int i = 0; i < n; i++)
+ {
+ total += _intervals[i];
+ work += _work[i];
+ ticks += _ticks[i];
+ sim += _sim[i];
+ _sorted[i] = _intervals[i];
+ }
+ Array.Sort(_sorted, 0, n);
+
+ double median = NearestRank(50, n);
+ int slowest = Math.Max(1, (int)Math.Ceiling(n * 0.01));
+ double slowestSum = 0;
+ for (int i = n - slowest; i < n; i++) slowestSum += _sorted[i];
+
+ int hitches = 0;
+ for (int i = 0; i < n; i++)
+ if (_intervals[i] > 2 * median) hitches++;
+
+ return new FrameStatsSummary(
+ Frames: n,
+ Fps: total > 0 ? n / total : 0,
+ AvgMs: total / n * 1000,
+ P50Ms: median * 1000,
+ P95Ms: NearestRank(95, n) * 1000,
+ P99Ms: NearestRank(99, n) * 1000,
+ MaxMs: _sorted[n - 1] * 1000,
+ OnePercentLowFps: slowestSum > 0 ? slowest / slowestSum : 0,
+ Hitches: hitches,
+ AvgWorkMs: work / n * 1000,
+ SimTickRate: total > 0 ? ticks / total : 0,
+ AvgTickMs: ticks > 0 ? sim / ticks * 1000 : 0,
+ GpuSamples: _gpuCount,
+ AvgGpuMs: GpuAverageMs());
+ }
+
+ private double GpuAverageMs()
+ {
+ if (_gpuCount == 0) return 0;
+ double sum = 0;
+ for (int i = 0; i < _gpuCount; i++) sum += _gpu[i];
+ return sum / _gpuCount * 1000;
+ }
+
+ /// <summary>Nearest-rank percentile of the first <paramref name="n"/> sorted intervals.</summary>
+ private double NearestRank(int percent, int n)
+ {
+ int rank = Math.Max(1, (int)Math.Ceiling(percent / 100.0 * n));
+ return _sorted[rank - 1];
+ }
+}
diff --git a/src/Godosa.Core/Diagnostics/PerfCsv.cs b/src/Godosa.Core/Diagnostics/PerfCsv.cs
new file mode 100644
index 0000000..6c4ef0d
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/PerfCsv.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Per-frame CSV rows for perf-log stall hunts. Fixed columns first, then the
+/// caller's own (<see cref="Header"/> names, <see cref="Row"/> values, same order).
+/// Invariant culture; times in ms with 3 decimals; blank = unknown (e.g. GPU before a
+/// timer query reports).</summary>
+public static class PerfCsv
+{
+ public const string BaseHeader = "t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms";
+
+ public static string Header(params string[] extraColumns) =>
+ extraColumns.Length == 0 ? BaseHeader : BaseHeader + "," + string.Join(',', extraColumns);
+
+ public static string Row(double timeSeconds, double frameSeconds, double workSeconds, int simTicks,
+ double simSeconds, double? gpuSeconds, params string[] extraValues)
+ {
+ string row = string.Create(CultureInfo.InvariantCulture,
+ $"{timeSeconds:F3},{frameSeconds * 1000:F3},{workSeconds * 1000:F3},{simTicks},{simSeconds * 1000:F3},{Ms(gpuSeconds)}");
+ return extraValues.Length == 0 ? row : row + "," + string.Join(',', extraValues);
+ }
+
+ /// <summary>Seconds → ms with 3 decimals (invariant); blank for null.</summary>
+ public static string Ms(double? seconds) =>
+ seconds is { } s ? (s * 1000).ToString("F3", CultureInfo.InvariantCulture) : "";
+}
diff --git a/src/Godosa.Core/Godosa.Core.csproj b/src/Godosa.Core/Godosa.Core.csproj
new file mode 100644
index 0000000..41ac853
--- /dev/null
+++ b/src/Godosa.Core/Godosa.Core.csproj
@@ -0,0 +1,5 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <PackageReference Include="NLayer" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Core/Input/GamepadState.cs b/src/Godosa.Core/Input/GamepadState.cs
new file mode 100644
index 0000000..5f1d5a5
--- /dev/null
+++ b/src/Godosa.Core/Input/GamepadState.cs
@@ -0,0 +1,58 @@
+using System.Numerics;
+
+namespace Godosa.Core.Input;
+
+[Flags]
+public enum PadButtons
+{
+ None = 0,
+ A = 1 << 0,
+ B = 1 << 1,
+ X = 1 << 2,
+ Y = 1 << 3,
+ Back = 1 << 4,
+ Start = 1 << 5,
+ LeftShoulder = 1 << 6,
+ RightShoulder = 1 << 7,
+ DpadUp = 1 << 8,
+ DpadDown = 1 << 9,
+ DpadLeft = 1 << 10,
+ DpadRight = 1 << 11,
+}
+
+/// <summary>Raw held pad state, no game meaning: sticks −1…1 (SDL convention, stick up =
+/// −Y), triggers 0…1. Each game maps it to its own input frame.</summary>
+public readonly record struct GamepadState(Vector2 LeftStick, Vector2 RightStick, float LeftTrigger, float RightTrigger, PadButtons Buttons)
+{
+ public bool Held(PadButtons button) => (Buttons & button) != 0;
+}
+
+/// <summary>Input-script pad events (<c>PadA PadB PadX PadY PadBack PadStart PadUp PadDown
+/// PadLeft PadRight PadLB PadRB PadLT PadRT</c>, case-insensitive): triggers go full on/off.</summary>
+public static class GamepadScript
+{
+ public static GamepadState? Apply(GamepadState state, string name, bool down)
+ {
+ string key = name.ToLowerInvariant();
+ if (key == "padlt") return state with { LeftTrigger = down ? 1f : 0f };
+ if (key == "padrt") return state with { RightTrigger = down ? 1f : 0f };
+ PadButtons button = key switch
+ {
+ "pada" => PadButtons.A,
+ "padb" => PadButtons.B,
+ "padx" => PadButtons.X,
+ "pady" => PadButtons.Y,
+ "padback" => PadButtons.Back,
+ "padstart" => PadButtons.Start,
+ "padup" => PadButtons.DpadUp,
+ "paddown" => PadButtons.DpadDown,
+ "padleft" => PadButtons.DpadLeft,
+ "padright" => PadButtons.DpadRight,
+ "padlb" => PadButtons.LeftShoulder,
+ "padrb" => PadButtons.RightShoulder,
+ _ => PadButtons.None,
+ };
+ if (button == PadButtons.None) return null;
+ return state with { Buttons = down ? state.Buttons | button : state.Buttons & ~button };
+ }
+}
diff --git a/src/Godosa.Core/Input/InputScript.cs b/src/Godosa.Core/Input/InputScript.cs
new file mode 100644
index 0000000..481b78f
--- /dev/null
+++ b/src/Godosa.Core/Input/InputScript.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace Godosa.Core.Input;
+
+public enum ScriptAction { Down, Up, Click, Type }
+
+/// <param name="Arg">Physical key name (Down/Up), button label (Click) or text (Type).</param>
+public readonly record struct ScriptEvent(double Time, ScriptAction Action, string Arg);
+
+/// <summary>Timed input for smoke runs (the host replays it as real key events and button
+/// clicks). One per line: <c>TIME ACTION ARGS</c>, TIME absolute seconds or <c>+seconds</c>
+/// after the previous line; actions <c>down K</c>, <c>up K</c>, <c>press K</c>,
+/// <c>hold K SECONDS</c>, <c>click LABEL</c>, <c>type TEXT</c> (into the focused text box); <c>#</c> comments.</summary>
+public sealed class InputScript
+{
+ public const double PressSeconds = 0.1;
+
+ private int _next;
+
+ public IReadOnlyList<ScriptEvent> Events { get; }
+
+ public bool Finished => _next >= Events.Count;
+
+ private InputScript(List<ScriptEvent> events) => Events = events;
+
+ public static InputScript Parse(string text)
+ {
+ var events = new List<ScriptEvent>();
+ double? previous = null;
+ var lines = text.Split('\n');
+ for (int n = 0; n < lines.Length; n++)
+ {
+ string line = lines[n].Trim();
+ if (line.Length == 0 || line.StartsWith('#')) continue;
+ string[] parts = line.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
+ FormatException Bad(string why) => new($"input script line {n + 1}: {why}: '{line}'");
+ if (parts.Length < 3) throw Bad("expected TIME ACTION ARG");
+
+ bool relative = parts[0].StartsWith('+');
+ if (!double.TryParse(parts[0].TrimStart('+'), NumberStyles.Float, CultureInfo.InvariantCulture, out double t))
+ throw Bad("bad time");
+ if (relative)
+ {
+ if (previous is null) throw Bad("relative time needs a line before it");
+ t += previous.Value;
+ }
+ previous = t;
+
+ string arg = parts[2];
+ switch (parts[1])
+ {
+ case "down": events.Add(new(t, ScriptAction.Down, arg)); break;
+ case "up": events.Add(new(t, ScriptAction.Up, arg)); break;
+ case "press":
+ events.Add(new(t, ScriptAction.Down, arg));
+ events.Add(new(t + PressSeconds, ScriptAction.Up, arg));
+ break;
+ case "hold":
+ string[] hold = arg.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (hold.Length != 2 || !double.TryParse(hold[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds))
+ throw Bad("hold needs KEY SECONDS");
+ events.Add(new(t, ScriptAction.Down, hold[0]));
+ events.Add(new(t + seconds, ScriptAction.Up, hold[0]));
+ break;
+ case "click": events.Add(new(t, ScriptAction.Click, arg)); break;
+ case "type": events.Add(new(t, ScriptAction.Type, arg)); break;
+ default: throw Bad($"unknown action '{parts[1]}'");
+ }
+ }
+ return new InputScript(events.OrderBy(e => e.Time).ToList()); // stable: same-time lines keep order
+ }
+
+ /// <summary>Appends every not-yet-returned event with Time ≤ <paramref name="now"/>.</summary>
+ public void Due(double now, List<ScriptEvent> into)
+ {
+ while (_next < Events.Count && Events[_next].Time <= now)
+ into.Add(Events[_next++]);
+ }
+}
diff --git a/src/Godosa.Core/Input/MenuCursor.cs b/src/Godosa.Core/Input/MenuCursor.cs
new file mode 100644
index 0000000..be6cab3
--- /dev/null
+++ b/src/Godosa.Core/Input/MenuCursor.cs
@@ -0,0 +1,21 @@
+namespace Godosa.Core.Input;
+
+/// <summary>Which action a gamepad highlights in an open window (hud spec §4): wraps at the
+/// ends, stays inside a list that shrinks, −1 when there's nothing to pick.</summary>
+public sealed class MenuCursor
+{
+ public int Index { get; private set; } = -1;
+
+ public void Clamp(int count)
+ {
+ if (count <= 0) Index = -1;
+ else if (Index < 0) Index = 0;
+ else if (Index >= count) Index = count - 1;
+ }
+
+ public void Move(int delta, int count)
+ {
+ if (count <= 0) return;
+ Index = ((Index + delta) % count + count) % count;
+ }
+}
diff --git a/src/Godosa.Core/Runs/DigestLine.cs b/src/Godosa.Core/Runs/DigestLine.cs
new file mode 100644
index 0000000..bc4519b
--- /dev/null
+++ b/src/Godosa.Core/Runs/DigestLine.cs
@@ -0,0 +1,13 @@
+using System.Globalization;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>The shared digest stdout line: <c>digest &lt;frame&gt; &lt;game seconds 0.###&gt; &lt;16 hex&gt;</c>.</summary>
+public static class DigestLine
+{
+ public static string Format(long frame, double gameSeconds, ulong hash) =>
+ string.Create(CultureInfo.InvariantCulture, $"digest {frame} {gameSeconds:0.###} {hash:x16}");
+
+ /// <summary>Every <paramref name="every"/> frames (frame 0 and every ≤ 0 never).</summary>
+ public static bool Due(long frame, int every) => every > 0 && frame > 0 && frame % every == 0;
+}
diff --git a/src/Godosa.Core/Runs/FrameClock.cs b/src/Godosa.Core/Runs/FrameClock.cs
new file mode 100644
index 0000000..eedb78d
--- /dev/null
+++ b/src/Godosa.Core/Runs/FrameClock.cs
@@ -0,0 +1,23 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Game time per frame: deterministic → a fixed <see cref="Step"/> whatever the wall clock; otherwise the wall dt
+/// clamped to [0, maxDt]. <see cref="Held"/> (e.g. a map still loading) → no game time and no frame counted.</summary>
+public sealed class FrameClock(bool deterministic, double step = 1.0 / 60, double maxDt = 0.1)
+{
+ public bool Deterministic { get; } = deterministic;
+ public double Step { get; } = step;
+ public long Frame { get; private set; }
+ public double Seconds { get; private set; }
+ public bool Held { get; set; }
+
+ /// <summary>The game dt of this frame (0 when held); counts the frame unless held.</summary>
+ public double Next(double wallDt)
+ {
+ if (Held)
+ return 0;
+ var dt = Deterministic ? Step : Math.Clamp(wallDt, 0, maxDt);
+ Frame++;
+ Seconds += dt;
+ return dt;
+ }
+}
diff --git a/src/Godosa.Core/Runs/HeadlessLoop.cs b/src/Godosa.Core/Runs/HeadlessLoop.cs
new file mode 100644
index 0000000..fc21b41
--- /dev/null
+++ b/src/Godosa.Core/Runs/HeadlessLoop.cs
@@ -0,0 +1,20 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Runs frames as fast as possible: <c>frame(n)</c> for n = 1, 2, … until it returns false, <paramref name="frames"/>
+/// is reached, or <paramref name="cap"/> (Capped = true).</summary>
+public static class HeadlessLoop
+{
+ public const long DefaultCap = 100_000;
+
+ public static (long Frames, bool Capped) Run(Func<long, bool> frame, long? frames, long cap = DefaultCap)
+ {
+ for (long n = 1; ; n++)
+ {
+ if (n > cap)
+ return (cap, true);
+ var more = frame(n);
+ if (!more || n == frames)
+ return (n, false);
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/RouteLegs.cs b/src/Godosa.Core/Runs/RouteLegs.cs
new file mode 100644
index 0000000..e02b7c3
--- /dev/null
+++ b/src/Godosa.Core/Runs/RouteLegs.cs
@@ -0,0 +1,117 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Route planning for run scripts (docs/notes/run-scripts-routing.md): a way over a grid (8 neighbours) cut into
+/// the legs the game's own router accepts, the script seals that close a way, and portal hops between walkable areas. Pure;
+/// the game passes its grid, its router's acceptance test and its portals.</summary>
+public static class RouteLegs
+{
+ /// <summary>Breadth-first over <c>[0,width)×[0,height)</c>, 8 neighbours, until <paramref name="goal"/>: each reached
+ /// cell → the one it was reached from (start → itself).</summary>
+ public static Dictionary<(long X, long Y), (long X, long Y)> Reach((long X, long Y) start, (long X, long Y) goal, long width, long height,
+ Func<long, long, bool> blocked)
+ {
+ var from = new Dictionary<(long X, long Y), (long X, long Y)> { [start] = start };
+ var queue = new Queue<(long X, long Y)>([start]);
+ while (queue.Count > 0 && !from.ContainsKey(goal))
+ {
+ var at = queue.Dequeue();
+ for (var dx = -1; dx <= 1; dx++)
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ var next = (at.X + dx, at.Y + dy);
+ if (from.ContainsKey(next) || next.Item1 < 0 || next.Item2 < 0 || next.Item1 >= width || next.Item2 >= height
+ || blocked(next.Item1, next.Item2))
+ continue;
+ from[next] = at;
+ queue.Enqueue(next);
+ }
+ }
+ return from;
+ }
+
+ /// <summary>The cells from <paramref name="start"/> (excluded) to <paramref name="goal"/> (included); null when walled
+ /// off.</summary>
+ public static List<(long X, long Y)>? Path((long X, long Y) start, (long X, long Y) goal, long width, long height, Func<long, long, bool> blocked)
+ {
+ var from = Reach(start, goal, width, height, blocked);
+ return from.ContainsKey(goal) ? Trace(from, start, goal) : null;
+ }
+
+ /// <summary>Legs along <paramref name="path"/>: from each stop the farthest path cell <paramref name="accepts"/> (the
+ /// game's router takes the trip), until the goal; null when the router takes no further cell from a stop.</summary>
+ public static List<(long X, long Y)>? Legs((long X, long Y) start, IReadOnlyList<(long X, long Y)> path,
+ Func<(long X, long Y), (long X, long Y), bool> accepts) => Legs(start, path, accepts, out _);
+
+ /// <inheritdoc cref="Legs(ValueTuple{long, long}, IReadOnlyList{ValueTuple{long, long}}, Func{ValueTuple{long, long}, ValueTuple{long, long}, bool})"/>
+ /// <param name="stuck">The stop no leg leaves (refusals name it).</param>
+ public static List<(long X, long Y)>? Legs((long X, long Y) start, IReadOnlyList<(long X, long Y)> path,
+ Func<(long X, long Y), (long X, long Y), bool> accepts, out (long X, long Y) stuck)
+ {
+ var legs = new List<(long X, long Y)>();
+ var (stop, index) = (start, 0);
+ while (index < path.Count)
+ {
+ var best = path.Count - 1;
+ while (best >= index && !accepts(stop, path[best]))
+ best--;
+ if (best < index)
+ {
+ stuck = stop;
+ return null;
+ }
+ (stop, index) = (path[best], best + 1);
+ legs.Add(stop);
+ }
+ stuck = default;
+ return legs;
+ }
+
+ /// <summary>The <paramref name="soft"/> cells (blocked by the game's scripts, opened later) the way would cross once
+ /// open, start to goal, at most <paramref name="max"/>; empty when walled off anyway or open already.</summary>
+ public static List<(long X, long Y)> Seals((long X, long Y) start, (long X, long Y) goal, long width, long height,
+ Func<long, long, bool> blocked, Func<long, long, bool> soft, int max = 8)
+ {
+ if (Path(start, goal, width, height, blocked) != null)
+ return [];
+ var way = Path(start, goal, width, height, (x, y) => blocked(x, y) && !soft(x, y));
+ return way == null ? [] : way.Where(c => soft(c.X, c.Y)).Take(max).ToList();
+ }
+
+ /// <summary>Portal hops: breadth-first over portal exits from <paramref name="start"/>; the portals to take, in order, so
+ /// that <paramref name="reaches"/> (a walk inside one area) gets to <paramref name="goal"/>; empty when it walks there;
+ /// null when no chain does.</summary>
+ public static List<TPortal>? Hops<TPos, TPortal>(TPos start, TPos goal, IReadOnlyList<TPortal> portals, Func<TPortal, TPos> at,
+ Func<TPortal, TPos> exit, Func<TPos, TPos, bool> reaches) where TPos : notnull
+ {
+ var via = new Dictionary<TPos, (TPos From, TPortal? Portal)> { [start] = (start, default) };
+ var queue = new Queue<TPos>([start]);
+ while (queue.Count > 0)
+ {
+ var here = queue.Dequeue();
+ if (reaches(here, goal))
+ {
+ var hops = new List<TPortal>();
+ for (var p = here; !EqualityComparer<TPos>.Default.Equals(p, start); p = via[p].From)
+ hops.Add(via[p].Portal!);
+ hops.Reverse();
+ return hops;
+ }
+ foreach (var portal in portals)
+ if (!via.ContainsKey(exit(portal)) && reaches(here, at(portal)))
+ {
+ via[exit(portal)] = (here, portal);
+ queue.Enqueue(exit(portal));
+ }
+ }
+ return null;
+ }
+
+ private static List<(long X, long Y)> Trace(Dictionary<(long X, long Y), (long X, long Y)> from, (long X, long Y) start, (long X, long Y) goal)
+ {
+ var path = new List<(long X, long Y)>();
+ for (var c = goal; c != start; c = from[c])
+ path.Add(c);
+ path.Reverse();
+ return path;
+ }
+}
diff --git a/src/Godosa.Core/Runs/RunFlags.cs b/src/Godosa.Core/Runs/RunFlags.cs
new file mode 100644
index 0000000..1eca433
--- /dev/null
+++ b/src/Godosa.Core/Runs/RunFlags.cs
@@ -0,0 +1,39 @@
+using System.Globalization;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>Shared run flags: <c>--deterministic [seed]</c> (fixed step, seeded streams), <c>--digest [every=N]</c>
+/// (state hash lines), <c>--headless</c> (no window; implies deterministic). <see cref="Seed"/> null = the game's default.</summary>
+public sealed record RunFlags(bool Deterministic = false, ulong? Seed = null, bool Digest = false, int DigestEvery = 0, bool Headless = false)
+{
+ public ulong SeedOr(ulong fallback) => Seed ?? fallback;
+
+ /// <summary>Consumes the run flag at <paramref name="i"/> (and its optional value, advancing <paramref name="i"/>);
+ /// false when <c>args[i]</c> is no run flag.</summary>
+ public static bool TryTake(IReadOnlyList<string> args, ref int i, ref RunFlags flags)
+ {
+ string? next = i + 1 < args.Count ? args[i + 1] : null;
+ switch (args[i])
+ {
+ case "--deterministic":
+ flags = flags with { Deterministic = true };
+ if (next != null && ulong.TryParse(next, NumberStyles.None, CultureInfo.InvariantCulture, out var seed))
+ (flags, i) = (flags with { Seed = seed }, i + 1);
+ return true;
+ case "--digest":
+ flags = flags with { Digest = true };
+ if (next != null && next.StartsWith("every=", StringComparison.Ordinal))
+ {
+ if (!int.TryParse(next.AsSpan(6), NumberStyles.None, CultureInfo.InvariantCulture, out var every) || every < 1)
+ throw new ArgumentException($"--digest {next}: every=N needs N ≥ 1");
+ (flags, i) = (flags with { DigestEvery = every }, i + 1);
+ }
+ return true;
+ case "--headless":
+ flags = flags with { Headless = true, Deterministic = true };
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs
new file mode 100644
index 0000000..1e1dd3a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs
@@ -0,0 +1,112 @@
+// Provenance: own design — shared run scripts (spec §5.4)
+using System.Buffers.Binary;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record LabelState(string Name, long Start, string File, int Line);
+
+public sealed record RunnerState(long Frame, ulong Digest, List<ScopeState> Scopes, List<LabelState> Labels);
+
+public sealed record CachedCheckpoint(RunnerState State, byte[] Game);
+
+public static class CheckpointKeys
+{
+ public const string RunnerVersion = "run-scripts 1";
+
+ public static IReadOnlyDictionary<int, string> Compute(ScriptProgram p, string contentVersion, ulong seed)
+ {
+ var d = new StateDigest();
+ d.Add(RunnerVersion);
+ d.Add(contentVersion);
+ d.Add(seed);
+ d.Add((int)p.Mode);
+ foreach (var (k, v) in p.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ d.Add($"{k}={v}");
+ var keys = new Dictionary<int, string>();
+ for (var i = 0; i < p.Code.Count; i++)
+ {
+ var instr = p.Code[i];
+ d.Add((int)instr.Op);
+ d.Add(instr.Format);
+ d.Add(instr.At.File);
+ d.Add(instr.Text);
+ if (instr.Op == Op.Checkpoint)
+ keys[i] = d.Hex;
+ }
+ return keys;
+ }
+}
+
+public sealed class CheckpointCache(string dir, long capBytes)
+{
+ private static readonly byte[] Magic = "GRS1"u8.ToArray();
+ private static readonly JsonSerializerOptions Json = new() { Converters = { new JsonStringEnumConverter() } };
+
+ public CachedCheckpoint? Load(string key)
+ {
+ var path = Path.Combine(dir, key + ".snap");
+ if (!File.Exists(path))
+ return null;
+ byte[] bytes;
+ try
+ {
+ bytes = File.ReadAllBytes(path);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ if (bytes.Length < 8 || !bytes.AsSpan(0, 4).SequenceEqual(Magic))
+ return null;
+ var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4));
+ if (length < 0 || 8 + length > bytes.Length)
+ return null;
+ RunnerState? state;
+ try
+ {
+ state = JsonSerializer.Deserialize<RunnerState>(Encoding.UTF8.GetString(bytes, 8, length), Json);
+ }
+ catch (JsonException)
+ {
+ return null; // a corrupt entry is a miss
+ }
+ return state == null ? null : new CachedCheckpoint(state, bytes[(8 + length)..]);
+ }
+
+ public void Save(string key, CachedCheckpoint cp)
+ {
+ Directory.CreateDirectory(dir);
+ var json = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cp.State, Json));
+ var path = Path.Combine(dir, key + ".snap");
+ var tmp = path + ".tmp";
+ using (var f = File.Create(tmp))
+ {
+ f.Write(Magic);
+ Span<byte> len = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(len, json.Length);
+ f.Write(len);
+ f.Write(json);
+ f.Write(cp.Game);
+ }
+ File.Move(tmp, path, overwrite: true);
+ Evict(path);
+ }
+
+ private void Evict(string keep)
+ {
+ var files = new DirectoryInfo(dir).GetFiles("*.snap").OrderBy(f => f.LastWriteTimeUtc).ToList();
+ var total = files.Sum(f => f.Length);
+ foreach (var f in files)
+ {
+ if (total <= capBytes)
+ break;
+ if (f.FullName == Path.GetFullPath(keep))
+ continue;
+ total -= f.Length;
+ f.Delete();
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Condition.cs b/src/Godosa.Core/Runs/Scripts/Condition.cs
new file mode 100644
index 0000000..346ee3b
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Condition.cs
@@ -0,0 +1,234 @@
+// Provenance: own design — shared run scripts (spec §3.6)
+using System.Globalization;
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public interface IConditionContext
+{
+ bool HasProbe(string name);
+ RunValue Probe(string name, IReadOnlyList<string> args);
+ long? Constant(string name);
+}
+
+/// <summary>Condition AST: or / and / not / comparison / atom. A bare identifier is a probe without arguments when the
+/// game has one by that name, else a game constant.</summary>
+public abstract record Cond
+{
+ public abstract RunValue Eval(IConditionContext c);
+
+ public bool Test(IConditionContext c) => Truthy(Eval(c));
+
+ public abstract IEnumerable<(string Name, IReadOnlyList<string> Args)> Probes();
+
+ public abstract IEnumerable<string> Bare();
+
+ public static string Describe(Cond cond, IConditionContext c) =>
+ string.Join(" ", cond.Probes().Where(p => c.HasProbe(p.Name)).Distinct().Select(p =>
+ {
+ var label = p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})";
+ return $"{label}={c.Probe(p.Name, p.Args)}";
+ }));
+
+ internal static bool Truthy(RunValue v) => v.IsNumber ? v.Number != 0 : !string.IsNullOrEmpty(v.Text);
+}
+
+public sealed record OrCond(Cond A, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) || B.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record AndCond(Cond A, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(A.Test(c) && B.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record NotCond(Cond A) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => RunValue.Of(!A.Test(c));
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes();
+ public override IEnumerable<string> Bare() => A.Bare();
+}
+
+public sealed record CompareCond(Cond A, string Op, Cond B) : Cond
+{
+ public override RunValue Eval(IConditionContext c)
+ {
+ var (a, b) = (A.Eval(c), B.Eval(c));
+ if (a.IsNumber != b.IsNumber)
+ throw new ScriptFault($"cannot compare {a} with {b}");
+ var order = a.IsNumber ? a.Number!.Value.CompareTo(b.Number!.Value) : string.CompareOrdinal(a.Text, b.Text);
+ if (!a.IsNumber && Op is "<" or "<=" or ">" or ">=")
+ throw new ScriptFault($"cannot order strings {a} and {b}");
+ return RunValue.Of(Op switch
+ {
+ "=" => order == 0, "!=" => order != 0, "<" => order < 0, "<=" => order <= 0, ">" => order > 0, _ => order >= 0,
+ });
+ }
+
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> Bare() => A.Bare().Concat(B.Bare());
+}
+
+public sealed record LiteralCond(RunValue Value) : Cond
+{
+ public override RunValue Eval(IConditionContext c) => Value;
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => [];
+ public override IEnumerable<string> Bare() => [];
+}
+
+/// <summary>A probe call <c>name(args)</c>, or a bare <c>name</c> (probe if the game has it, else a constant).</summary>
+public sealed record NameCond(string Name, IReadOnlyList<string> Args, bool HasParens) : Cond
+{
+ public override RunValue Eval(IConditionContext c)
+ {
+ if (HasParens || c.HasProbe(Name))
+ return c.Probe(Name, Args);
+ return c.Constant(Name) is { } n ? RunValue.Of(n) : throw new ScriptFault($"unknown probe or constant {Name}");
+ }
+
+ public override IEnumerable<(string, IReadOnlyList<string>)> Probes() => [(Name, Args)];
+ public override IEnumerable<string> Bare() => HasParens ? [] : [Name];
+}
+
+public static class ConditionParser
+{
+ public static Cond Parse(string text)
+ {
+ var tokens = Lex(text);
+ var pos = 0;
+ var cond = Or(tokens, ref pos);
+ if (pos != tokens.Count)
+ throw new FormatException($"unexpected '{tokens[pos]}' in condition '{text}'");
+ return cond;
+ }
+
+ private static Cond Or(List<string> t, ref int pos)
+ {
+ var a = And(t, ref pos);
+ while (pos < t.Count && t[pos] == "or")
+ {
+ pos++;
+ a = new OrCond(a, And(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond And(List<string> t, ref int pos)
+ {
+ var a = Not(t, ref pos);
+ while (pos < t.Count && t[pos] == "and")
+ {
+ pos++;
+ a = new AndCond(a, Not(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond Not(List<string> t, ref int pos)
+ {
+ if (pos < t.Count && t[pos] == "not")
+ {
+ pos++;
+ return new NotCond(Not(t, ref pos));
+ }
+ return Compare(t, ref pos);
+ }
+
+ private static Cond Compare(List<string> t, ref int pos)
+ {
+ var a = Atom(t, ref pos);
+ if (pos < t.Count && t[pos] is "=" or "!=" or "<" or "<=" or ">" or ">=")
+ {
+ var op = t[pos++];
+ return new CompareCond(a, op, Atom(t, ref pos));
+ }
+ return a;
+ }
+
+ private static Cond Atom(List<string> t, ref int pos)
+ {
+ if (pos >= t.Count)
+ throw new FormatException("condition ends early");
+ var tok = t[pos++];
+ if (tok == "(")
+ {
+ var inner = Or(t, ref pos);
+ if (pos >= t.Count || t[pos] != ")")
+ throw new FormatException("missing ')'");
+ pos++;
+ return inner;
+ }
+ if (tok.StartsWith('"'))
+ return new LiteralCond(RunValue.Of(tok[1..^1]));
+ if (long.TryParse(tok, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n))
+ return new LiteralCond(RunValue.Of(n));
+ if (!char.IsLetter(tok[0]) && tok[0] != '_')
+ throw new FormatException($"unexpected '{tok}'");
+ var paren = tok.IndexOf('(');
+ if (paren < 0)
+ return new NameCond(tok, [], false);
+ var args = tok[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray();
+ return new NameCond(tok[..paren], args, true);
+ }
+
+ private static List<string> Lex(string text)
+ {
+ var tokens = new List<string>();
+ var i = 0;
+ while (i < text.Length)
+ {
+ var c = text[i];
+ if (c == ' ')
+ {
+ i++;
+ continue;
+ }
+ if (c is '(' or ')')
+ {
+ tokens.Add(c.ToString());
+ i++;
+ continue;
+ }
+ if (c == '"')
+ {
+ var end = text.IndexOf('"', i + 1);
+ if (end < 0)
+ throw new FormatException("unterminated quote");
+ tokens.Add(text[i..(end + 1)]);
+ i = end + 1;
+ continue;
+ }
+ if (c is '=' or '!' or '<' or '>')
+ {
+ var two = i + 1 < text.Length && text[i + 1] == '=';
+ var op = text.Substring(i, two ? 2 : 1);
+ if (op == "!")
+ throw new FormatException("use != or not");
+ tokens.Add(op);
+ i += op.Length;
+ continue;
+ }
+ var sb = new StringBuilder();
+ var depth = 0;
+ while (i < text.Length && (depth > 0 || (text[i] != ' ' && text[i] != ')' && !(text[i] is '=' or '!' or '<' or '>'))))
+ {
+ if (text[i] == '(' && sb.Length == 0)
+ break;
+ if (text[i] == '(')
+ depth++;
+ else if (text[i] == ')')
+ depth--;
+ sb.Append(text[i++]);
+ }
+ if (sb.Length == 0)
+ throw new FormatException($"unexpected '{text[i]}'");
+ tokens.Add(sb.ToString());
+ }
+ return tokens;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Input.cs b/src/Godosa.Core/Runs/Scripts/Input.cs
new file mode 100644
index 0000000..d549bb4
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Input.cs
@@ -0,0 +1,24 @@
+// Provenance: own design — shared run scripts (spec §5.5)
+namespace Godosa.Core.Runs.Scripts;
+
+public enum InputKind { Move, Down, Up, Wheel, KeyDown, KeyUp, Text }
+
+public enum MouseButton { Left, Right }
+
+/// <summary>One simulated user input event at virtual-screen coordinates.</summary>
+public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "",
+ string Text = "", int Delta = 0);
+
+/// <summary>An event due <see cref="Frame"/> frames after its step began.</summary>
+public sealed record PlannedInput(int Frame, InputEvent Event);
+
+public sealed record InputPlan(IReadOnlyList<PlannedInput> Events)
+{
+ public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame);
+}
+
+/// <summary>The game's input entry: the real UI in a window, its layout + hit-testing headless.</summary>
+public interface IInputRouter
+{
+ void Send(InputEvent e);
+}
diff --git a/src/Godosa.Core/Runs/Scripts/InputPlans.cs b/src/Godosa.Core/Runs/Scripts/InputPlans.cs
new file mode 100644
index 0000000..9c150b3
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/InputPlans.cs
@@ -0,0 +1,111 @@
+// Provenance: own design — shared run scripts (spec §4.1)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public static class Positions
+{
+ /// <summary><c>x,y</c> or a locator <c>@name(args)</c> (args comma separated).</summary>
+ public static (int X, int Y) Resolve(string token, IRunGame game)
+ {
+ if (token.StartsWith('@'))
+ {
+ var paren = token.IndexOf('(');
+ var name = paren > 0 ? token[1..paren] : token[1..];
+ var args = paren > 0 && token.EndsWith(')')
+ ? token[(paren + 1)..^1].Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray()
+ : [];
+ if (!game.Locators.TryGetValue(name, out var locator))
+ throw new ScriptFault($"unknown locator @{name}");
+ var r = locator.Locate(args);
+ return r.IsOk ? (r.X, r.Y) : throw new ScriptFault($"locator {token}: {r.Error}");
+ }
+ var parts = token.Split(',');
+ if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x)
+ && int.TryParse(parts[1], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var y))
+ return (x, y);
+ throw new ScriptFault($"bad position '{token}'");
+ }
+}
+
+public static class InputPlans
+{
+ public static bool IsInputVerb(string verb) => verb is "click" or "rclick" or "dclick" or "drag" or "move" or "wheel"
+ or "key" or "hold" or "chord" or "type";
+
+ public static InputPlan For(string verb, IReadOnlyList<Token> args, Func<string, (int X, int Y)> position)
+ {
+ List<PlannedInput> e = [];
+ void Add(int frame, InputEvent ev) => e.Add(new PlannedInput(frame, ev));
+ switch (verb)
+ {
+ case "click" or "rclick" or "dclick":
+ {
+ var (x, y) = position(args[0].Text);
+ var button = verb == "rclick" ? MouseButton.Right : MouseButton.Left;
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ Add(0, new InputEvent(InputKind.Down, x, y, button));
+ Add(1, new InputEvent(InputKind.Up, x, y, button));
+ if (verb == "dclick")
+ {
+ Add(2, new InputEvent(InputKind.Down, x, y, button));
+ Add(3, new InputEvent(InputKind.Up, x, y, button));
+ }
+ break;
+ }
+ case "drag":
+ {
+ var (ax, ay) = position(args[0].Text);
+ var (bx, by) = position(args[1].Text);
+ Add(0, new InputEvent(InputKind.Move, ax, ay));
+ Add(0, new InputEvent(InputKind.Down, ax, ay));
+ for (var step = 1; step <= 3; step++)
+ Add(step, new InputEvent(InputKind.Move, ax + (bx - ax) * step / 3, ay + (by - ay) * step / 3));
+ Add(4, new InputEvent(InputKind.Up, bx, by));
+ break;
+ }
+ case "move":
+ {
+ var (x, y) = position(args[0].Text);
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ break;
+ }
+ case "wheel":
+ {
+ var delta = int.TryParse(args[0].Text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var d)
+ ? d : throw new ScriptFault($"bad wheel delta '{args[0].Text}'");
+ var (x, y) = args.Count > 1 ? position(args[1].Text) : (0, 0);
+ if (args.Count > 1)
+ Add(0, new InputEvent(InputKind.Move, x, y));
+ Add(0, new InputEvent(InputKind.Wheel, x, y, Delta: delta));
+ break;
+ }
+ case "key":
+ Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text));
+ Add(1, new InputEvent(InputKind.KeyUp, Key: args[0].Text));
+ break;
+ case "hold":
+ {
+ var frames = Durations.Frames(args[1].Text) is { } f and > 0 ? f : throw new ScriptFault($"bad hold frames '{args[1].Text}'");
+ Add(0, new InputEvent(InputKind.KeyDown, Key: args[0].Text));
+ Add(frames, new InputEvent(InputKind.KeyUp, Key: args[0].Text));
+ break;
+ }
+ case "chord":
+ {
+ var keys = args[0].Text.Split('+', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var k in keys)
+ Add(0, new InputEvent(InputKind.KeyDown, Key: k));
+ foreach (var k in keys.Reverse())
+ Add(1, new InputEvent(InputKind.KeyUp, Key: k));
+ break;
+ }
+ case "type":
+ Add(0, new InputEvent(InputKind.Text, Text: args[0].Text));
+ break;
+ default:
+ throw new ScriptFault($"not an input verb: {verb}");
+ }
+ return new InputPlan(e);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/LibVerbs.cs b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs
new file mode 100644
index 0000000..5659b0c
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/LibVerbs.cs
@@ -0,0 +1,32 @@
+// Provenance: own design — shared run scripts (spec §4.1, §4.2)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>A verb the lib defines. <see cref="GameHook"/> = implemented by the game's <see cref="IRunGame.Actions"/>
+/// entry of the same name; otherwise the runner implements it.</summary>
+public sealed record VerbSpec(string Name, VerbKind Kind, int MinArgs, int MaxArgs, bool GameHook);
+
+public static class LibVerbs
+{
+ private const VerbKind P = VerbKind.Player, D = VerbKind.Debug;
+
+ public static IReadOnlyList<VerbSpec> All { get; } =
+ [
+ new("click", P, 1, 1, false), new("rclick", P, 1, 1, false), new("dclick", P, 1, 1, false),
+ new("drag", P, 2, 2, false), new("move", P, 1, 1, false), new("wheel", P, 1, 2, false),
+ new("key", P, 1, 1, false), new("hold", P, 2, 2, false), new("chord", P, 1, 1, false), new("type", P, 1, 1, false),
+ new("wait-frames", P, 1, 1, false), new("rng", P, 2, 3, false), new("log", P, 1, 1, false), new("output", P, 1, 1, false),
+ new("screenshot", P, 1, 1, true), new("save", P, 1, 1, true), new("load", P, 1, 1, true),
+ new("walk-to", P, 1, 1, true), new("talk", P, 1, 1, true), new("answer", P, 1, 1, true), new("attack", P, 1, 2, true),
+ new("use", P, 1, 3, true), new("equip", P, 1, 1, true), new("drop", P, 1, 1, true), new("take", P, 1, 1, true),
+ new("open-panel", P, 1, 1, true), new("close-panel", P, 0, 0, true), new("travel-to", P, 1, 1, true),
+ new("rest", P, 1, 1, true), new("buy", P, 3, 3, true), new("sell", P, 3, 3, true),
+ new("cheat", D, 1, 20, true), new("option", D, 2, 2, true), new("teleport", D, 2, 2, true),
+ new("talk-direct", D, 1, 1, true), new("kill", D, 1, 1, true), new("give", D, 1, 2, true), new("set-state", D, 2, 2, true),
+ ];
+
+ private static readonly Dictionary<string, VerbSpec> ByName = All.ToDictionary(v => v.Name, StringComparer.Ordinal);
+
+ public static VerbSpec? Find(string name) => ByName.GetValueOrDefault(name);
+
+ public static readonly string[] OutputLevels = ["quiet", "checkpoints", "labels", "steps", "trace"];
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunGame.cs b/src/Godosa.Core/Runs/Scripts/RunGame.cs
new file mode 100644
index 0000000..a6222a6
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunGame.cs
@@ -0,0 +1,68 @@
+// Provenance: own design — shared run scripts (spec §6; one hook shape for §4.2 capabilities, see Actions)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>A game's run-script adapter. <see cref="Actions"/> holds the lib's game-hooked verbs by their lib name
+/// (<c>talk</c>, <c>walk-to</c>, <c>cheat</c>, …) and the game's own verbs. Player-kind actions return input plans only;
+/// the lib injects them.</summary>
+public interface IRunGame
+{
+ string Name { get; }
+ string ContentVersion { get; }
+ IInputRouter Input { get; }
+ IReadOnlyDictionary<string, IProbe> Probes { get; }
+ IReadOnlyDictionary<string, long> Constants { get; }
+ IReadOnlyDictionary<string, ILocator> Locators { get; }
+ IReadOnlyDictionary<string, IRunAction> Actions { get; }
+ IRunRng? Rng { get; }
+ bool SupportsSnapshot { get; }
+ byte[] SnapshotSave();
+ /// <summary>Why the current state can't be checkpointed faithfully (e.g. state the snapshot doesn't hold), or null.
+ /// A refused checkpoint is not cached (warning); failure snapshots are still taken.</summary>
+ string? SnapshotRefusal() => null;
+ void SnapshotRestore(byte[] data);
+ /// <summary>The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame
+ /// (<see cref="ScriptRunner.Restoring"/>), in which the host advances no game frame.</summary>
+ bool RestoreDeferred => false;
+ ulong Digest();
+ string Dump();
+ void Advance();
+}
+
+public interface IProbe
+{
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ RunValue Eval(IReadOnlyList<string> args);
+}
+
+public readonly record struct LocatorResult(int X, int Y, string? Error)
+{
+ public static LocatorResult Ok(int x, int y) => new(x, y, null);
+ public static LocatorResult Fail(string message) => new(0, 0, message);
+ public bool IsOk => Error == null;
+}
+
+/// <summary>Finds where to aim (virtual-screen position) from game state, as a player reads the screen.</summary>
+public interface ILocator
+{
+ LocatorResult Locate(IReadOnlyList<string> args);
+}
+
+/// <summary>Begin returns an input plan to inject (player) and/or an immediate status (debug: usually Done); after the
+/// plan is sent, Poll runs each frame until Done or Fault.</summary>
+public interface IRunAction
+{
+ VerbKind Kind { get; }
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ ActionStart Begin(IReadOnlyList<string> args);
+ StepStatus Poll();
+}
+
+public sealed record ActionStart(InputPlan? Plan = null, StepStatus? Status = null);
+
+public interface IRunRng
+{
+ void Seed(long seed);
+ void ForceNext(string kind, long value);
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunOutput.cs b/src/Godosa.Core/Runs/Scripts/RunOutput.cs
new file mode 100644
index 0000000..4c75a6a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunOutput.cs
@@ -0,0 +1,21 @@
+// Provenance: own design — shared run scripts (spec §7.1)
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Auto-output filter: a line of <c>level</c> prints when <c>level ≤ max(current, floor)</c>; <see cref="Always"/>
+/// = log lines and failures.</summary>
+public sealed class RunOutput(Action<string> write, OutputLevel floor)
+{
+ public void Emit(OutputLevel level, OutputLevel current, string line)
+ {
+ if (level <= (current > floor ? current : floor))
+ write(line);
+ }
+
+ public void Always(string line) => write(line);
+
+ public static OutputLevel Parse(string name) => name switch
+ {
+ "quiet" => OutputLevel.Quiet, "checkpoints" => OutputLevel.Checkpoints, "labels" => OutputLevel.Labels,
+ "steps" => OutputLevel.Steps, "trace" => OutputLevel.Trace, _ => throw new ScriptFault($"bad output level '{name}'"),
+ };
+}
diff --git a/src/Godosa.Core/Runs/Scripts/RunReport.cs b/src/Godosa.Core/Runs/Scripts/RunReport.cs
new file mode 100644
index 0000000..f0ecf0d
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/RunReport.cs
@@ -0,0 +1,58 @@
+// Provenance: own design — shared run scripts (spec §7.2, §7.3)
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record CheckpointRecord(string Name, long Frame, string Digest, string Source);
+
+public sealed record FailureInfo(string At, IReadOnlyList<string> Chain, string Step, string? Label, string Reason,
+ IReadOnlyList<string> Recent, string Dump, IReadOnlyDictionary<string, string> Vars, string? Snapshot);
+
+public sealed record RunReport(string Script, RunMode Mode, ulong Seed, bool Deterministic, RunOutcome Outcome, long Frames,
+ int Steps, double WallSeconds, int CacheHits, int CacheMisses, IReadOnlyList<CheckpointRecord> Checkpoints,
+ IReadOnlyList<string> Warnings, FailureInfo? Failure);
+
+public static class ReportWriter
+{
+ private static readonly JsonSerializerOptions Json = new() { WriteIndented = true, Converters = { new JsonStringEnumConverter() } };
+
+ public static string Text(RunReport r)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"script: {r.Script} mode: {r.Mode.ToString().ToLowerInvariant()} seed: {r.Seed} deterministic: {(r.Deterministic ? "yes" : "no")}");
+ sb.AppendLine($"outcome: {r.Outcome} frames: {r.Frames} steps: {r.Steps} wall: {r.WallSeconds:0.0} s cache: {r.CacheHits} hit / {r.CacheMisses} miss");
+ foreach (var c in r.Checkpoints)
+ sb.AppendLine($"checkpoint {c.Name} f={c.Frame} {c.Digest} ({c.Source})");
+ foreach (var w in r.Warnings)
+ sb.AppendLine($"warning: {w}");
+ if (r.Failure is { } f)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"FAILED at {string.Join(" → ", f.Chain.Append(f.At))}{(f.Label == null ? "" : $" [{f.Label}]")}");
+ sb.AppendLine($" step: {f.Step}");
+ sb.AppendLine($" reason: {f.Reason}");
+ sb.AppendLine("recent steps:");
+ foreach (var s in f.Recent)
+ sb.AppendLine($" {s}");
+ sb.AppendLine("variables:");
+ foreach (var (k, v) in f.Vars.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ sb.AppendLine($" {k} = {v}");
+ sb.AppendLine("game state:");
+ sb.AppendLine(f.Dump);
+ if (f.Snapshot != null)
+ sb.AppendLine($"failure save: {f.Snapshot}");
+ }
+ return sb.ToString();
+ }
+
+ public static void Write(string dir, RunReport report, byte[]? failSnapshot)
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "report.txt"), Text(report));
+ File.WriteAllText(Path.Combine(dir, "report.json"), JsonSerializer.Serialize(report, Json));
+ if (failSnapshot != null)
+ File.WriteAllBytes(Path.Combine(dir, "fail.snap"), failSnapshot);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptAst.cs b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs
new file mode 100644
index 0000000..9fa2654
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptAst.cs
@@ -0,0 +1,25 @@
+// Provenance: own design — shared run scripts
+namespace Godosa.Core.Runs.Scripts;
+
+public enum BlockKind { Setup, Step, Try }
+
+/// <summary>One parsed line (or block header) of a <c>.steps</c> file; <see cref="Text"/> = the trimmed source line.</summary>
+public abstract record Node(SourceRef At, string Text);
+public sealed record VerbNode(SourceRef At, string Text, string Verb, string Args) : Node(At, Text);
+public sealed record WaitNode(SourceRef At, string Text, string Condition, int Timeout) : Node(At, Text);
+public sealed record ExpectNode(SourceRef At, string Text, string Condition) : Node(At, Text);
+public sealed record SetNode(SourceRef At, string Text, string Name, string Value) : Node(At, Text);
+public sealed record IncludeNode(SourceRef At, string Text, string Path) : Node(At, Text);
+public sealed record CallNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Args) : Node(At, Text);
+public sealed record CheckpointNode(SourceRef At, string Text, string Name) : Node(At, Text);
+public sealed record BlockNode(SourceRef At, string Text, BlockKind Kind, string? Label, IReadOnlyList<Node> Body) : Node(At, Text);
+public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Params, IReadOnlyList<Node> Body) : Node(At, Text);
+
+public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList<string> VarFiles,
+ IReadOnlyList<Node> Body, IReadOnlyList<ScriptError> Errors);
+
+public static class ScriptFormat
+{
+ /// <summary>Newest format version this runner knows; every older one stays supported.</summary>
+ public const int Latest = 1;
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
new file mode 100644
index 0000000..d123539
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
@@ -0,0 +1,218 @@
+// Provenance: own design — shared run scripts (spec §3.4, §3.7 structural checks)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptLoader
+{
+ private sealed class State(IScriptSource source)
+ {
+ public IScriptSource Source { get; } = source;
+ public List<Instr> Code { get; } = [];
+ public List<ScriptError> Errors { get; } = [];
+ public Dictionary<string, (DefNode Def, string File, int Format)> Defs { get; } = new(StringComparer.Ordinal);
+ public List<string> Includes { get; } = [];
+ public List<string> Calls { get; } = [];
+ public List<SourceRef> Chain { get; } = [];
+ public HashSet<string> Checkpoints { get; } = new(StringComparer.Ordinal);
+ public List<HashSet<string>> Known { get; } = [];
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public HashSet<string> Locked { get; } = new(StringComparer.Ordinal);
+ public int TryDepth { get; set; }
+ }
+
+ public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary<string, string> cmdVars)
+ {
+ var s = new State(source);
+ foreach (var (k, v) in cmdVars)
+ {
+ s.Vars[k] = v;
+ s.Locked.Add(k);
+ }
+ s.Known.Add([.. cmdVars.Keys]);
+ var text = source.Read(entry);
+ if (text == null)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), $"script not found: {entry}"));
+ return new ScriptProgram(entry, RunMode.Debug, null, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+ var file = ScriptParser.Parse(entry, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode == null)
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), "top-level file needs 'mode'"));
+ LoadVarFiles(s, file);
+ s.Includes.Add(entry);
+ for (var i = 0; i < file.Body.Count; i++)
+ {
+ if (file.Body[i] is BlockNode { Kind: BlockKind.Setup } setup && i > 0)
+ s.Errors.Add(new ScriptError(setup.At, "setup must be the first block"));
+ }
+ Emit(s, file.Body, entry, file.Format, top: true);
+ return new ScriptProgram(entry, file.Mode ?? RunMode.Debug, file.Seed, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+
+ private static void LoadVarFiles(State s, ScriptFile file)
+ {
+ foreach (var raw in file.VarFiles)
+ {
+ string path;
+ try
+ {
+ path = ScriptPaths.Resolve(file.Path, VarExpander.Expand(raw, n => s.Vars.GetValueOrDefault(n), null));
+ }
+ catch (ScriptFault e)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), e.Message));
+ continue;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), $"vars file not found: {path}"));
+ continue;
+ }
+ var (values, errors) = VarsFile.Parse(path, text);
+ s.Errors.AddRange(errors);
+ foreach (var (k, v) in values)
+ {
+ if (!s.Locked.Contains(k))
+ s.Vars[k] = v;
+ s.Known[0].Add(k);
+ }
+ }
+ }
+
+ private static void Emit(State s, IReadOnlyList<Node> nodes, string file, int format, bool top)
+ {
+ foreach (var node in nodes)
+ {
+ Instr I(Op op) => new(op, node.At, node.Text, [.. s.Chain], format);
+ switch (node)
+ {
+ case VerbNode v:
+ CheckVars(s, v.At, v.Args);
+ s.Code.Add(I(Op.Verb) with { A = v.Verb, B = v.Args });
+ break;
+ case WaitNode w:
+ CheckVars(s, w.At, w.Condition);
+ s.Code.Add(I(Op.Wait) with { A = w.Condition, Timeout = w.Timeout });
+ break;
+ case ExpectNode e:
+ CheckVars(s, e.At, e.Condition);
+ s.Code.Add(I(Op.Expect) with { A = e.Condition });
+ break;
+ case SetNode set:
+ CheckVars(s, set.At, set.Value);
+ if (s.Locked.Contains(set.Name))
+ s.Errors.Add(new ScriptError(set.At, $"variable {set.Name} is set on the command line"));
+ s.Known[^1].Add(set.Name);
+ if (s.Calls.Count == 0)
+ s.Known[0].Add(set.Name);
+ s.Code.Add(I(Op.Set) with { A = set.Name, B = set.Value });
+ break;
+ case CheckpointNode cp:
+ if (s.TryDepth > 0)
+ s.Errors.Add(new ScriptError(cp.At, "checkpoint inside try"));
+ if (!s.Checkpoints.Add(cp.Name))
+ s.Errors.Add(new ScriptError(cp.At, $"duplicate checkpoint {cp.Name}"));
+ s.Code.Add(I(Op.Checkpoint) with { A = cp.Name });
+ break;
+ case BlockNode { Kind: BlockKind.Setup } b:
+ if (!top)
+ s.Errors.Add(new ScriptError(b.At, "setup only in the top-level file"));
+ s.Code.Add(I(Op.SetupBegin));
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.SetupEnd));
+ break;
+ case BlockNode { Kind: BlockKind.Step } b:
+ s.Code.Add(I(Op.LabelBegin) with { A = b.Label! });
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.LabelEnd) with { A = b.Label! });
+ break;
+ case BlockNode { Kind: BlockKind.Try } b:
+ var begin = s.Code.Count;
+ s.Code.Add(I(Op.TryBegin));
+ s.TryDepth++;
+ Emit(s, b.Body, file, format, false);
+ s.TryDepth--;
+ s.Code[begin] = s.Code[begin] with { Jump = s.Code.Count };
+ s.Code.Add(I(Op.TryEnd));
+ break;
+ case DefNode d:
+ if (!s.Defs.TryAdd(d.Name, (d, file, format)))
+ s.Errors.Add(new ScriptError(d.At, $"def {d.Name} already defined"));
+ break;
+ case CallNode c:
+ Call(s, c, format);
+ break;
+ case IncludeNode inc:
+ Include(s, inc);
+ break;
+ }
+ }
+ }
+
+ private static void Call(State s, CallNode c, int format)
+ {
+ foreach (var arg in c.Args)
+ CheckVars(s, c.At, arg);
+ if (!s.Defs.TryGetValue(c.Name, out var def))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"unknown def {c.Name}"));
+ return;
+ }
+ if (def.Def.Params.Count != c.Args.Count)
+ {
+ s.Errors.Add(new ScriptError(c.At, $"def {c.Name} takes {def.Def.Params.Count} arguments, got {c.Args.Count}"));
+ return;
+ }
+ if (s.Calls.Contains(c.Name))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"recursive call {c.Name}"));
+ return;
+ }
+ var bindings = def.Def.Params.Zip(c.Args, (p, a) => new KeyValuePair<string, string>(p, a)).ToList();
+ s.Code.Add(new Instr(Op.ScopeBegin, c.At, c.Text, [.. s.Chain], format) { Bindings = bindings, LocalVars = true });
+ s.Calls.Add(c.Name);
+ s.Chain.Add(c.At);
+ s.Known.Add([.. def.Def.Params]);
+ Emit(s, def.Def.Body, def.File, def.Format, false);
+ s.Known.RemoveAt(s.Known.Count - 1);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Calls.RemoveAt(s.Calls.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, c.At, c.Text, [.. s.Chain], format));
+ }
+
+ private static void Include(State s, IncludeNode inc)
+ {
+ var path = ScriptPaths.Resolve(inc.At.File, inc.Path);
+ if (s.Includes.Contains(path))
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include cycle: {string.Join(" → ", s.Includes)} → {path}"));
+ return;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include not found: {path}"));
+ return;
+ }
+ var file = ScriptParser.Parse(path, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode != null)
+ s.Errors.Add(new ScriptError(new SourceRef(path, 2), "mode only in the top-level file"));
+ LoadVarFiles(s, file);
+ s.Code.Add(new Instr(Op.ScopeBegin, inc.At, inc.Text, [.. s.Chain], file.Format));
+ s.Includes.Add(path);
+ s.Chain.Add(inc.At);
+ Emit(s, file.Body, path, file.Format, false);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Includes.RemoveAt(s.Includes.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, inc.At, inc.Text, [.. s.Chain], file.Format));
+ }
+
+ private static void CheckVars(State s, SourceRef at, string text)
+ {
+ foreach (var name in VarExpander.Names(text))
+ {
+ if (!s.Known.Any(k => k.Contains(name)))
+ s.Errors.Add(new ScriptError(at, $"unresolved variable ${{{name}}}"));
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptParser.cs b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs
new file mode 100644
index 0000000..f9f964f
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs
@@ -0,0 +1,231 @@
+// Provenance: own design — shared run scripts (spec §3)
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Format v1 parser: one file → <see cref="ScriptFile"/>; every error collected (never throws).</summary>
+public static partial class ScriptParser
+{
+ private sealed record Line(int Number, int Indent, string Text);
+
+ [GeneratedRegex(@"^set\s+([A-Za-z_][\w.\-]*)\s*=\s*(.*)$")]
+ private static partial Regex SetLine();
+
+ [GeneratedRegex(@"^def\s+([A-Za-z_][\w\-]*)\s*\(([^)]*)\)\s*:$")]
+ private static partial Regex DefLine();
+
+ [GeneratedRegex(@"^([A-Za-z_][\w\-]*)\s*(?:\(([^)]*)\))?$")]
+ private static partial Regex CallTarget();
+
+ public static ScriptFile Parse(string path, string text)
+ {
+ var errors = new List<ScriptError>();
+ var lines = new List<Line>();
+ var raw = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < raw.Length; i++)
+ {
+ var at = new SourceRef(path, i + 1);
+ var stripped = StepLexer.StripComment(raw[i]).TrimEnd();
+ if (stripped.Trim().Length == 0)
+ continue;
+ var indent = stripped.Length - stripped.TrimStart(' ', '\t').Length;
+ if (stripped[..indent].Contains('\t'))
+ {
+ errors.Add(new ScriptError(at, "tab in indentation"));
+ continue;
+ }
+ if (indent % 2 != 0)
+ {
+ errors.Add(new ScriptError(at, "indent not a multiple of 2"));
+ continue;
+ }
+ lines.Add(new Line(i + 1, indent / 2, stripped.Trim()));
+ }
+
+ var format = 0;
+ RunMode? mode = null;
+ ulong? seed = null;
+ var varFiles = new List<string>();
+ var pos = 0;
+ if (lines.Count == 0 || !lines[0].Text.StartsWith("format ", StringComparison.Ordinal))
+ errors.Add(new ScriptError(new SourceRef(path, lines.Count > 0 ? lines[0].Number : 1), "first line must be 'format N'"));
+ else
+ {
+ var value = lines[0].Text["format ".Length..].Trim();
+ if (!int.TryParse(value, CultureInfo.InvariantCulture, out format) || format < 1 || format > ScriptFormat.Latest)
+ {
+ errors.Add(new ScriptError(new SourceRef(path, lines[0].Number), $"unknown format {value} (latest {ScriptFormat.Latest})"));
+ format = 0;
+ }
+ pos = 1;
+ }
+ for (; pos < lines.Count && lines[pos].Indent == 0 && IsHeader(lines[pos].Text); pos++)
+ Header(lines[pos], path, errors, ref mode, ref seed, varFiles);
+ var body = Block(lines, ref pos, 0, path, errors);
+ return new ScriptFile(path, format, mode, seed, varFiles, body, errors);
+ }
+
+ private static bool IsHeader(string text) =>
+ text.StartsWith("mode ", StringComparison.Ordinal) || text.StartsWith("seed ", StringComparison.Ordinal)
+ || text.StartsWith("vars ", StringComparison.Ordinal) || text.StartsWith("format ", StringComparison.Ordinal);
+
+ private static void Header(Line line, string path, List<ScriptError> errors, ref RunMode? mode, ref ulong? seed, List<string> varFiles)
+ {
+ var at = new SourceRef(path, line.Number);
+ var value = line.Text[(line.Text.IndexOf(' ') + 1)..].Trim();
+ switch (line.Text[..line.Text.IndexOf(' ')])
+ {
+ case "mode":
+ mode = value switch { "player" => RunMode.Player, "debug" => RunMode.Debug, _ => null };
+ if (mode == null)
+ errors.Add(new ScriptError(at, "mode must be player or debug"));
+ break;
+ case "seed":
+ if (ulong.TryParse(value, CultureInfo.InvariantCulture, out var s))
+ seed = s;
+ else
+ errors.Add(new ScriptError(at, $"bad seed '{value}'"));
+ break;
+ case "vars":
+ varFiles.Add(value);
+ break;
+ default:
+ errors.Add(new ScriptError(at, "format must be the first line only"));
+ break;
+ }
+ }
+
+ private static List<Node> Block(List<Line> lines, ref int pos, int indent, string path, List<ScriptError> errors)
+ {
+ var nodes = new List<Node>();
+ while (pos < lines.Count)
+ {
+ var line = lines[pos];
+ if (line.Indent < indent)
+ break;
+ var at = new SourceRef(path, line.Number);
+ if (line.Indent > indent)
+ {
+ errors.Add(new ScriptError(at, "unexpected indent"));
+ pos++;
+ continue;
+ }
+ pos++;
+ if (indent == 0 && IsHeader(line.Text))
+ {
+ errors.Add(new ScriptError(at, "header line after steps"));
+ continue;
+ }
+ var hasBody = pos < lines.Count && lines[pos].Indent == indent + 1;
+ if (line.Text.EndsWith(':') && (hasBody || IsBlockKeyword(line.Text)))
+ {
+ var body = hasBody ? Block(lines, ref pos, indent + 1, path, errors) : [];
+ if (!hasBody)
+ errors.Add(new ScriptError(at, "empty block"));
+ if (BlockHeader(line.Text, at, body, errors) is { } node)
+ nodes.Add(node);
+ continue;
+ }
+ if (Statement(line.Text, at, errors) is { } statement)
+ nodes.Add(statement);
+ }
+ return nodes;
+ }
+
+ /// <summary>A line ending in ':' is a block header when it has an indented body or starts with a block keyword;
+ /// otherwise a statement (<c>log Done:</c>).</summary>
+ private static bool IsBlockKeyword(string text) =>
+ text is "setup:" or "try:" || text.StartsWith("step ", StringComparison.Ordinal) || text.StartsWith("def ", StringComparison.Ordinal);
+
+ private static Node? BlockHeader(string text, SourceRef at, List<Node> body, List<ScriptError> errors)
+ {
+ if (text == "setup:")
+ return new BlockNode(at, text, BlockKind.Setup, null, body);
+ if (text == "try:")
+ return new BlockNode(at, text, BlockKind.Try, null, body);
+ if (text.StartsWith("step ", StringComparison.Ordinal) && text.Length > "step :".Length)
+ return new BlockNode(at, text, BlockKind.Step, text["step ".Length..^1].Trim(), body);
+ if (DefLine().Match(text) is { Success: true } def)
+ {
+ var parameters = def.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ return new DefNode(at, text, def.Groups[1].Value, parameters, body);
+ }
+ errors.Add(new ScriptError(at, $"unknown block '{text}'"));
+ return null;
+ }
+
+ private static Node? Statement(string text, SourceRef at, List<ScriptError> errors)
+ {
+ var space = text.IndexOf(' ');
+ var head = space < 0 ? text : text[..space];
+ var rest = space < 0 ? "" : text[(space + 1)..].Trim();
+ try
+ {
+ // Validate quoting once for every line (log "abc → unterminated quote).
+ StepLexer.Tokens(rest);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(at, e.Message));
+ return null;
+ }
+ switch (head)
+ {
+ case "set":
+ if (SetLine().Match(text) is not { Success: true } set)
+ {
+ errors.Add(new ScriptError(at, "bad set (set name = value)"));
+ return null;
+ }
+ var value = set.Groups[2].Value.Trim();
+ if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
+ value = StepLexer.Tokens(value)[0].Text;
+ return new SetNode(at, text, set.Groups[1].Value, value);
+ case "wait":
+ var last = rest.LastIndexOf(' ');
+ if (last < 0)
+ {
+ errors.Add(new ScriptError(at, "wait needs a condition and a timeout"));
+ return null;
+ }
+ var timeout = rest[(last + 1)..];
+ if (Durations.Frames(timeout) is not { } frames)
+ {
+ errors.Add(new ScriptError(at, $"bad timeout '{timeout}'"));
+ return null;
+ }
+ return new WaitNode(at, text, rest[..last].Trim(), frames);
+ case "expect":
+ if (rest.Length == 0)
+ {
+ errors.Add(new ScriptError(at, "expect needs a condition"));
+ return null;
+ }
+ return new ExpectNode(at, text, rest);
+ case "include":
+ return rest.Length == 0 || rest.Contains(' ')
+ ? Error(errors, at, "include takes one path")
+ : new IncludeNode(at, text, rest);
+ case "checkpoint":
+ return rest.Length == 0 || rest.Contains(' ')
+ ? Error(errors, at, "checkpoint takes one name")
+ : new CheckpointNode(at, text, rest);
+ case "call":
+ if (CallTarget().Match(rest) is not { Success: true } call)
+ return Error(errors, at, "bad call (call name(args))");
+ var args = call.Groups[2].Success
+ ? call.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray()
+ : [];
+ return new CallNode(at, text, call.Groups[1].Value, args);
+ default:
+ return new VerbNode(at, text, head, rest);
+ }
+ }
+
+ private static Node? Error(List<ScriptError> errors, SourceRef at, string message)
+ {
+ errors.Add(new ScriptError(at, message));
+ return null;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs
new file mode 100644
index 0000000..502e24c
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptProgram.cs
@@ -0,0 +1,57 @@
+// Provenance: own design — shared run scripts
+namespace Godosa.Core.Runs.Scripts;
+
+public enum Op { Verb, Wait, Expect, Set, Checkpoint, LabelBegin, LabelEnd, ScopeBegin, ScopeEnd, TryBegin, TryEnd, SetupBegin, SetupEnd }
+
+/// <summary>One flattened instruction. <see cref="Chain"/> = include / call sites, outermost first.</summary>
+public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList<SourceRef> Chain, int Format)
+{
+ public string A { get; init; } = "";
+ public string B { get; init; } = "";
+ public int Timeout { get; init; }
+ public IReadOnlyList<KeyValuePair<string, string>> Bindings { get; init; } = [];
+ public bool LocalVars { get; init; }
+ public int Jump { get; init; } = -1;
+}
+
+public sealed record ScriptProgram(string Entry, RunMode Mode, ulong? Seed, IReadOnlyDictionary<string, string> Vars,
+ IReadOnlySet<string> Locked, IReadOnlyList<Instr> Code, IReadOnlyList<ScriptError> Errors);
+
+public interface IScriptSource
+{
+ string? Read(string path);
+}
+
+public sealed class MemoryScriptSource(IReadOnlyDictionary<string, string> files) : IScriptSource
+{
+ public string? Read(string path) => files.GetValueOrDefault(path);
+}
+
+/// <summary>Files under <paramref name="root"/>; paths are '/'-separated and relative to it.</summary>
+public sealed class FileScriptSource(string root) : IScriptSource
+{
+ public string? Read(string path)
+ {
+ var full = Path.GetFullPath(Path.Combine(root, path));
+ return File.Exists(full) ? File.ReadAllText(full) : null;
+ }
+}
+
+public static class ScriptPaths
+{
+ public static string Resolve(string fromFile, string relative)
+ {
+ var dir = fromFile.Contains('/') ? fromFile[..fromFile.LastIndexOf('/')] : "";
+ var parts = new List<string>(dir.Length == 0 ? [] : dir.Split('/'));
+ foreach (var part in relative.Replace('\\', '/').Split('/'))
+ {
+ if (part is "" or ".")
+ continue;
+ if (part == ".." && parts.Count > 0)
+ parts.RemoveAt(parts.Count - 1);
+ else if (part != "..")
+ parts.Add(part);
+ }
+ return string.Join('/', parts);
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs
new file mode 100644
index 0000000..63a335a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs
@@ -0,0 +1,100 @@
+// Provenance: own design — shared run scripts (CLI)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+/// <summary>Run-script CLI options (<c>--script</c>, <c>--var k=v</c>, <c>--output</c>, <c>--no-cache</c>, <c>--no-snapshots</c>, <c>--from</c>,
+/// <c>--until</c>, <c>--nondeterministic</c>, <c>--step-cap</c>, <c>--report-dir</c>); determinism and seed come from
+/// <see cref="RunFlags"/>.</summary>
+public sealed class ScriptRunFlags
+{
+ public string? Script { get; set; }
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public OutputLevel? Output { get; set; }
+ public bool NoCache { get; set; }
+ public bool NoSnapshots { get; set; }
+ public string? From { get; set; }
+ public string? Until { get; set; }
+ public bool NonDeterministic { get; set; }
+ public int? StepCap { get; set; }
+ public string? ReportDir { get; set; }
+
+ public static bool TryTake(string[] args, ref int i, ScriptRunFlags flags)
+ {
+ string Value(ref int j)
+ {
+ if (j + 1 >= args.Length)
+ throw new ArgumentException($"{args[j]} needs a value");
+ return args[++j];
+ }
+ switch (args[i])
+ {
+ case "--script":
+ flags.Script = Value(ref i);
+ return true;
+ case "--var":
+ {
+ var v = Value(ref i);
+ var eq = v.IndexOf('=');
+ if (eq <= 0)
+ throw new ArgumentException($"--var needs name=value, got '{v}'");
+ flags.Vars[v[..eq]] = v[(eq + 1)..];
+ return true;
+ }
+ case "--output":
+ {
+ var v = Value(ref i);
+ try
+ {
+ flags.Output = RunOutput.Parse(v);
+ }
+ catch (ScriptFault e)
+ {
+ throw new ArgumentException(e.Message);
+ }
+ return true;
+ }
+ case "--no-cache":
+ flags.NoCache = true;
+ return true;
+ case "--no-snapshots":
+ flags.NoSnapshots = true;
+ return true;
+ case "--from":
+ flags.From = Value(ref i);
+ return true;
+ case "--until":
+ flags.Until = Value(ref i);
+ return true;
+ case "--nondeterministic":
+ flags.NonDeterministic = true;
+ return true;
+ case "--step-cap":
+ {
+ var v = Value(ref i);
+ flags.StepCap = int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 0 ? n : throw new ArgumentException($"bad --step-cap '{v}'");
+ return true;
+ }
+ case "--report-dir":
+ flags.ReportDir = Value(ref i);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public ScriptRunOptions ToOptions(RunFlags run, string? cacheDir) => new()
+ {
+ Deterministic = !NonDeterministic,
+ Seed = run.Seed,
+ Output = Output ?? OutputLevel.Labels,
+ Floor = Output ?? OutputLevel.Quiet,
+ StepCap = StepCap ?? 36_000,
+ CacheDir = cacheDir,
+ UseCache = !NoCache,
+ Snapshots = !NoSnapshots,
+ From = From,
+ Until = Until,
+ ReportDir = ReportDir,
+ };
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
new file mode 100644
index 0000000..edc7b97
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
@@ -0,0 +1,568 @@
+// Provenance: own design — shared run scripts (spec §5)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record ScriptRunOptions
+{
+ public bool Deterministic { get; init; } = true;
+ /// <summary>Overrides the script's <c>seed</c> header; neither → 0 and the game's rng is left alone.</summary>
+ public ulong? Seed { get; init; }
+ public OutputLevel Output { get; init; } = OutputLevel.Labels;
+ public OutputLevel Floor { get; init; } = OutputLevel.Quiet;
+ public int StepCap { get; init; } = 36_000;
+ public string? CacheDir { get; init; }
+ public bool UseCache { get; init; } = true;
+ /// <summary>false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing
+ /// cached (<c>--no-snapshots</c>).</summary>
+ public bool Snapshots { get; init; } = true;
+ public string? From { get; init; }
+ public string? Until { get; init; }
+ public string? ReportDir { get; init; }
+ public long CacheCapBytes { get; init; } = 2L << 30;
+}
+
+public enum RunOutcome { Passed, Failed, Invalid, Stopped }
+
+public sealed record RunResult(RunOutcome Outcome, int ExitCode, long Frames, string? Failure);
+
+public sealed class ScriptRunner : IConditionContext
+{
+ private sealed class Active(Instr instr, long startFrame)
+ {
+ public Instr Instr { get; } = instr;
+ public long StartFrame { get; } = startFrame;
+ public int Frames { get; set; }
+ public InputPlan? Plan { get; set; }
+ /// <summary>The <see cref="Frames"/> value a plan's frame 0 lands on (0 for the step's own plan).</summary>
+ public int PlanBase { get; set; }
+ public int Sent { get; set; }
+ public IRunAction? Action { get; init; }
+ public Cond? Wait { get; init; }
+ public int? WaitFrames { get; init; }
+ }
+
+ private sealed record Label(string Name, long Start, SourceRef At);
+
+ private readonly ScriptProgram _program;
+ private readonly IRunGame _game;
+ private readonly ScriptRunOptions _options;
+ private readonly RunOutput _out;
+ private readonly VarScopes _vars;
+ private readonly List<Label> _labels = [];
+ private Active? _active;
+ private sealed record TryFrame(int End, int ScopeDepth, int LabelCount);
+ private readonly List<TryFrame> _tries = [];
+ private int _pc;
+ private bool _started;
+
+ public ScriptRunner(ScriptProgram program, IRunGame game, ScriptRunOptions options, Action<string> output)
+ {
+ (_program, _game, _options) = (program, game, options);
+ _out = new RunOutput(output, options.Floor);
+ _vars = new VarScopes(options.Output);
+ foreach (var (k, v) in program.Vars)
+ _vars.Set(k, v);
+ }
+
+ public long FrameCount { get; private set; }
+
+ /// <summary>Steps completed so far (a window host draws before the next one resolves its positions).</summary>
+ public int StepsDone => _steps;
+
+ /// <summary>Effective seed: options, else the script's <c>seed</c> header, else 0.</summary>
+ public ulong Seed => _options.Seed ?? _program.Seed ?? 0;
+ public RunResult? Result { get; private set; }
+ public RunReport? Report { get; private set; }
+
+ private readonly Queue<string> _recent = new();
+ private readonly List<CheckpointRecord> _checkpoints = [];
+ private readonly List<string> _warnings = [];
+ private readonly System.Diagnostics.Stopwatch _wall = new();
+ private int _steps, _cacheHits, _cacheMisses;
+ private CheckpointCache? _cache;
+ private IReadOnlyDictionary<int, string> _keys = new Dictionary<int, string>();
+
+ public RunResult Run(long cap = HeadlessLoop.DefaultCap)
+ {
+ var (_, capped) = HeadlessLoop.Run(_ => Frame(), null, cap);
+ if (capped && Result == null)
+ Fail(_active?.Instr, $"frame cap {cap}");
+ return Result ?? new RunResult(RunOutcome.Passed, 0, FrameCount, null);
+ }
+
+ /// <summary>A deferred checkpoint restore (<see cref="IRunGame.RestoreDeferred"/>) is under way: the host advances no
+ /// game frame and calls <see cref="AfterFrame"/> not at all this frame; the next <see cref="BeforeFrame"/> goes on.</summary>
+ public bool Restoring { get; private set; }
+
+ public bool Frame()
+ {
+ BeforeFrame();
+ if (Result != null)
+ return false;
+ if (Restoring)
+ return true;
+ Guard(_active?.Instr, _game.Advance);
+ if (Result == null)
+ AfterFrame();
+ return Result == null;
+ }
+
+ public void BeforeFrame()
+ {
+ if (Result != null)
+ return;
+ if (!_started)
+ {
+ var ok = false;
+ Guard(null, () => ok = Start());
+ if (!ok || Result != null)
+ return;
+ if (Restoring = _cacheHits > 0 && _game.RestoreDeferred)
+ return;
+ }
+ Restoring = false;
+ Guard(_active?.Instr, () =>
+ {
+ while (_active == null && Result == null)
+ {
+ if (_pc >= _program.Code.Count)
+ {
+ Finish(new RunResult(RunOutcome.Passed, 0, FrameCount, null), null);
+ return;
+ }
+ Exec(_program.Code[_pc++]);
+ }
+ });
+ if (_active is { Plan: { } plan } a && Result == null)
+ {
+ Guard(a.Instr, () =>
+ {
+ foreach (var e in plan.Events.Where(e => e.Frame + a.PlanBase == a.Frames))
+ {
+ _game.Input.Send(e.Event);
+ a.Sent++;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" input f={FrameCount} {e.Event}");
+ }
+ });
+ }
+ }
+
+ /// <param name="poll">false: the frame counts but the step is not polled (a window host whose next draw shows what this
+ /// frame changed: a click resolved now would aim on the draw from before); the next frame polls it.</param>
+ public void AfterFrame(bool poll = true)
+ {
+ if (Result != null || _active is not { } a)
+ return;
+ FrameCount++;
+ a.Frames++;
+ if (!poll)
+ return;
+ Guard(a.Instr, () =>
+ {
+ var status = Poll(a);
+ if (status.Plan is { } more)
+ (a.Plan, a.PlanBase, a.Sent) = (more, a.Frames, 0);
+ if (status.State == StepState.Running && a.Frames >= _options.StepCap)
+ status = StepStatus.Fault($"step exceeded {_options.StepCap} frames");
+ if (status.State == StepState.Fault)
+ throw new ScriptFault(status.Message ?? "fault");
+ if (status.State == StepState.Done)
+ Complete(a.Instr, a.Frames);
+ });
+ }
+
+ private bool Start()
+ {
+ _started = true;
+ _wall.Start();
+ var errors = ScriptValidator.Validate(_program, _game, _options.Deterministic);
+ if (errors.Count > 0)
+ {
+ foreach (var e in errors)
+ _out.Always(e.ToString());
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, errors[0].ToString()), null);
+ return false;
+ }
+ var until = int.MaxValue;
+ if (_options.Until is { } stop)
+ {
+ until = _program.Code.Select((c, n) => (c, n)).FirstOrDefault(x => x.c.A == stop && x.c.Op is Op.Checkpoint or Op.LabelEnd, (null!, -1)).Item2;
+ if (until < 0)
+ return Invalid($"unknown label or checkpoint {stop}");
+ }
+ if ((_options.Seed ?? _program.Seed) is { } seed && _game.Rng is { } rng)
+ rng.Seed(unchecked((long)seed));
+ if (_options.Deterministic && _options.CacheDir != null && _game.SupportsSnapshot)
+ {
+ _cache = new CheckpointCache(_options.CacheDir, _options.CacheCapBytes);
+ _keys = CheckpointKeys.Compute(_program, _game.ContentVersion, Seed);
+ }
+ if (_options.From is { } from)
+ {
+ var index = _keys.Keys.Concat(_program.Code.Select((c, n) => (c, n)).Where(x => x.c.Op == Op.Checkpoint).Select(x => x.n))
+ .Distinct().FirstOrDefault(n => _program.Code[n].A == from, -1);
+ if (index < 0)
+ return Invalid($"unknown checkpoint {from}");
+ if (_cache?.Load(_keys[index]) is not { } entry)
+ return Invalid($"checkpoint {from} not cached");
+ Resume(index, entry);
+ }
+ else if (_cache != null && _options.UseCache)
+ {
+ foreach (var index in _keys.Keys.Where(k => k < until).OrderDescending())
+ {
+ if (_cache.Load(_keys[index]) is { } entry)
+ {
+ Resume(index, entry);
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private bool Invalid(string message)
+ {
+ _out.Always(message);
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, message), null);
+ return false;
+ }
+
+ private void Resume(int index, CachedCheckpoint entry)
+ {
+ _game.SnapshotRestore(entry.Game);
+ FrameCount = entry.State.Frame;
+ _vars.Restore(entry.State.Scopes);
+ _labels.Clear();
+ _labels.AddRange(entry.State.Labels.Select(l => new Label(l.Name, l.Start, new SourceRef(l.File, l.Line))));
+ _pc = index + 1;
+ var name = _program.Code[index].A;
+ _checkpoints.Add(new CheckpointRecord(name, FrameCount, entry.State.Digest.ToString("x16"), "resumed"));
+ _cacheHits++;
+ _out.Always($"resumed at {name} (cache) f={FrameCount}");
+ }
+
+ private StepStatus Poll(Active a)
+ {
+ if (a.Plan is { } plan && a.Sent < plan.Events.Count)
+ return StepStatus.Running;
+ if (a.Action != null)
+ return a.Action.Poll();
+ if (a.WaitFrames is { } n)
+ return a.Frames >= n ? StepStatus.Done : StepStatus.Running;
+ if (a.Wait is { } cond)
+ {
+ if (cond.Test(this))
+ return StepStatus.Done;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" wait f={FrameCount} {a.Instr.A}: {Cond.Describe(cond, this)}");
+ return a.Frames >= a.Instr.Timeout
+ ? StepStatus.Fault($"timeout after {a.Instr.Timeout} frames: {a.Instr.A} ({Cond.Describe(cond, this)})")
+ : StepStatus.Running;
+ }
+ return StepStatus.Done;
+ }
+
+ private void Exec(Instr i)
+ {
+ switch (i.Op)
+ {
+ case Op.Set:
+ _vars.Set(i.A, Expand(i.B));
+ break;
+ case Op.Expect:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (!cond.Test(this))
+ throw new ScriptFault($"expect failed: {i.A} ({Cond.Describe(cond, this)})");
+ Complete(i, 0);
+ break;
+ }
+ case Op.Wait:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (cond.Test(this))
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { Wait = cond };
+ break;
+ }
+ case Op.Verb:
+ Verb(i);
+ break;
+ case Op.ScopeBegin:
+ _vars.Push(i.LocalVars, _vars.Level, i.Bindings.Select(b => new KeyValuePair<string, string>(b.Key, Expand(b.Value))).ToList());
+ break;
+ case Op.ScopeEnd:
+ _vars.Pop();
+ break;
+ case Op.LabelBegin:
+ _labels.Add(new Label(i.A, FrameCount, i.At));
+ break;
+ case Op.LabelEnd:
+ {
+ var label = _labels[^1];
+ _labels.RemoveAt(_labels.Count - 1);
+ _out.Emit(OutputLevel.Labels, _vars.Level, $"[{label.At} {label.Name}] done f={FrameCount} (+{FrameCount - label.Start})");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Labels, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, _game.Digest()));
+ if (_options.Until == label.Name)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ case Op.TryBegin:
+ _tries.Add(new TryFrame(i.Jump, _vars.Depth, _labels.Count));
+ break;
+ case Op.TryEnd:
+ if (_tries.Count > 0)
+ _tries.RemoveAt(_tries.Count - 1);
+ break;
+ case Op.Checkpoint:
+ {
+ if (!_options.Snapshots)
+ {
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, "", "marker"));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ var digest = _game.Digest();
+ var source = "replayed";
+ if (_cache != null && _game.SnapshotRefusal() is { } refusal)
+ _warnings.Add($"checkpoint {i.A} not cached: {refusal}");
+ else if (_cache != null && _keys.TryGetValue(_pc - 1, out var key))
+ {
+ var old = _cache.Load(key);
+ if (old != null && old.State.Digest == digest)
+ source = "cached";
+ else
+ {
+ if (old != null)
+ _warnings.Add($"checkpoint {i.A} digest differs from cache ({old.State.Digest:x16} → {digest:x16}); cache replaced");
+ try
+ {
+ _cache.Save(key, new CachedCheckpoint(new RunnerState(FrameCount, digest, _vars.Snapshot(),
+ [.. _labels.Select(l => new LabelState(l.Name, l.Start, l.At.File, l.At.Line))]), _game.SnapshotSave()));
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"checkpoint cache not written: {e.Message}");
+ }
+ _cacheMisses++;
+ }
+ }
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, digest.ToString("x16"), source));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, digest));
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ }
+ }
+
+ private void Verb(Instr i)
+ {
+ var tokens = ExpandTokens(i.B);
+ switch (i.A)
+ {
+ case "log":
+ _out.Always(tokens[0].Text);
+ return;
+ case "output":
+ _vars.Level = RunOutput.Parse(tokens[0].Text);
+ return;
+ case "rng":
+ Rng(tokens);
+ return;
+ case "wait-frames":
+ {
+ var n = Durations.Frames(tokens[0].Text) ?? throw new ScriptFault($"bad frame count '{tokens[0].Text}'");
+ if (n == 0)
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { WaitFrames = n };
+ return;
+ }
+ }
+ if (InputPlans.IsInputVerb(i.A))
+ {
+ _active = new Active(i, FrameCount) { Plan = InputPlans.For(i.A, tokens, t => Positions.Resolve(t, _game)) };
+ return;
+ }
+ var action = _game.Actions[i.A];
+ var start = action.Begin(tokens.Select(t => t.Text).ToList());
+ if (start.Status is { State: StepState.Fault } fault)
+ throw new ScriptFault(fault.Message ?? "fault");
+ if (start.Plan == null && start.Status is { State: StepState.Done })
+ {
+ Complete(i, 0);
+ return;
+ }
+ _active = new Active(i, FrameCount) { Plan = start.Plan, Action = action };
+ }
+
+ private void Rng(List<Token> t)
+ {
+ var rng = _game.Rng ?? throw new ScriptFault("game has no rng hook");
+ long N(string s) => long.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new ScriptFault($"bad number '{s}'");
+ switch (t[0].Text)
+ {
+ case "seed" when t.Count == 2:
+ rng.Seed(N(t[1].Text));
+ break;
+ case "next" when t.Count == 3:
+ rng.ForceNext(t[1].Text, N(t[2].Text));
+ break;
+ default:
+ throw new ScriptFault("rng seed <n> | rng next <kind> <value>");
+ }
+ }
+
+ private void Complete(Instr i, int frames)
+ {
+ _active = null;
+ var label = _labels.Count > 0 ? $" {_labels[^1].Name}" : "";
+ _out.Emit(OutputLevel.Steps, _vars.Level, $"[{i.At}{label}] {i.Text} done f={FrameCount} (+{frames})");
+ _steps++;
+ _recent.Enqueue($"{i.At} {i.Text} f={FrameCount}");
+ while (_recent.Count > 20)
+ _recent.Dequeue();
+ }
+
+ /// <summary>Quoted arguments expand inside their token (a value with quotes or spaces stays one argument); unquoted ones
+ /// expand, then split (a variable may hold several arguments).</summary>
+ private List<Token> ExpandTokens(string text) =>
+ [.. StepLexer.Tokens(text).SelectMany(t => t.Quoted ? [new Token(Expand(t.Text), true)] : StepLexer.Tokens(Expand(t.Text)))];
+
+ private string Expand(string text) => VarExpander.Expand(text, _vars.Get, p =>
+ {
+ var cond = ConditionParser.Parse(p);
+ return cond.Eval(this);
+ });
+
+ private void Guard(Instr? at, Action step)
+ {
+ try
+ {
+ step();
+ }
+ catch (ScriptFault e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (FormatException e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ Fail(at ?? CurrentOrLast(), $"{e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ private Instr? CurrentOrLast() => _pc > 0 && _pc <= _program.Code.Count ? _program.Code[_pc - 1] : null;
+
+ private void Fail(Instr? at, string reason)
+ {
+ ReleaseHeld();
+ _active = null;
+ if (_tries.Count > 0)
+ {
+ var t = _tries[^1];
+ _tries.RemoveAt(_tries.Count - 1);
+ _vars.PopTo(t.ScopeDepth);
+ _labels.RemoveRange(t.LabelCount, _labels.Count - t.LabelCount);
+ _out.Always($"try failed at {at?.At}: {reason}");
+ _pc = t.End + 1;
+ return;
+ }
+ var where = at == null ? "" : $" {at.At} {at.Text}";
+ byte[]? snap = null;
+ if (_game.SupportsSnapshot)
+ {
+ try
+ {
+ snap = _game.SnapshotSave();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ _warnings.Add($"failure save not written: {e.Message}");
+ }
+ }
+ string dump;
+ try
+ {
+ dump = _game.Dump();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ dump = $"(dump failed: {e.Message})";
+ }
+ var failure = new FailureInfo(at?.At.ToString() ?? "", at?.Chain.Select(c => c.ToString()).ToList() ?? [], at?.Text ?? "",
+ _labels.Count > 0 ? _labels[^1].Name : null, reason, [.. _recent], dump, _vars.Visible(),
+ snap != null && _options.ReportDir != null ? Path.Combine(_options.ReportDir, "fail.snap") : null);
+ _out.Always($"FAIL{where}: {reason}");
+ Finish(new RunResult(RunOutcome.Failed, 1, FrameCount, reason), failure, snap);
+ }
+
+ private void Finish(RunResult result, FailureInfo? failure, byte[]? failSnapshot = null)
+ {
+ Result = result;
+ _wall.Stop();
+ Report = new RunReport(_program.Entry, _program.Mode, Seed, _options.Deterministic, result.Outcome, FrameCount,
+ _steps, _wall.Elapsed.TotalSeconds, _cacheHits, _cacheMisses, [.. _checkpoints], [.. _warnings], failure);
+ if (_options.ReportDir is not { } dir)
+ return;
+ try
+ {
+ ReportWriter.Write(dir, Report, failSnapshot);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"report not written: {e.Message}");
+ Report = Report with { Warnings = [.. _warnings] };
+ _out.Always($"warning: report not written: {e.Message}");
+ }
+ }
+
+ /// <summary>Review focus 1: a half-sent plan leaves no button or key held — pending ups are sent now.</summary>
+ private void ReleaseHeld()
+ {
+ if (_active is not { Plan: { } plan } a)
+ return;
+ var sent = plan.Events.Take(a.Sent).Select(e => e.Event).ToList();
+ foreach (var pending in plan.Events.Skip(a.Sent).Select(e => e.Event))
+ {
+ var held = pending.Kind switch
+ {
+ InputKind.Up => sent.Count(e => e.Kind == InputKind.Down && e.Button == pending.Button)
+ > sent.Count(e => e.Kind == InputKind.Up && e.Button == pending.Button),
+ InputKind.KeyUp => sent.Count(e => e.Kind == InputKind.KeyDown && e.Key == pending.Key)
+ > sent.Count(e => e.Kind == InputKind.KeyUp && e.Key == pending.Key),
+ _ => false,
+ };
+ if (held)
+ {
+ try
+ {
+ _game.Input.Send(pending);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ // The game is failing anyway; the report carries the first error.
+ }
+ sent.Add(pending);
+ }
+ }
+ }
+
+ bool IConditionContext.HasProbe(string name) => _game.Probes.ContainsKey(name);
+
+ RunValue IConditionContext.Probe(string name, IReadOnlyList<string> args) =>
+ _game.Probes.TryGetValue(name, out var p) ? p.Eval(args) : throw new ScriptFault($"unknown probe {name}");
+
+ long? IConditionContext.Constant(string name) => _game.Constants.TryGetValue(name, out var v) ? v : null;
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
new file mode 100644
index 0000000..909c26f
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
@@ -0,0 +1,46 @@
+// Provenance: own design — shared run scripts (design notes kept privately by the author)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public enum RunMode { Player, Debug }
+
+/// <summary>Player = only injects user input (may read state to aim); Debug = calls the game directly.</summary>
+public enum VerbKind { Player, Debug }
+
+public enum OutputLevel { Quiet, Checkpoints, Labels, Steps, Trace }
+
+public enum StepState { Running, Done, Fault }
+
+public sealed record SourceRef(string File, int Line)
+{
+ public override string ToString() => $"{File}:{Line}";
+}
+
+public sealed record ScriptError(SourceRef At, string Message)
+{
+ public override string ToString() => $"{At}: {Message}";
+}
+
+/// <param name="Plan">Running with more input (multi-click verbs): the runner plays it from the next frame and polls
+/// again once it is sent.</param>
+public readonly record struct StepStatus(StepState State, string? Message = null, InputPlan? Plan = null)
+{
+ public static StepStatus Running => new(StepState.Running);
+ public static StepStatus Input(InputPlan plan) => new(StepState.Running, null, plan);
+ public static StepStatus Done => new(StepState.Done);
+ public static StepStatus Fault(string message) => new(StepState.Fault, message);
+}
+
+/// <summary>A step failure inside the runner (turned into a failed run, never escapes <c>ScriptRunner</c>).</summary>
+public sealed class ScriptFault(string message) : Exception(message);
+
+/// <summary>A probe / condition value: a number or a string.</summary>
+public readonly record struct RunValue(long? Number, string? Text)
+{
+ public static RunValue Of(long n) => new(n, null);
+ public static RunValue Of(string s) => new(null, s);
+ public static RunValue Of(bool b) => new(b ? 1 : 0, null);
+ public bool IsNumber => Number.HasValue;
+ public override string ToString() => Number?.ToString(CultureInfo.InvariantCulture) ?? Text ?? "";
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
new file mode 100644
index 0000000..e67c48a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
@@ -0,0 +1,109 @@
+// Provenance: own design — shared run scripts (spec §3.7)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptValidator
+{
+ public static List<ScriptError> Validate(ScriptProgram p, IRunGame game, bool deterministic)
+ {
+ var errors = new List<ScriptError>(p.Errors);
+ foreach (var (name, action) in game.Actions)
+ {
+ if (LibVerbs.Find(name) is { } lib && lib.Kind != action.Kind)
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb with another kind"));
+ else if (LibVerbs.Find(name) is { GameHook: false })
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb"));
+ }
+ var inSetup = false;
+ foreach (var i in p.Code)
+ {
+ switch (i.Op)
+ {
+ case Op.SetupBegin:
+ inSetup = true;
+ break;
+ case Op.SetupEnd:
+ inSetup = false;
+ break;
+ case Op.TryBegin when deterministic:
+ errors.Add(new ScriptError(i.At, "try needs a non-deterministic run"));
+ break;
+ case Op.Verb:
+ Verb(errors, i, game, p.Mode, inSetup);
+ break;
+ case Op.Wait or Op.Expect:
+ Condition(errors, i, game);
+ break;
+ }
+ }
+ return errors;
+ }
+
+ private static void Verb(List<ScriptError> errors, Instr i, IRunGame game, RunMode mode, bool inSetup)
+ {
+ var lib = LibVerbs.Find(i.A);
+ var gameVerb = lib == null ? game.Actions.GetValueOrDefault(i.A) : null;
+ if (lib == null && gameVerb == null)
+ {
+ errors.Add(new ScriptError(i.At, $"unknown verb {i.A}"));
+ return;
+ }
+ if ((lib is { GameHook: true } && !game.Actions.ContainsKey(i.A)) || (lib?.Name == "rng" && game.Rng == null))
+ {
+ errors.Add(new ScriptError(i.At, $"verb {i.A} not supported by game {game.Name}"));
+ return;
+ }
+ var (kind, min, max) = lib != null ? (lib.Kind, lib.MinArgs, lib.MaxArgs) : (gameVerb!.Kind, gameVerb.MinArgs, gameVerb.MaxArgs);
+ List<Token> tokens;
+ try
+ {
+ tokens = StepLexer.Tokens(i.B);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ if (tokens.Count < min || tokens.Count > max)
+ errors.Add(new ScriptError(i.At, $"verb {i.A} takes {min}..{max} arguments, got {tokens.Count}"));
+ if (kind == VerbKind.Debug && mode == RunMode.Player && !inSetup)
+ errors.Add(new ScriptError(i.At, $"debug verb {i.A} outside setup in player mode"));
+ if (i.A == "output" && tokens.Count == 1 && !LibVerbs.OutputLevels.Contains(tokens[0].Text))
+ errors.Add(new ScriptError(i.At, "output level must be quiet|checkpoints|labels|steps|trace"));
+ foreach (var t in tokens.Where(t => !t.Quoted && t.Text.StartsWith('@')))
+ {
+ var name = t.Text[1..(t.Text.IndexOf('(') is var p and > 0 ? p : t.Text.Length)];
+ if (!game.Locators.ContainsKey(name))
+ errors.Add(new ScriptError(i.At, $"unknown locator @{name}"));
+ }
+ }
+
+ private static void Condition(List<ScriptError> errors, Instr i, IRunGame game)
+ {
+ if (i.A.Contains("${", StringComparison.Ordinal))
+ return; // expanded at run time; checked then
+ Cond cond;
+ try
+ {
+ cond = ConditionParser.Parse(i.A);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ foreach (var bare in cond.Bare())
+ {
+ if (!game.Probes.ContainsKey(bare) && !game.Constants.ContainsKey(bare))
+ errors.Add(new ScriptError(i.At, $"unknown probe or constant {bare}"));
+ }
+ foreach (var (name, args) in cond.Probes())
+ {
+ if (cond.Bare().Contains(name) && args.Count == 0)
+ continue;
+ if (!game.Probes.TryGetValue(name, out var probe))
+ errors.Add(new ScriptError(i.At, $"unknown probe {name}"));
+ else if (args.Count < probe.MinArgs || args.Count > probe.MaxArgs)
+ errors.Add(new ScriptError(i.At, $"probe {name} takes {probe.MinArgs}..{probe.MaxArgs} arguments, got {args.Count}"));
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/StepLexer.cs b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
new file mode 100644
index 0000000..4c54544
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
@@ -0,0 +1,104 @@
+// Provenance: own design — shared run scripts
+using System.Globalization;
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record Token(string Text, bool Quoted);
+
+/// <summary>Line lexing for <c>.steps</c>: <c>#</c> comments outside quotes; tokens split on spaces, <c>"…"</c> (escapes
+/// <c>\"</c>, <c>\\</c>) and <c>(…)</c> groups stay one token.</summary>
+public static class StepLexer
+{
+ public static string StripComment(string line)
+ {
+ var quoted = false;
+ for (var i = 0; i < line.Length; i++)
+ {
+ var c = line[i];
+ if (quoted && c == '\\' && i + 1 < line.Length)
+ {
+ i++;
+ continue;
+ }
+ if (c == '"')
+ quoted = !quoted;
+ else if (c == '#' && !quoted)
+ return line[..i];
+ }
+ return line;
+ }
+
+ public static List<Token> Tokens(string text)
+ {
+ var tokens = new List<Token>();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == ' ')
+ {
+ i++;
+ continue;
+ }
+ if (text[i] == '"')
+ {
+ var sb = new StringBuilder();
+ i++;
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new FormatException("unterminated quote");
+ var c = text[i];
+ if (c == '\\' && i + 1 < text.Length)
+ {
+ sb.Append(text[i + 1]);
+ i += 2;
+ continue;
+ }
+ if (c == '"')
+ {
+ i++;
+ break;
+ }
+ sb.Append(c);
+ i++;
+ }
+ tokens.Add(new Token(sb.ToString(), true));
+ continue;
+ }
+ var start = i;
+ var depth = 0;
+ while (i < text.Length && (depth > 0 || text[i] != ' '))
+ {
+ if (text[i] == '(')
+ depth++;
+ else if (text[i] == ')' && depth > 0)
+ depth--;
+ i++;
+ }
+ tokens.Add(new Token(text[start..i], false));
+ }
+ return tokens;
+ }
+}
+
+/// <summary>Timeouts and waits in frames (1 frame = 1/60 s): <c>120</c> = 120 frames, <c>2s</c> = 120, <c>0.5s</c> = 30;
+/// negative, non-whole frame counts and other units → null.</summary>
+public static class Durations
+{
+ public const int FramesPerSecond = 60;
+
+ public static int? Frames(string text)
+ {
+ if (text.EndsWith('s'))
+ {
+ var number = text[..^1];
+ if (number.Length == 0 || !number.All(c => char.IsAsciiDigit(c) || c == '.')
+ || !decimal.TryParse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var seconds))
+ return null;
+ var frames = seconds * FramesPerSecond;
+ return frames == decimal.Truncate(frames) ? (int)frames : null;
+ }
+ return text.Length > 0 && text.All(char.IsAsciiDigit) && int.TryParse(text, CultureInfo.InvariantCulture, out var n) ? n : null;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Vars.cs b/src/Godosa.Core/Runs/Scripts/Vars.cs
new file mode 100644
index 0000000..a01da62
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Vars.cs
@@ -0,0 +1,174 @@
+// Provenance: own design — shared run scripts (spec §3.3)
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public static class VarsFile
+{
+ public static (Dictionary<string, string> Values, List<ScriptError> Errors) Parse(string path, string text)
+ {
+ var values = new Dictionary<string, string>(StringComparer.Ordinal);
+ var errors = new List<ScriptError>();
+ var lines = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = StepLexer.StripComment(lines[i]).Trim();
+ if (line.Length == 0)
+ continue;
+ var eq = line.IndexOf('=');
+ var name = eq > 0 ? line[..eq].Trim() : "";
+ if (name.Length == 0 || name.Contains(' '))
+ {
+ errors.Add(new ScriptError(new SourceRef(path, i + 1), "bad vars line (name = value)"));
+ continue;
+ }
+ values[name] = line[(eq + 1)..].Trim();
+ }
+ return (values, errors);
+ }
+}
+
+/// <summary><c>${name}</c> expansion, innermost first (<c>${ui.rest.${hours}}</c>); <c>${probe:name(args)}</c> = a probe
+/// value; a <c>$</c> not followed by <c>{</c> is literal.</summary>
+public static class VarExpander
+{
+ public static string Expand(string text, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var sb = new StringBuilder();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ sb.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ sb.Append(text[i++]);
+ }
+ return sb.ToString();
+ }
+
+ /// <summary>Resolves the reference opened before <paramref name="i"/> (nested references in its name first); the
+ /// value is used as is, never scanned again.</summary>
+ private static string Reference(string text, ref int i, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var name = new StringBuilder();
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new ScriptFault($"unclosed ${{ in '{text}'");
+ if (text[i] == '}')
+ {
+ i++;
+ break;
+ }
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ name.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ name.Append(text[i++]);
+ }
+ var n = name.ToString();
+ if (n.Length == 0)
+ throw new ScriptFault("empty ${}");
+ if (n.StartsWith("probe:", StringComparison.Ordinal))
+ return (probe ?? throw new ScriptFault($"no probes here: ${{{n}}}"))(n["probe:".Length..]).ToString();
+ return lookup(n) ?? throw new ScriptFault($"unresolved variable ${{{n}}}");
+ }
+
+ /// <summary>Plain names inside the innermost <c>${…}</c> (no nested <c>${</c>, no <c>probe:</c>).</summary>
+ public static IEnumerable<string> Names(string text)
+ {
+ for (var i = 0; i < text.Length - 1; i++)
+ {
+ if (text[i] != '$' || text[i + 1] != '{')
+ continue;
+ var close = text.IndexOf('}', i + 2);
+ if (close < 0)
+ yield break;
+ var inner = text[(i + 2)..close];
+ if (inner.Length > 0 && !inner.Contains("${", StringComparison.Ordinal) && !inner.StartsWith("probe:", StringComparison.Ordinal))
+ yield return inner;
+ }
+ }
+}
+
+public sealed record ScopeState(Dictionary<string, string> Vars, bool Local, OutputLevel Level);
+
+/// <summary>Variable scopes: root (globals), include scopes (vars go to root, output level scoped), def-call scopes
+/// (local vars). Lookup walks from the top down.</summary>
+public sealed class VarScopes
+{
+ private readonly List<ScopeState> _stack;
+
+ public VarScopes(OutputLevel level) => _stack = [new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, level)];
+
+ public int Depth => _stack.Count;
+
+ public OutputLevel Level
+ {
+ get => _stack[^1].Level;
+ set => _stack[^1] = _stack[^1] with { Level = value };
+ }
+
+ public void Push(bool local, OutputLevel level, IEnumerable<KeyValuePair<string, string>> bindings) =>
+ _stack.Add(new ScopeState(new Dictionary<string, string>(bindings, StringComparer.Ordinal), local, level));
+
+ public void Pop()
+ {
+ if (_stack.Count > 1)
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public void PopTo(int depth)
+ {
+ while (_stack.Count > Math.Max(1, depth))
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public string? Get(string name)
+ {
+ for (var i = _stack.Count - 1; i >= 0; i--)
+ {
+ if (_stack[i].Vars.TryGetValue(name, out var v))
+ return v;
+ }
+ return null;
+ }
+
+ public void Set(string name, string value)
+ {
+ for (var i = _stack.Count - 1; i >= 1; i--)
+ {
+ if (_stack[i].Local)
+ {
+ _stack[i].Vars[name] = value;
+ return;
+ }
+ }
+ _stack[0].Vars[name] = value;
+ }
+
+ public Dictionary<string, string> Visible()
+ {
+ var all = new Dictionary<string, string>(StringComparer.Ordinal);
+ foreach (var scope in _stack)
+ foreach (var (k, v) in scope.Vars)
+ all[k] = v;
+ return all;
+ }
+
+ public List<ScopeState> Snapshot() =>
+ [.. _stack.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) })];
+
+ public void Restore(IReadOnlyList<ScopeState> scopes)
+ {
+ _stack.Clear();
+ _stack.AddRange(scopes.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) }));
+ if (_stack.Count == 0)
+ _stack.Add(new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, OutputLevel.Labels));
+ }
+}
diff --git a/src/Godosa.Core/Runs/SeedStreams.cs b/src/Godosa.Core/Runs/SeedStreams.cs
new file mode 100644
index 0000000..385d732
--- /dev/null
+++ b/src/Godosa.Core/Runs/SeedStreams.cs
@@ -0,0 +1,14 @@
+namespace Godosa.Core.Runs;
+
+/// <summary>Independent per-purpose seeds from one run seed: SplitMix64 output number <c>stream + 1</c> of
+/// <paramref name="seed"/> (audio draws then never shift the game's stream).</summary>
+public static class SeedStreams
+{
+ public static ulong Of(ulong seed, int stream)
+ {
+ var z = unchecked(seed + (ulong)(stream + 1) * 0x9E3779B97F4A7C15UL);
+ z = unchecked((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL);
+ z = unchecked((z ^ (z >> 27)) * 0x94D049BB133111EBUL);
+ return z ^ (z >> 31);
+ }
+}
diff --git a/src/Godosa.Core/Runs/StateDigest.cs b/src/Godosa.Core/Runs/StateDigest.cs
new file mode 100644
index 0000000..a7adaf2
--- /dev/null
+++ b/src/Godosa.Core/Runs/StateDigest.cs
@@ -0,0 +1,51 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Godosa.Core.Runs;
+
+/// <summary>FNV-1a 64 over state bytes (numbers little-endian, strings UTF-8) for comparing runs.</summary>
+public sealed class StateDigest
+{
+ private const ulong Offset = 14695981039346656037UL, Prime = 1099511628211UL;
+
+ public ulong Value { get; private set; } = Offset;
+ public string Hex => Value.ToString("x16");
+
+ public static ulong Of(ReadOnlySpan<byte> bytes)
+ {
+ var d = new StateDigest();
+ d.Add(bytes);
+ return d.Value;
+ }
+
+ public void Add(ReadOnlySpan<byte> bytes)
+ {
+ var h = Value;
+ foreach (var b in bytes)
+ h = (h ^ b) * Prime;
+ Value = h;
+ }
+
+ public void Add(byte b) => Value = (Value ^ b) * Prime;
+
+ public void Add(uint v)
+ {
+ Span<byte> b = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(b, v);
+ Add(b);
+ }
+
+ public void Add(int v) => Add(unchecked((uint)v));
+
+ public void Add(ulong v)
+ {
+ Span<byte> b = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(b, v);
+ Add(b);
+ }
+
+ public void Add(long v) => Add(unchecked((ulong)v));
+ public void Add(float v) => Add(BitConverter.SingleToUInt32Bits(v));
+ public void Add(double v) => Add(BitConverter.DoubleToUInt64Bits(v));
+ public void Add(string s) => Add(Encoding.UTF8.GetBytes(s));
+}
diff --git a/src/Godosa.Core/SettingsFile.cs b/src/Godosa.Core/SettingsFile.cs
new file mode 100644
index 0000000..6aca17e
--- /dev/null
+++ b/src/Godosa.Core/SettingsFile.cs
@@ -0,0 +1,49 @@
+namespace Godosa.Core;
+
+/// <summary>A settings file on disk. Failures never stop the game: any load failure
+/// (missing file excepted: plain defaults) → defaults + a stderr line; save failure →
+/// stderr. Saves write a temp file then rename, so a crash never leaves half a file.
+/// <paramref name="report"/> receives those lines (default: stderr).</summary>
+public sealed class SettingsFile<T>(string path, Func<string, T> parse, Func<T, string> serialize, Func<T> defaults,
+ Action<string>? report = null)
+{
+ private readonly Action<string> _report = report ?? Console.Error.WriteLine;
+
+ public string Path => path;
+
+ /// <summary>The override variable's value when set, else
+ /// <c>ApplicationData/&lt;appFolder&gt;/settings.json</c>.</summary>
+ public static string DefaultPath(string environmentOverride, string appFolder) =>
+ Environment.GetEnvironmentVariable(environmentOverride) is { Length: > 0 } overridden
+ ? overridden
+ : System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appFolder, "settings.json");
+
+ public T Load()
+ {
+ try
+ {
+ return File.Exists(path) ? parse(File.ReadAllText(path)) : defaults();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ _report($"settings: load failed ({e.Message}); using defaults");
+ return defaults();
+ }
+ }
+
+ public void Save(T value)
+ {
+ string temp = path + ".tmp";
+ try
+ {
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!); // a bare name: the working folder
+ File.WriteAllText(temp, serialize(value));
+ File.Move(temp, path, overwrite: true);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ _report($"settings: save failed ({e.Message})");
+ try { File.Delete(temp); } catch (Exception) { } // best effort: don't leave the half-step behind
+ }
+ }
+}
diff --git a/src/Godosa.Desktop/Buttons.cs b/src/Godosa.Desktop/Buttons.cs
new file mode 100644
index 0000000..34a89e0
--- /dev/null
+++ b/src/Godosa.Desktop/Buttons.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace Godosa.Desktop;
+
+public static class Buttons
+{
+ /// <summary>Presses a button as a click would: toggles a ToggleButton, else raises Click.</summary>
+ public static void Press(Button button)
+ {
+ if (button is ToggleButton toggle) toggle.IsChecked = toggle.IsChecked != true;
+ else button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ }
+}
diff --git a/src/Godosa.Desktop/CursorWarp.cs b/src/Godosa.Desktop/CursorWarp.cs
new file mode 100644
index 0000000..c44342d
--- /dev/null
+++ b/src/Godosa.Desktop/CursorWarp.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Moves the OS pointer to a point in a visual (free look recentering, camera
+/// spec §3: Avalonia 12.1.2 has no pointer lock). X11/XWayland via libX11
+/// (XWayland turns a warp under a hidden cursor into a pointer lock), Windows
+/// via user32. Returns false when unsupported or on any failure.
+/// </summary>
+public static partial class CursorWarp
+{
+ private static IntPtr _display;
+
+ public static bool TryWarp(Visual visual, Point viewPoint)
+ {
+ try
+ {
+ var topLevel = TopLevel.GetTopLevel(visual);
+ var handle = topLevel?.TryGetPlatformHandle();
+ if (topLevel is null || handle is null) return false;
+
+ if (OperatingSystem.IsWindows())
+ {
+ var screen = visual.PointToScreen(viewPoint);
+ return SetCursorPos(screen.X, screen.Y);
+ }
+ if (OperatingSystem.IsLinux() && handle.HandleDescriptor == "XID")
+ {
+ if (visual.TranslatePoint(viewPoint, topLevel) is not { } p) return false;
+ if (_display == IntPtr.Zero) _display = XOpenDisplay(IntPtr.Zero);
+ if (_display == IntPtr.Zero) return false;
+ double scaling = topLevel.RenderScaling;
+ XWarpPointer(_display, 0, (nuint)handle.Handle, 0, 0, 0, 0,
+ (int)Math.Round(p.X * scaling), (int)Math.Round(p.Y * scaling));
+ XFlush(_display);
+ return true;
+ }
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp unsupported (handle {handle.HandleDescriptor})");
+ return false;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ [LibraryImport("libX11.so.6")]
+ private static partial IntPtr XOpenDisplay(IntPtr displayName);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XWarpPointer(IntPtr display, nuint srcWindow, nuint destWindow,
+ int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XFlush(IntPtr display);
+
+ [LibraryImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool SetCursorPos(int x, int y);
+}
diff --git a/src/Godosa.Desktop/DisplayMode.cs b/src/Godosa.Desktop/DisplayMode.cs
new file mode 100644
index 0000000..527fa43
--- /dev/null
+++ b/src/Godosa.Desktop/DisplayMode.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Godosa.Desktop;
+
+/// <summary>Window fullscreen and a UI layer's scale (the 3D view stays outside that layer).</summary>
+public static class DisplayMode
+{
+ public static void SetFullscreen(Window? window, bool fullscreen)
+ {
+ if (window is null) return;
+ bool full = window.WindowState == WindowState.FullScreen;
+ if (fullscreen && !full) window.WindowState = WindowState.FullScreen;
+ else if (!fullscreen && full) window.WindowState = WindowState.Normal;
+ }
+
+ public static void SetUiScale(LayoutTransformControl layer, double factor) =>
+ layer.LayoutTransform = factor == 1.0 ? null : new ScaleTransform(factor, factor);
+}
diff --git a/src/Godosa.Desktop/FrameTimeGraph.cs b/src/Godosa.Desktop/FrameTimeGraph.cs
new file mode 100644
index 0000000..7e59a17
--- /dev/null
+++ b/src/Godosa.Desktop/FrameTimeGraph.cs
@@ -0,0 +1,65 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Frame-time bar graph (game-loop spec §5): one 1-px bar per frame in the
+/// stats window, newest at the right, 0–50 ms scale, dashed guides at 16.7 ms
+/// (60 fps) and 33.3 ms (30 fps). Bars are colored relative to the window
+/// median (green ≤ 1.5×, yellow ≤ 2×, red = hitch) so ordinary vsync jitter
+/// stays green whatever the refresh rate. Drawn by Avalonia, not GL, so it
+/// doesn't perturb the GL frames it measures. Presentation only.
+/// </summary>
+public sealed class FrameTimeGraph : Control
+{
+ private const float ScaleMs = 50f;
+ private const float Ms60 = 1000f / 60f;
+ private const float Ms30 = 1000f / 30f;
+
+ private static readonly IBrush Backdrop = new SolidColorBrush(Color.FromArgb(90, 255, 255, 255));
+ private static readonly IBrush Good = new SolidColorBrush(Color.FromRgb(80, 200, 120));
+ private static readonly IBrush Slow = new SolidColorBrush(Color.FromRgb(230, 200, 60));
+ private static readonly IBrush Bad = new SolidColorBrush(Color.FromRgb(230, 70, 60));
+ private static readonly IPen Guide = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1, DashStyle.Dash);
+
+ private readonly FrameTimeStats _stats;
+ private readonly float[] _buffer;
+
+ public FrameTimeGraph(FrameTimeStats stats)
+ {
+ _stats = stats;
+ _buffer = new float[stats.Capacity];
+ Width = stats.Capacity;
+ Height = 60;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ double w = Bounds.Width, h = Bounds.Height;
+ context.FillRectangle(Backdrop, new Rect(0, 0, w, h));
+
+ int n = _stats.CopyIntervalsMs(_buffer);
+ float median = (float)_stats.Summarize().P50Ms;
+ double x0 = w - n;
+ for (int i = 0; i < n; i++)
+ {
+ float ms = _buffer[i];
+ double barHeight = Math.Min(ms / ScaleMs, 1f) * h;
+ var brush = ms > 2f * median ? Bad : ms > 1.5f * median ? Slow : Good;
+ context.FillRectangle(brush, new Rect(x0 + i, h - barHeight, 1, barHeight));
+ }
+
+ DrawGuide(context, Ms60, w, h);
+ DrawGuide(context, Ms30, w, h);
+ }
+
+ private static void DrawGuide(DrawingContext context, float ms, double w, double h)
+ {
+ double y = h - ms / ScaleMs * h;
+ context.DrawLine(Guide, new Point(0, y), new Point(w, y));
+ }
+}
diff --git a/src/Godosa.Desktop/GlProgram.cs b/src/Godosa.Desktop/GlProgram.cs
new file mode 100644
index 0000000..25ebe5c
--- /dev/null
+++ b/src/Godosa.Desktop/GlProgram.cs
@@ -0,0 +1,35 @@
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>Shader program helpers: compile + link with the driver's info log in the
+/// exception, so a bad shader fails loudly at startup instead of drawing nothing.</summary>
+public static class GlProgram
+{
+ public static uint Link(GL gl, string vertexSource, string fragmentSource)
+ {
+ uint vs = Compile(gl, ShaderType.VertexShader, vertexSource);
+ uint fs = Compile(gl, ShaderType.FragmentShader, fragmentSource);
+ uint program = gl.CreateProgram();
+ gl.AttachShader(program, vs);
+ gl.AttachShader(program, fs);
+ gl.LinkProgram(program);
+ gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int linkStatus);
+ if (linkStatus == 0)
+ throw new InvalidOperationException($"Shader link failed: {gl.GetProgramInfoLog(program)}");
+ gl.DeleteShader(vs);
+ gl.DeleteShader(fs);
+ return program;
+ }
+
+ public static uint Compile(GL gl, ShaderType type, string source)
+ {
+ uint shader = gl.CreateShader(type);
+ gl.ShaderSource(shader, source);
+ gl.CompileShader(shader);
+ gl.GetShader(shader, ShaderParameterName.CompileStatus, out int status);
+ if (status == 0)
+ throw new InvalidOperationException($"Shader compile failed: {gl.GetShaderInfoLog(shader)}");
+ return shader;
+ }
+}
diff --git a/src/Godosa.Desktop/Godosa.Desktop.csproj b/src/Godosa.Desktop/Godosa.Desktop.csproj
new file mode 100644
index 0000000..0be77f4
--- /dev/null
+++ b/src/Godosa.Desktop/Godosa.Desktop.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Avalonia" />
+ <PackageReference Include="Silk.NET.OpenGL" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Desktop/GpuTimer.cs b/src/Godosa.Desktop/GpuTimer.cs
new file mode 100644
index 0000000..11076e8
--- /dev/null
+++ b/src/Godosa.Desktop/GpuTimer.cs
@@ -0,0 +1,71 @@
+using System;
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>GPU time per frame from GL_TIME_ELAPSED queries (game-loop spec §6). A small
+/// ring so reading a result never stalls on the frame still in flight; disables itself
+/// when the context has no timer queries (e.g. GLES without the extension).</summary>
+public sealed class GpuTimer
+{
+ private const int Ring = 4;
+ private readonly uint[] _queries = new uint[Ring];
+ private readonly bool[] _pending = new bool[Ring];
+ private int _current;
+ private bool _open;
+
+ public bool Supported { get; private set; }
+
+ public void Init(GL gl)
+ {
+ try
+ {
+ string version = gl.GetStringS(StringName.Version) ?? "";
+ if (version.Contains("OpenGL ES", StringComparison.Ordinal)) return;
+ for (int i = 0; i < Ring; i++) _queries[i] = gl.GenQuery();
+ Supported = gl.GetError() == GLEnum.NoError;
+ }
+ catch (Exception)
+ {
+ Supported = false;
+ }
+ }
+
+ public void Begin(GL gl)
+ {
+ if (!Supported || _pending[_current]) return; // slot still in flight: skip this frame
+ gl.BeginQuery(QueryTarget.TimeElapsed, _queries[_current]);
+ _open = true;
+ }
+
+ public void End(GL gl)
+ {
+ if (!_open) return;
+ gl.EndQuery(QueryTarget.TimeElapsed);
+ _pending[_current] = true;
+ _open = false;
+ _current = (_current + 1) % Ring;
+ }
+
+ /// <summary>Seconds for each finished query since the last call.</summary>
+ public void Collect(GL gl, Action<double> onResult)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++)
+ {
+ if (!_pending[i]) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.ResultAvailable, out int available);
+ if (available == 0) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.Result, out ulong nanoseconds);
+ _pending[i] = false;
+ onResult(nanoseconds / 1e9);
+ }
+ }
+
+ public void Delete(GL gl)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++) gl.DeleteQuery(_queries[i]);
+ Supported = false;
+ }
+}
diff --git a/src/Godosa.Desktop/InputScriptPlayer.cs b/src/Godosa.Desktop/InputScriptPlayer.cs
new file mode 100644
index 0000000..2aad679
--- /dev/null
+++ b/src/Godosa.Desktop/InputScriptPlayer.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Godosa.Core.Diagnostics;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Replays the game's input-script file (Core <see cref="InputScript"/>, path in the
+/// variable given to <see cref="Configure"/>) as real
+/// routed key events to the focused control and clicks on visible buttons, so smoke runs
+/// can drive the game without X key injection (which never reaches a focused window on
+/// KDE Wayland). Log category <c>script</c> (<see cref="EngineDebug"/>).</summary>
+public sealed class InputScriptPlayer
+{
+ private readonly Window _window;
+ private readonly InputScript _script;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+ private readonly List<ScriptEvent> _due = new();
+ private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(10) };
+
+ private static string? _variable;
+ private readonly Func<Window, Interactive?>? _keyTarget;
+
+ /// <summary>True while a script drives the game: window focus no longer pauses or
+ /// releases input.</summary>
+ public static bool Active { get; private set; }
+
+ /// <summary>Scripted gamepad (<c>PadA … PadRT</c>, see <see cref="GamepadScript"/>).</summary>
+ public static GamepadState Pad { get; private set; }
+
+ /// <summary>Call first in <c>Main</c>: names the game's script variable.</summary>
+ public static void Configure(string environmentVariable)
+ {
+ _variable = environmentVariable;
+ Active = Environment.GetEnvironmentVariable(environmentVariable) is { Length: > 0 };
+ }
+
+ private InputScriptPlayer(Window window, InputScript script, Func<Window, Interactive?>? keyTarget)
+ {
+ _window = window;
+ _script = script;
+ _keyTarget = keyTarget;
+ _timer.Tick += (_, _) => Step();
+ _timer.Start();
+ }
+
+ /// <param name="keyTarget">Where keys go when nothing has focus (e.g. the game view); the window otherwise.</param>
+ public static InputScriptPlayer? Start(Window window, Func<Window, Interactive?>? keyTarget = null)
+ {
+ if (!Active) return null;
+ string path = Environment.GetEnvironmentVariable(_variable!)!;
+ var script = InputScript.Parse(File.ReadAllText(path));
+ if (EngineDebug.Script) EngineDebug.Write("script", $"{script.Events.Count} events from {path}");
+ return new InputScriptPlayer(window, script, keyTarget);
+ }
+
+ private void Step()
+ {
+ _script.Due(_clock.Elapsed.TotalSeconds, _due);
+ foreach (var e in _due)
+ {
+ bool ok = e.Action switch
+ {
+ ScriptAction.Click => Click(e.Arg),
+ ScriptAction.Type => Type(e.Arg),
+ _ => Key(e.Action == ScriptAction.Down, e.Arg),
+ };
+ if (EngineDebug.Script) EngineDebug.Write("script", $"t={_clock.Elapsed.TotalSeconds:F2} {e.Action} {e.Arg}{(ok ? "" : " (FAILED)")}");
+ }
+ _due.Clear();
+ if (_script.Finished) _timer.Stop();
+ }
+
+ private bool Key(bool down, string name)
+ {
+ if (name.StartsWith("Pad", StringComparison.OrdinalIgnoreCase))
+ {
+ if (GamepadScript.Apply(Pad, name, down) is not { } next) return false;
+ Pad = next;
+ return true;
+ }
+ if (!Enum.TryParse(name, ignoreCase: true, out PhysicalKey physical)) return false;
+ Enum.TryParse(name, ignoreCase: true, out Key key);
+ var target = _window.FocusManager?.GetFocusedElement() as Interactive
+ ?? _keyTarget?.Invoke(_window)
+ ?? _window;
+ target.RaiseEvent(new KeyEventArgs
+ {
+ RoutedEvent = down ? InputElement.KeyDownEvent : InputElement.KeyUpEvent,
+ PhysicalKey = physical,
+ Key = key,
+ Source = target,
+ });
+ return true;
+ }
+
+ private bool Type(string text)
+ {
+ if (_window.FocusManager?.GetFocusedElement() is not TextBox box) return false;
+ box.Text = text;
+ return true;
+ }
+
+ private bool Click(string label)
+ {
+ var buttons = _window.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled).ToList();
+ string Text(Button b) => b.Content?.ToString()?.Trim() ?? "";
+ var button = buttons.FirstOrDefault(b => Text(b).Equals(label, StringComparison.OrdinalIgnoreCase)) // exact label first
+ ?? buttons.FirstOrDefault(b => b.Name == label) // steppers: fov+ / fov-
+ ?? buttons.FirstOrDefault(b => Text(b).Contains(label, StringComparison.OrdinalIgnoreCase));
+ if (button is not null) Buttons.Press(button);
+ return button is not null;
+ }
+}
diff --git a/src/Godosa.Desktop/PadNavigator.cs b/src/Godosa.Desktop/PadNavigator.cs
new file mode 100644
index 0000000..b4b59d5
--- /dev/null
+++ b/src/Godosa.Desktop/PadNavigator.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.VisualTree;
+using Avalonia.Media;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Gamepad control of an open window: D-pad moves a highlight through its enabled
+/// buttons (wrapping), A presses (the game decides B/Start).</summary>
+public sealed class PadNavigator
+{
+ private static readonly Thickness HighlightThickness = new(2);
+ private readonly MenuCursor _cursor = new();
+ private readonly List<Button> _buttons = new();
+ private Control? _container;
+ private Button? _highlighted;
+ private readonly IBrush _highlight;
+
+ public PadNavigator(IBrush highlight) => _highlight = highlight;
+
+ /// <summary>The window pad input should drive now, or null (pad drives the character).</summary>
+ public Control? Active { get; private set; }
+
+ /// <summary>Highlight only while the gamepad is the device in use (keyboard/mouse users
+ /// get no stray gold border).</summary>
+ public bool ShowHighlight { get; set; }
+
+ public void Refresh(Control? active)
+ {
+ Active = active;
+ if (!ReferenceEquals(active, _container))
+ {
+ _container = active;
+ _cursor.Clamp(0);
+ }
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ Highlight();
+ }
+
+ public void Move(int delta)
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ _cursor.Move(delta, _buttons.Count);
+ Highlight();
+ }
+
+ public void Press()
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ if (_cursor.Index >= 0) Buttons.Press(_buttons[_cursor.Index]);
+ }
+
+ private void Collect()
+ {
+ _buttons.Clear();
+ if (_container is null) return;
+ _buttons.AddRange(_container.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled));
+ }
+
+ private void Highlight()
+ {
+ var target = ShowHighlight && _cursor.Index >= 0 && _cursor.Index < _buttons.Count ? _buttons[_cursor.Index] : null;
+ if (ReferenceEquals(target, _highlighted)) return;
+ if (_highlighted is not null)
+ {
+ _highlighted.ClearValue(Button.BorderBrushProperty);
+ _highlighted.ClearValue(Button.BorderThicknessProperty);
+ }
+ _highlighted = target;
+ if (target is not null)
+ {
+ target.BorderBrush = _highlight;
+ target.BorderThickness = HighlightThickness;
+ target.BringIntoView(); // long option pages scroll
+ }
+ }
+}
diff --git a/src/Godosa.Gamepad/Godosa.Gamepad.csproj b/src/Godosa.Gamepad/Godosa.Gamepad.csproj
new file mode 100644
index 0000000..3fb6b13
--- /dev/null
+++ b/src/Godosa.Gamepad/Godosa.Gamepad.csproj
@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Silk.NET.SDL" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Gamepad/SdlGamepadSource.cs b/src/Godosa.Gamepad/SdlGamepadSource.cs
new file mode 100644
index 0000000..ee63161
--- /dev/null
+++ b/src/Godosa.Gamepad/SdlGamepadSource.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Numerics;
+using Godosa.Core.Diagnostics;
+using Godosa.Core.Input;
+using Silk.NET.SDL;
+
+namespace Godosa.Gamepad;
+
+/// <summary>
+/// Reads the first attached game controller through windowless SDL. Returns raw
+/// held state; each game maps it (and applies deadzones). Any SDL failure disables
+/// the pad for the session and the game stays keyboard-only.
+/// </summary>
+public sealed unsafe class SdlGamepadSource : IDisposable
+{
+ private Sdl? _sdl;
+ private GameController* _pad;
+ private bool _failed;
+
+ public GamepadState? Poll()
+ {
+ if (_failed) return null;
+ try
+ {
+ if (_sdl is null && !Init()) return null;
+ var sdl = _sdl!;
+ sdl.GameControllerUpdate(); // also detects hot-plug (~1.4 µs/call)
+
+ if (_pad != null && sdl.GameControllerGetAttached(_pad) != SdlBool.True)
+ {
+ sdl.GameControllerClose(_pad);
+ _pad = null;
+ if (EngineDebug.Input) EngineDebug.Write("input", "gamepad detached");
+ }
+ if (_pad == null && !Open(sdl)) return null;
+
+ var buttons = PadButtons.None;
+ foreach (var (sdlButton, pad) in ButtonMap)
+ if (sdl.GameControllerGetButton(_pad, sdlButton) != 0) buttons |= pad;
+ return new GamepadState(
+ new Vector2(Axis(sdl, GameControllerAxis.Leftx), Axis(sdl, GameControllerAxis.Lefty)),
+ new Vector2(Axis(sdl, GameControllerAxis.Rightx), Axis(sdl, GameControllerAxis.Righty)),
+ Math.Max(0f, Axis(sdl, GameControllerAxis.Triggerleft)),
+ Math.Max(0f, Axis(sdl, GameControllerAxis.Triggerright)),
+ buttons);
+ }
+ catch (Exception ex)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad disabled: {ex.Message}");
+ _failed = true;
+ return null;
+ }
+ }
+
+ private static readonly (GameControllerButton Sdl, PadButtons Pad)[] ButtonMap =
+ {
+ (GameControllerButton.A, PadButtons.A), (GameControllerButton.B, PadButtons.B),
+ (GameControllerButton.X, PadButtons.X), (GameControllerButton.Y, PadButtons.Y),
+ (GameControllerButton.Back, PadButtons.Back), (GameControllerButton.Start, PadButtons.Start),
+ (GameControllerButton.Leftshoulder, PadButtons.LeftShoulder), (GameControllerButton.Rightshoulder, PadButtons.RightShoulder),
+ (GameControllerButton.DpadUp, PadButtons.DpadUp), (GameControllerButton.DpadDown, PadButtons.DpadDown),
+ (GameControllerButton.DpadLeft, PadButtons.DpadLeft), (GameControllerButton.DpadRight, PadButtons.DpadRight),
+ };
+
+ private bool Init()
+ {
+ var sdl = Sdl.GetApi();
+ sdl.SetHint(Sdl.HintJoystickAllowBackgroundEvents, "1"); // the Avalonia window has focus, not SDL
+ sdl.SetHint(Sdl.HintNoSignalHandlers, "1");
+ if (sdl.InitSubSystem(Sdl.InitGamecontroller) != 0)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad disabled: {sdl.GetErrorS()}");
+ _failed = true;
+ return false;
+ }
+ sdl.JoystickEventState(0); // SDL_IGNORE: state is polled; nothing drains SDL's event queue
+ sdl.GameControllerEventState(0);
+ _sdl = sdl;
+ return true;
+ }
+
+ private bool Open(Sdl sdl)
+ {
+ int count = sdl.NumJoysticks();
+ for (int i = 0; i < count; i++)
+ {
+ if (sdl.IsGameController(i) != SdlBool.True) continue;
+ _pad = sdl.GameControllerOpen(i);
+ if (_pad == null) continue;
+ if (EngineDebug.Input) EngineDebug.Write("input", $"gamepad attached: {sdl.GameControllerNameS(_pad)}");
+ return true;
+ }
+ return false;
+ }
+
+ private float Axis(Sdl sdl, GameControllerAxis axis) =>
+ Math.Clamp(sdl.GameControllerGetAxis(_pad, axis) / 32767f, -1f, 1f);
+
+ public void Dispose()
+ {
+ if (_sdl is null) return;
+ if (_pad != null) _sdl.GameControllerClose(_pad);
+ _pad = null;
+ _sdl.QuitSubSystem(Sdl.InitGamecontroller);
+ _sdl = null;
+ }
+}