From 39066900773e7857faf2d02e7ed51b71d219d97d Mon Sep 17 00:00:00 2001 From: godosa Date: Tue, 6 Oct 2026 23:39:36 +0200 Subject: godosa-engine: initial public history --- src/Godosa.Core/Audio/AudioTypes.cs | 45 ++ src/Godosa.Core/Audio/BiquadFilter.cs | 37 ++ src/Godosa.Core/Audio/Mp3.cs | 133 ++++++ src/Godosa.Core/Audio/Pcm.cs | 24 + src/Godosa.Core/Audio/SignalStats.cs | 51 +++ src/Godosa.Core/Audio/Synth.cs | 189 ++++++++ src/Godosa.Core/Audio/WavReader.cs | 157 +++++++ src/Godosa.Core/Diagnostics/DebugFlags.cs | 20 + src/Godosa.Core/Diagnostics/EngineDebug.cs | 25 ++ src/Godosa.Core/Diagnostics/FrameTimeStats.cs | 139 ++++++ src/Godosa.Core/Diagnostics/PerfCsv.cs | 27 ++ src/Godosa.Core/Godosa.Core.csproj | 5 + src/Godosa.Core/Input/GamepadState.cs | 58 +++ src/Godosa.Core/Input/InputScript.cs | 82 ++++ src/Godosa.Core/Input/MenuCursor.cs | 21 + src/Godosa.Core/Runs/DigestLine.cs | 13 + src/Godosa.Core/Runs/FrameClock.cs | 23 + src/Godosa.Core/Runs/HeadlessLoop.cs | 20 + src/Godosa.Core/Runs/RouteLegs.cs | 117 +++++ src/Godosa.Core/Runs/RunFlags.cs | 39 ++ src/Godosa.Core/Runs/Scripts/CheckpointCache.cs | 112 +++++ src/Godosa.Core/Runs/Scripts/Condition.cs | 234 ++++++++++ src/Godosa.Core/Runs/Scripts/Input.cs | 24 + src/Godosa.Core/Runs/Scripts/InputPlans.cs | 111 +++++ src/Godosa.Core/Runs/Scripts/LibVerbs.cs | 32 ++ src/Godosa.Core/Runs/Scripts/RunGame.cs | 68 +++ src/Godosa.Core/Runs/Scripts/RunOutput.cs | 21 + src/Godosa.Core/Runs/Scripts/RunReport.cs | 58 +++ src/Godosa.Core/Runs/Scripts/ScriptAst.cs | 25 ++ src/Godosa.Core/Runs/Scripts/ScriptLoader.cs | 218 +++++++++ src/Godosa.Core/Runs/Scripts/ScriptParser.cs | 231 ++++++++++ src/Godosa.Core/Runs/Scripts/ScriptProgram.cs | 57 +++ src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs | 100 +++++ src/Godosa.Core/Runs/Scripts/ScriptRunner.cs | 568 ++++++++++++++++++++++++ src/Godosa.Core/Runs/Scripts/ScriptTypes.cs | 46 ++ src/Godosa.Core/Runs/Scripts/ScriptValidator.cs | 109 +++++ src/Godosa.Core/Runs/Scripts/StepLexer.cs | 104 +++++ src/Godosa.Core/Runs/Scripts/Vars.cs | 174 ++++++++ src/Godosa.Core/Runs/SeedStreams.cs | 14 + src/Godosa.Core/Runs/StateDigest.cs | 51 +++ src/Godosa.Core/SettingsFile.cs | 49 ++ 41 files changed, 3631 insertions(+) create mode 100644 src/Godosa.Core/Audio/AudioTypes.cs create mode 100644 src/Godosa.Core/Audio/BiquadFilter.cs create mode 100644 src/Godosa.Core/Audio/Mp3.cs create mode 100644 src/Godosa.Core/Audio/Pcm.cs create mode 100644 src/Godosa.Core/Audio/SignalStats.cs create mode 100644 src/Godosa.Core/Audio/Synth.cs create mode 100644 src/Godosa.Core/Audio/WavReader.cs create mode 100644 src/Godosa.Core/Diagnostics/DebugFlags.cs create mode 100644 src/Godosa.Core/Diagnostics/EngineDebug.cs create mode 100644 src/Godosa.Core/Diagnostics/FrameTimeStats.cs create mode 100644 src/Godosa.Core/Diagnostics/PerfCsv.cs create mode 100644 src/Godosa.Core/Godosa.Core.csproj create mode 100644 src/Godosa.Core/Input/GamepadState.cs create mode 100644 src/Godosa.Core/Input/InputScript.cs create mode 100644 src/Godosa.Core/Input/MenuCursor.cs create mode 100644 src/Godosa.Core/Runs/DigestLine.cs create mode 100644 src/Godosa.Core/Runs/FrameClock.cs create mode 100644 src/Godosa.Core/Runs/HeadlessLoop.cs create mode 100644 src/Godosa.Core/Runs/RouteLegs.cs create mode 100644 src/Godosa.Core/Runs/RunFlags.cs create mode 100644 src/Godosa.Core/Runs/Scripts/CheckpointCache.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Condition.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Input.cs create mode 100644 src/Godosa.Core/Runs/Scripts/InputPlans.cs create mode 100644 src/Godosa.Core/Runs/Scripts/LibVerbs.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunGame.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunOutput.cs create mode 100644 src/Godosa.Core/Runs/Scripts/RunReport.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptAst.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptLoader.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptParser.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptProgram.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptRunner.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptTypes.cs create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptValidator.cs create mode 100644 src/Godosa.Core/Runs/Scripts/StepLexer.cs create mode 100644 src/Godosa.Core/Runs/Scripts/Vars.cs create mode 100644 src/Godosa.Core/Runs/SeedStreams.cs create mode 100644 src/Godosa.Core/Runs/StateDigest.cs create mode 100644 src/Godosa.Core/SettingsFile.cs (limited to 'src/Godosa.Core') 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; + +/// An endless stereo source the audio thread pulls blocks from. +public interface IStereoStream +{ + /// Fills interleaved L/R frames, −1…1. + void Render(float[] interleaved, int frames); +} + +/// Headphone 3D (binaural). Auto lets OpenAL Soft decide from the output device. +public enum HrtfMode { Auto, On, Off } + +/// Why the game is (not) paused, as far as sound cares. +public enum AudioPauseState { Running, Menu, Inactive } + +/// 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). +public interface IAudioMix +{ + HrtfMode Hrtf { get; } + float ChannelGain(int channel, AudioPauseState state); + /// True if sounds on this channel pause with the game world. + bool Pausable(int channel); + /// True if the game world (pausable channels) is paused in this state. + bool WorldPaused(AudioPauseState state); + /// The channel's name in debug logs (default: its number). + string ChannelName(int channel) => channel.ToString(System.Globalization.CultureInfo.InvariantCulture); +} + +/// EFX standard reverb parameters (OpenAL Soft preset values, efx-presets.h). +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); + + /// EFX_REVERB_PRESET_CAVE: long, bright, dense. + 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 } + +/// Stateful RBJ-cookbook biquad (transposed direct form II): keeps its state between +/// calls, so a stream filters block after block without seams. +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); + + /// For a filter run at another rate (e.g. a control-rate modulator). + 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; + +/// Whole MP3 files decoded at once (NLayer), downmixed to mono at the file's rate like +/// : for positioned sounds and loop emitters. Undecodable → null. +public static class Mp3Reader +{ + /// Length in seconds from the frame headers (NLayer's scan, no decoding: ~1/60 of 's cost), + /// so a caller can time a sound and decode it elsewhere. Undecodable → null. + 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(); + 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; + } + } +} + +/// 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 ; a once-only stream goes silent and reports +/// . For music and speech. +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; + +/// 16-bit mono PCM helpers. +public static class Pcm +{ + public const int SampleRate = 44100; + + /// Interleaved float frames (e.g. decoded OGG) → mono 16-bit. + 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; + +/// Signal measurements for audio tests and reports: zero crossings, RMS, spectral centroid. +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)); + } + + /// Power-weighted mean frequency (where the energy is) of the loudest 2048-sample + /// window, naive DFT. Magnitude weighting over-counts faint broadband tails. + 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; + +/// 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. +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(); + + /// Uniform in [−1, 1). + public float Signed() => Next01() * 2f - 1f; + + /// FNV-1a: stable across runs (string.GetHashCode isn't). + 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 } + +/// Placeholder sound synthesis (audio spec §3): float buffers at 44.1 kHz, +/// oscillators with exponential sweeps, noise, envelopes, swept RBJ biquads. +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); + + /// Oscillator whose pitch sweeps exponentially from f0 to f1 over the buffer. + 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; + } + + /// FM: carrier f, modulator at f·ratio, index sweeping index0 → index1 (bells, chirps). + 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; + } + + /// Pink-ish noise (Paul Kellet's economy filter): softer, like wind and leaves. + 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; + } + + /// Linear attack to 1, then exponential decay with time constant . + 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; + } + + /// Adds into at a time offset (clipped to fit). + 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); + + /// Biquad with its cutoff swept exponentially f0 → f1, retuned every 32 samples. In place. + 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; + } + + /// Makes a loop seamless: the extra at the end fade + /// into the start, so the result wraps like any adjacent pair of samples. + 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; + } + + /// Normalises the peak to (0–1 FS) and, unless it's a loop, + /// fades 2 ms at both ends so starts and stops never click. + 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); + +/// 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. +public static class WavReader +{ + private const int FormatPcm = 1, FormatMsAdpcm = 2, FormatFloat = 3, FormatExtensible = 0xFFFE; + + public static PcmClip? Read(ReadOnlySpan 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.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 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]; + + /// 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. + private static short[]? MsAdpcm(ReadOnlySpan data, ReadOnlySpan 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(); + Span 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 frame, int channels) => (short)(channels == 1 ? frame[0] : (frame[0] + frame[1]) / 2); + + private static short[]? Decode(ReadOnlySpan 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; + +/// Opt-in debug categories from one environment value: a comma list, +/// case-insensitive; all turns on every category but not +/// switches (perf experiments like nohud). +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; + +/// The lib's own debug categories (input, script, audio), read +/// from the game's variable: call first in Main, before any lib +/// code runs. Gate each call on its flag so disabled logging costs one bool check and no +/// string formatting. +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; + +/// One snapshot of the rolling frame window (game-loop spec §5). Times in ms. +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); + +/// +/// 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. +/// +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]; + } + + /// GPU time of one frame (timer queries report a few frames late, so these + /// roll separately from ). + 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; } + + /// Time since the previous frame started. + /// CPU time spent inside this frame's render callback. + /// Simulation ticks run this frame. + /// CPU time those ticks took. + 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++; + } + + /// Copies frame intervals in ms, oldest first; returns how many were written. + public int CopyIntervalsMs(Span 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; + } + + /// Nearest-rank percentile of the first sorted intervals. + 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; + +/// Per-frame CSV rows for perf-log stall hunts. Fixed columns first, then the +/// caller's own ( names, values, same order). +/// Invariant culture; times in ms with 3 decimals; blank = unknown (e.g. GPU before a +/// timer query reports). +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); + } + + /// Seconds → ms with 3 decimals (invariant); blank for null. + 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 @@ + + + + + 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, +} + +/// 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. +public readonly record struct GamepadState(Vector2 LeftStick, Vector2 RightStick, float LeftTrigger, float RightTrigger, PadButtons Buttons) +{ + public bool Held(PadButtons button) => (Buttons & button) != 0; +} + +/// Input-script pad events (PadA PadB PadX PadY PadBack PadStart PadUp PadDown +/// PadLeft PadRight PadLB PadRB PadLT PadRT, case-insensitive): triggers go full on/off. +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 } + +/// Physical key name (Down/Up), button label (Click) or text (Type). +public readonly record struct ScriptEvent(double Time, ScriptAction Action, string Arg); + +/// Timed input for smoke runs (the host replays it as real key events and button +/// clicks). One per line: TIME ACTION ARGS, TIME absolute seconds or +seconds +/// after the previous line; actions down K, up K, press K, +/// hold K SECONDS, click LABEL, type TEXT (into the focused text box); # comments. +public sealed class InputScript +{ + public const double PressSeconds = 0.1; + + private int _next; + + public IReadOnlyList Events { get; } + + public bool Finished => _next >= Events.Count; + + private InputScript(List events) => Events = events; + + public static InputScript Parse(string text) + { + var events = new List(); + 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 + } + + /// Appends every not-yet-returned event with Time ≤ . + public void Due(double now, List 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; + +/// 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. +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; + +/// The shared digest stdout line: digest <frame> <game seconds 0.###> <16 hex>. +public static class DigestLine +{ + public static string Format(long frame, double gameSeconds, ulong hash) => + string.Create(CultureInfo.InvariantCulture, $"digest {frame} {gameSeconds:0.###} {hash:x16}"); + + /// Every frames (frame 0 and every ≤ 0 never). + 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; + +/// Game time per frame: deterministic → a fixed whatever the wall clock; otherwise the wall dt +/// clamped to [0, maxDt]. (e.g. a map still loading) → no game time and no frame counted. +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; } + + /// The game dt of this frame (0 when held); counts the frame unless held. + 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; + +/// Runs frames as fast as possible: frame(n) for n = 1, 2, … until it returns false, +/// is reached, or (Capped = true). +public static class HeadlessLoop +{ + public const long DefaultCap = 100_000; + + public static (long Frames, bool Capped) Run(Func 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; + +/// 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. +public static class RouteLegs +{ + /// Breadth-first over [0,width)×[0,height), 8 neighbours, until : each reached + /// cell → the one it was reached from (start → itself). + 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 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; + } + + /// The cells from (excluded) to (included); null when walled + /// off. + public static List<(long X, long Y)>? Path((long X, long Y) start, (long X, long Y) goal, long width, long height, Func blocked) + { + var from = Reach(start, goal, width, height, blocked); + return from.ContainsKey(goal) ? Trace(from, start, goal) : null; + } + + /// Legs along : from each stop the farthest path cell (the + /// game's router takes the trip), until the goal; null when the router takes no further cell from a stop. + 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 _); + + /// + /// The stop no leg leaves (refusals name it). + 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; + } + + /// The cells (blocked by the game's scripts, opened later) the way would cross once + /// open, start to goal, at most ; empty when walled off anyway or open already. + public static List<(long X, long Y)> Seals((long X, long Y) start, (long X, long Y) goal, long width, long height, + Func blocked, Func 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(); + } + + /// Portal hops: breadth-first over portal exits from ; the portals to take, in order, so + /// that (a walk inside one area) gets to ; empty when it walks there; + /// null when no chain does. + public static List? Hops(TPos start, TPos goal, IReadOnlyList portals, Func at, + Func exit, Func reaches) where TPos : notnull + { + var via = new Dictionary { [start] = (start, default) }; + var queue = new Queue([start]); + while (queue.Count > 0) + { + var here = queue.Dequeue(); + if (reaches(here, goal)) + { + var hops = new List(); + for (var p = here; !EqualityComparer.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; + +/// Shared run flags: --deterministic [seed] (fixed step, seeded streams), --digest [every=N] +/// (state hash lines), --headless (no window; implies deterministic). null = the game's default. +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; + + /// Consumes the run flag at (and its optional value, advancing ); + /// false when args[i] is no run flag. + public static bool TryTake(IReadOnlyList 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 Scopes, List Labels); + +public sealed record CachedCheckpoint(RunnerState State, byte[] Game); + +public static class CheckpointKeys +{ + public const string RunnerVersion = "run-scripts 1"; + + public static IReadOnlyDictionary 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(); + 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(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 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 args); + long? Constant(string name); +} + +/// 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. +public abstract record Cond +{ + public abstract RunValue Eval(IConditionContext c); + + public bool Test(IConditionContext c) => Truthy(Eval(c)); + + public abstract IEnumerable<(string Name, IReadOnlyList Args)> Probes(); + + public abstract IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => A.Probes(); + public override IEnumerable 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)> Probes() => A.Probes().Concat(B.Probes()); + public override IEnumerable 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)> Probes() => []; + public override IEnumerable Bare() => []; +} + +/// A probe call name(args), or a bare name (probe if the game has it, else a constant). +public sealed record NameCond(string Name, IReadOnlyList 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)> Probes() => [(Name, Args)]; + public override IEnumerable 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 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 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 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 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 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 Lex(string text) + { + var tokens = new List(); + 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 } + +/// One simulated user input event at virtual-screen coordinates. +public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "", + string Text = "", int Delta = 0); + +/// An event due frames after its step began. +public sealed record PlannedInput(int Frame, InputEvent Event); + +public sealed record InputPlan(IReadOnlyList Events) +{ + public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame); +} + +/// The game's input entry: the real UI in a window, its layout + hit-testing headless. +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 +{ + /// x,y or a locator @name(args) (args comma separated). + 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 args, Func position) + { + List 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; + +/// A verb the lib defines. = implemented by the game's +/// entry of the same name; otherwise the runner implements it. +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 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 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; + +/// A game's run-script adapter. holds the lib's game-hooked verbs by their lib name +/// (talk, walk-to, cheat, …) and the game's own verbs. Player-kind actions return input plans only; +/// the lib injects them. +public interface IRunGame +{ + string Name { get; } + string ContentVersion { get; } + IInputRouter Input { get; } + IReadOnlyDictionary Probes { get; } + IReadOnlyDictionary Constants { get; } + IReadOnlyDictionary Locators { get; } + IReadOnlyDictionary Actions { get; } + IRunRng? Rng { get; } + bool SupportsSnapshot { get; } + byte[] SnapshotSave(); + /// 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. + string? SnapshotRefusal() => null; + void SnapshotRestore(byte[] data); + /// The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame + /// (), in which the host advances no game frame. + bool RestoreDeferred => false; + ulong Digest(); + string Dump(); + void Advance(); +} + +public interface IProbe +{ + int MinArgs { get; } + int MaxArgs { get; } + RunValue Eval(IReadOnlyList 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; +} + +/// Finds where to aim (virtual-screen position) from game state, as a player reads the screen. +public interface ILocator +{ + LocatorResult Locate(IReadOnlyList args); +} + +/// 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. +public interface IRunAction +{ + VerbKind Kind { get; } + int MinArgs { get; } + int MaxArgs { get; } + ActionStart Begin(IReadOnlyList 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; + +/// Auto-output filter: a line of level prints when level ≤ max(current, floor); +/// = log lines and failures. +public sealed class RunOutput(Action 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 Chain, string Step, string? Label, string Reason, + IReadOnlyList Recent, string Dump, IReadOnlyDictionary 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 Checkpoints, + IReadOnlyList 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 } + +/// One parsed line (or block header) of a .steps file; = the trimmed source line. +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 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 Body) : Node(At, Text); +public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList Params, IReadOnlyList Body) : Node(At, Text); + +public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList VarFiles, + IReadOnlyList Body, IReadOnlyList Errors); + +public static class ScriptFormat +{ + /// Newest format version this runner knows; every older one stays supported. + 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 Code { get; } = []; + public List Errors { get; } = []; + public Dictionary Defs { get; } = new(StringComparer.Ordinal); + public List Includes { get; } = []; + public List Calls { get; } = []; + public List Chain { get; } = []; + public HashSet Checkpoints { get; } = new(StringComparer.Ordinal); + public List> Known { get; } = []; + public Dictionary Vars { get; } = new(StringComparer.Ordinal); + public HashSet Locked { get; } = new(StringComparer.Ordinal); + public int TryDepth { get; set; } + } + + public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary 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 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(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; + +/// Format v1 parser: one file → ; every error collected (never throws). +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(); + var lines = new List(); + 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(); + 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 errors, ref RunMode? mode, ref ulong? seed, List 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 Block(List lines, ref int pos, int indent, string path, List errors) + { + var nodes = new List(); + 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; + } + + /// A line ending in ':' is a block header when it has an indented body or starts with a block keyword; + /// otherwise a statement (log Done:). + 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 body, List 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 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 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 } + +/// One flattened instruction. = include / call sites, outermost first. +public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList Chain, int Format) +{ + public string A { get; init; } = ""; + public string B { get; init; } = ""; + public int Timeout { get; init; } + public IReadOnlyList> 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 Vars, + IReadOnlySet Locked, IReadOnlyList Code, IReadOnlyList Errors); + +public interface IScriptSource +{ + string? Read(string path); +} + +public sealed class MemoryScriptSource(IReadOnlyDictionary files) : IScriptSource +{ + public string? Read(string path) => files.GetValueOrDefault(path); +} + +/// Files under ; paths are '/'-separated and relative to it. +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(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; + +/// Run-script CLI options (--script, --var k=v, --output, --no-cache, --no-snapshots, --from, +/// --until, --nondeterministic, --step-cap, --report-dir); determinism and seed come from +/// . +public sealed class ScriptRunFlags +{ + public string? Script { get; set; } + public Dictionary 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; + /// Overrides the script's seed header; neither → 0 and the game's rng is left alone. + 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; + /// false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing + /// cached (--no-snapshots). + 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; } + /// The value a plan's frame 0 lands on (0 for the step's own plan). + 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