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.Audio/Godosa.Audio.csproj | 18 + src/Godosa.Audio/OpenAlAudio.Reverb.cs | 83 +++ src/Godosa.Audio/OpenAlAudio.Streams.cs | 119 +++++ src/Godosa.Audio/OpenAlAudio.cs | 360 +++++++++++++ src/Godosa.Core.Tests/Audio/AudioMixTests.cs | 22 + src/Godosa.Core.Tests/Audio/Mp3Tests.cs | 90 ++++ src/Godosa.Core.Tests/Audio/SynthTests.cs | 122 +++++ src/Godosa.Core.Tests/Audio/WavReaderTests.cs | 127 +++++ .../Diagnostics/DebugFlagsTests.cs | 59 +++ .../Diagnostics/FrameTimeStatsTests.cs | 97 ++++ .../Diagnostics/GpuAndCsvTests.cs | 53 ++ src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs | 38 ++ src/Godosa.Core.Tests/Godosa.Core.Tests.csproj | 13 + src/Godosa.Core.Tests/Input/GamepadScriptTests.cs | 56 ++ src/Godosa.Core.Tests/Input/InputScriptTests.cs | 73 +++ src/Godosa.Core.Tests/Input/MenuCursorTests.cs | 42 ++ src/Godosa.Core.Tests/Runs/RouteLegsTests.cs | 116 +++++ src/Godosa.Core.Tests/Runs/RunsTests.cs | 149 ++++++ .../Runs/Scripts/ActionInputTests.cs | 47 ++ .../Runs/Scripts/CheckpointCacheTests.cs | 185 +++++++ .../Runs/Scripts/ConditionTests.cs | 63 +++ src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs | 100 ++++ .../Runs/Scripts/ReviewFixTests.cs | 157 ++++++ .../Runs/Scripts/RunReportTests.cs | 83 +++ .../Runs/Scripts/ScriptLoaderTests.cs | 103 ++++ .../Runs/Scripts/ScriptParserTests.cs | 111 ++++ .../Runs/Scripts/ScriptRunFlagsTests.cs | 54 ++ .../Runs/Scripts/ScriptRunnerStructureTests.cs | 85 +++ .../Runs/Scripts/ScriptRunnerTests.cs | 224 ++++++++ .../Runs/Scripts/ScriptValidatorTests.cs | 63 +++ .../Runs/Scripts/StepLexerTests.cs | 58 +++ src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs | 84 +++ src/Godosa.Core.Tests/SettingsFileTests.cs | 90 ++++ 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 ++ src/Godosa.Desktop/Buttons.cs | 15 + src/Godosa.Desktop/CursorWarp.cs | 66 +++ src/Godosa.Desktop/DisplayMode.cs | 19 + src/Godosa.Desktop/FrameTimeGraph.cs | 65 +++ src/Godosa.Desktop/GlProgram.cs | 35 ++ src/Godosa.Desktop/Godosa.Desktop.csproj | 12 + src/Godosa.Desktop/GpuTimer.cs | 71 +++ src/Godosa.Desktop/InputScriptPlayer.cs | 124 +++++ src/Godosa.Desktop/PadNavigator.cs | 85 +++ src/Godosa.Gamepad/Godosa.Gamepad.csproj | 11 + src/Godosa.Gamepad/SdlGamepadSource.cs | 107 ++++ 85 files changed, 7385 insertions(+) create mode 100644 src/Godosa.Audio/Godosa.Audio.csproj create mode 100644 src/Godosa.Audio/OpenAlAudio.Reverb.cs create mode 100644 src/Godosa.Audio/OpenAlAudio.Streams.cs create mode 100644 src/Godosa.Audio/OpenAlAudio.cs create mode 100644 src/Godosa.Core.Tests/Audio/AudioMixTests.cs create mode 100644 src/Godosa.Core.Tests/Audio/Mp3Tests.cs create mode 100644 src/Godosa.Core.Tests/Audio/SynthTests.cs create mode 100644 src/Godosa.Core.Tests/Audio/WavReaderTests.cs create mode 100644 src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs create mode 100644 src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs create mode 100644 src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs create mode 100644 src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs create mode 100644 src/Godosa.Core.Tests/Godosa.Core.Tests.csproj create mode 100644 src/Godosa.Core.Tests/Input/GamepadScriptTests.cs create mode 100644 src/Godosa.Core.Tests/Input/InputScriptTests.cs create mode 100644 src/Godosa.Core.Tests/Input/MenuCursorTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/RouteLegsTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/RunsTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs create mode 100644 src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs create mode 100644 src/Godosa.Core.Tests/SettingsFileTests.cs 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 create mode 100644 src/Godosa.Desktop/Buttons.cs create mode 100644 src/Godosa.Desktop/CursorWarp.cs create mode 100644 src/Godosa.Desktop/DisplayMode.cs create mode 100644 src/Godosa.Desktop/FrameTimeGraph.cs create mode 100644 src/Godosa.Desktop/GlProgram.cs create mode 100644 src/Godosa.Desktop/Godosa.Desktop.csproj create mode 100644 src/Godosa.Desktop/GpuTimer.cs create mode 100644 src/Godosa.Desktop/InputScriptPlayer.cs create mode 100644 src/Godosa.Desktop/PadNavigator.cs create mode 100644 src/Godosa.Gamepad/Godosa.Gamepad.csproj create mode 100644 src/Godosa.Gamepad/SdlGamepadSource.cs (limited to 'src') diff --git a/src/Godosa.Audio/Godosa.Audio.csproj b/src/Godosa.Audio/Godosa.Audio.csproj new file mode 100644 index 0000000..a478730 --- /dev/null +++ b/src/Godosa.Audio/Godosa.Audio.csproj @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + diff --git a/src/Godosa.Audio/OpenAlAudio.Reverb.cs b/src/Godosa.Audio/OpenAlAudio.Reverb.cs new file mode 100644 index 0000000..b93e74a --- /dev/null +++ b/src/Godosa.Audio/OpenAlAudio.Reverb.cs @@ -0,0 +1,83 @@ +using Silk.NET.OpenAL.Extensions.Creative; + +namespace Godosa.Audio; + +/// One EFX reverb for the region (audio spec §2): positioned sources send to it, 2D +/// ones (beds, music, interface) don't. Air absorption dulls distant sounds. +public sealed unsafe partial class OpenAlAudio +{ + private EffectExtension? _efx; + private uint _effect, _effectSlot; + private ReverbPreset? _reverbApplied; + private ReverbPreset? _reverbWanted; + private bool _reverbInitialised; + + /// The current region's reverb, or null for none (UI thread). + public ReverbPreset? Reverb + { + set { lock (_poseLock) _reverbWanted = value; } + } + + private void InitReverb() + { + if (!_al!.TryGetExtension(out _efx)) _efx = null; + if (_efx is null) + { + if (EngineDebug.Audio) EngineDebug.Write("audio", "no EFX: no reverb"); + return; + } + _effect = _efx.GenEffects(1)[0]; + _efx.SetEffectProperty(_effect, EffectInteger.EffectType, (int)EffectType.Reverb); + _effectSlot = _efx.GenAuxiliaryEffectSlots(1)[0]; + _reverbApplied = null; + _reverbInitialised = false; + } + + private void UpdateReverb() + { + if (_efx is null) return; + ReverbPreset? wanted; + lock (_poseLock) wanted = _reverbWanted; + if (_reverbInitialised && _reverbApplied == wanted) return; + _reverbInitialised = true; + _reverbApplied = wanted; + if (wanted is not { } p) + { + // No effect in the slot: sends to it are dropped and the mixer runs no reverb. + _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0); + if (EngineDebug.Audio) EngineDebug.Write("audio", "reverb off"); + return; + } + _efx.SetEffectProperty(_effect, EffectFloat.ReverbDensity, p.Density); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbDiffusion, p.Diffusion); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbGain, p.Gain); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbGainHF, p.GainHF); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayTime, p.DecayTime); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbDecayHFRatio, p.DecayHFRatio); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsGain, p.ReflectionsGain); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbReflectionsDelay, p.ReflectionsDelay); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbGain, p.LateReverbGain); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbLateReverbDelay, p.LateReverbDelay); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbAirAbsorptionGainHF, p.AirAbsorptionGainHF); + _efx.SetEffectProperty(_effect, EffectFloat.ReverbRoomRolloffFactor, p.RoomRolloffFactor); + _efx.SetEffectProperty(_effect, EffectInteger.ReverbDecayHFLimit, p.DecayHFLimit ? 1 : 0); + _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, (int)_effect); // re-attach applies the change + if (EngineDebug.Audio) EngineDebug.Write("audio", $"reverb {(p == ReverbPreset.Forest ? "forest" : p == ReverbPreset.Cave ? "cave" : "plain")}"); + } + + private void SendToReverb(uint source, bool positioned) + { + if (_efx is null) return; + _efx.SetSourceProperty(source, EFXSourceInteger3.AuxiliarySendFilter, positioned ? (int)_effectSlot : 0, 0, 0); + _efx.SetSourceProperty(source, EFXSourceFloat.AirAbsorptionFactor, positioned ? 1f : 0f); + } + + private void ReleaseReverb() + { + if (_efx is null) return; + _efx.SetAuxiliaryEffectSlotProperty(_effectSlot, EffectSlotInteger.Effect, 0); + _efx.DeleteAuxiliaryEffectSlot(_effectSlot); + _efx.DeleteEffect(_effect); + _efx = null; + } +} diff --git a/src/Godosa.Audio/OpenAlAudio.Streams.cs b/src/Godosa.Audio/OpenAlAudio.Streams.cs new file mode 100644 index 0000000..435c058 --- /dev/null +++ b/src/Godosa.Audio/OpenAlAudio.Streams.cs @@ -0,0 +1,119 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using Silk.NET.OpenAL; + +namespace Godosa.Audio; + +/// Endless stereo streams (ambience beds, music — audio spec §5–§6): each holds one +/// source with a queue of buffers the audio thread refills from its generator. +public sealed unsafe partial class OpenAlAudio +{ + private const int StreamBuffers = 4, StreamFrames = 2048; // ~190 ms queued + + public sealed class StreamHandle + { + internal StreamHandle(IStereoStream source, int channel) => (Source, Channel) = (source, channel); + internal IStereoStream Source { get; } + internal int Channel { get; } + internal int Slot = -1; + internal uint[] Buffers = System.Array.Empty(); + private int _gainBits = System.BitConverter.SingleToInt32Bits(1f); // written by the UI thread + + public float Gain + { + get => System.BitConverter.Int32BitsToSingle(Volatile.Read(ref _gainBits)); + set => Volatile.Write(ref _gainBits, System.BitConverter.SingleToInt32Bits(value)); + } + } + + private readonly bool[] _streamSlot = new bool[SourceCount]; + private readonly List _streams = new(); // audio thread only + private readonly ConcurrentQueue<(StreamHandle Stream, bool Add)> _streamCommands = new(); + private readonly float[] _streamMix = new float[StreamFrames * 2]; + private readonly short[] _streamPcm = new short[StreamFrames * 2]; + + public StreamHandle AddStream(IStereoStream source, int channel, float gain = 1f) + { + var handle = new StreamHandle(source, channel) { Gain = gain }; + _streamCommands.Enqueue((handle, true)); + return handle; + } + + public void RemoveStream(StreamHandle handle) => _streamCommands.Enqueue((handle, false)); + + private void UpdateStreams(AudioPauseState state) + { + var al = _al!; + while (_streamCommands.TryDequeue(out var command)) + { + if (command.Add) _streams.Add(command.Stream); + else if (_streams.Remove(command.Stream)) Release(command.Stream); + } + foreach (var s in _streams) + { + if (s.Slot < 0 && !Claim(s)) continue; + uint source = _sources[s.Slot]; + _gain[s.Slot] = s.Gain; + SetGain(s.Slot, s.Gain * _mix.ChannelGain(s.Channel, state)); + al.GetSourceProperty(source, GetSourceInteger.BuffersProcessed, out int processed); + for (int i = 0; i < processed; i++) + { + uint buffer; + al.SourceUnqueueBuffers(source, 1, &buffer); + Fill(s, buffer); + al.SourceQueueBuffers(source, 1, &buffer); + } + if (SourceState(source) != Silk.NET.OpenAL.SourceState.Playing) al.SourcePlay(source); // start, or recover from a starve + } + } + + private bool Claim(StreamHandle s) + { + var al = _al!; + int slot = FreeSource(); + if (slot < 0) return false; + s.Slot = slot; + _streamSlot[slot] = true; + _channel[slot] = s.Channel; + _appliedGain[slot] = -1f; + _pausedByUs[slot] = false; + uint source = _sources[slot]; + al.SetSourceProperty(source, SourceInteger.Buffer, 0); + al.SetSourceProperty(source, SourceBoolean.Looping, false); + al.SetSourceProperty(source, SourceBoolean.SourceRelative, true); + al.SetSourceProperty(source, SourceVector3.Position, System.Numerics.Vector3.Zero); + al.SetSourceProperty(source, SourceFloat.Pitch, 1f); + SendToReverb(source, positioned: false); + s.Buffers = al.GenBuffers(StreamBuffers); + foreach (uint b in s.Buffers) Fill(s, b); + fixed (uint* ids = s.Buffers) al.SourceQueueBuffers(source, StreamBuffers, ids); + if (EngineDebug.Audio) EngineDebug.Write("audio", $"stream {s.Source.GetType().Name} {s.Channel} on source {slot}"); + return true; + } + + private void Fill(StreamHandle s, uint buffer) + { + s.Source.Render(_streamMix, StreamFrames); + for (int i = 0; i < _streamMix.Length; i++) _streamPcm[i] = Pcm.ToShort(_streamMix[i]); + _al!.BufferData(buffer, BufferFormat.Stereo16, _streamPcm, Pcm.SampleRate); + } + + private void Release(StreamHandle s) + { + if (s.Slot < 0) return; + var al = _al!; + uint source = _sources[s.Slot]; + al.SourceStop(source); + al.SetSourceProperty(source, SourceInteger.Buffer, 0); // detaches the queue + al.DeleteBuffers(s.Buffers); + _streamSlot[s.Slot] = false; + s.Slot = -1; + } + + /// Before the context goes (HRTF change): streams re-claim a source afterwards. + private void ReleaseStreams() + { + foreach (var s in _streams) Release(s); + } +} diff --git a/src/Godosa.Audio/OpenAlAudio.cs b/src/Godosa.Audio/OpenAlAudio.cs new file mode 100644 index 0000000..7a0d59b --- /dev/null +++ b/src/Godosa.Audio/OpenAlAudio.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; +using Silk.NET.OpenAL; + +namespace Godosa.Audio; + +/// +/// OpenAL Soft output on its own thread (every AL call happens there; other threads post +/// sounds, loops, streams and the listener pose). Mix policy comes from the game's +/// . No device or any AL failure → silent (logged, never throws). +/// +public sealed unsafe partial class OpenAlAudio : IDisposable +{ + private const int SourceCount = 32; + private const int MaxQueued = 64; + private const int UpdateMs = 10; + public const float ReferenceDistance = 2f, MaxDistance = 60f; + + // ALC_SOFT_HRTF (not wrapped by Silk.NET): context attribute + status query. + private const int AlcHrtfSoft = 0x1992, AlcHrtfStatusSoft = 0x1993, AlcDontCareSoft = 0x0002; + // Mix at our buffers' rate: no per-source resampling (~15 % of the mixer, measured). + private const int AlcFrequency = 0x1007; + + public static OpenAlAudio? Current { get; private set; } + + /// A one-shot sound. runs once per + /// (the buffer is cached); no position = heard in the head (2D). Falloff: inverse distance, + /// gain = Reference / distance, distance clamped to [Reference, Max] (world units of the game). + /// (distance → gain factor, audio thread) replaces that curve for a positioned sound: + /// OpenAL only pans it, the factor is applied every update (the game's own attenuation). + public readonly record struct Sound(string Key, Func Render, int Channel, + Vector3? Position = null, float Gain = 1f, float Pitch = 1f, float Reference = ReferenceDistance, float Max = MaxDistance, + Func? Falloff = null); + + /// A looping sound that follows something (a projectile in flight). Posted as the + /// full set every frame: new keys start, missing keys stop. + public readonly record struct LoopEmitter(object Key, Sound Sound, Vector3 Position, Vector3 Velocity); + + private readonly IAudioMix _mix; + private readonly object _loopLock = new(); + private List _loopsPosted = new(), _loopsWorking = new(); + private readonly Dictionary _loopSlots = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _loopSeen = new(ReferenceEqualityComparer.Instance); + private readonly List _loopGone = new(); + private readonly ConcurrentQueue _queue = new(); + private readonly ConcurrentQueue _stops = new(); + private readonly Thread _thread; + private readonly object _poseLock = new(); + private volatile bool _running = true; + private Vector3 _listenerPosition, _listenerForward = -Vector3.UnitZ, _listenerUp = Vector3.UnitY; + private int _state; // AudioPauseState, written by the UI thread + + private ALContext? _alc; + private AL? _al; + private Device* _device; + private Context* _context; + private HrtfMode _hrtfApplied; + private readonly uint[] _sources = new uint[SourceCount]; + private readonly int[] _channel = new int[SourceCount]; + private readonly float[] _gain = new float[SourceCount]; + private readonly float[] _appliedGain = new float[SourceCount]; // last gain sent to AL (skip repeats) + private readonly Func?[] _falloff = new Func?[SourceCount]; // null = OpenAL's curve + private readonly Vector3[] _position = new Vector3[SourceCount]; + private Vector3 _appliedPosition, _appliedForward; + private readonly bool[] _pausedByUs = new bool[SourceCount]; + private readonly Dictionary _buffers = new(); + + public static OpenAlAudio Start(IAudioMix mix) => Current = new OpenAlAudio(mix); + + private OpenAlAudio(IAudioMix mix) + { + _mix = mix; + _thread = new Thread(Run) { IsBackground = true, Name = "audio" }; + _thread.Start(); + } + + public AudioPauseState State + { + set => Volatile.Write(ref _state, (int)value); + } + + public void Play(in Sound sound) + { + if (_queue.Count < MaxQueued) _queue.Enqueue(sound); + } + + /// Stops the one-shot sounds playing on (loops and streams keep going). Applied on + /// the audio thread before any sound queued after this call starts, so stop-then-play cuts cleanly (a voice line + /// replaced by the next one). + public void StopChannel(int channel) => _stops.Enqueue(channel); + + /// Replaces the set of loop emitters (UI thread, every rendered frame). + public void SetLoops(List loops) + { + lock (_loopLock) + { + _loopsPosted.Clear(); + _loopsPosted.AddRange(loops); + } + } + + /// Listener = the camera (UI thread, every rendered frame). + public void SetListener(Vector3 position, Vector3 forward, Vector3 up) + { + lock (_poseLock) (_listenerPosition, _listenerForward, _listenerUp) = (position, forward, up); + } + + public void Dispose() + { + _running = false; + _thread.Join(500); + if (ReferenceEquals(Current, this)) Current = null; + } + + private void Run() + { + try + { + Open(); + } + catch (Exception e) + { + Console.Error.WriteLine($"audio: disabled ({e.Message})"); + Close(); + } + while (_running) + { + try + { + if (_al is not null) Update(); + else _queue.Clear(); + } + catch (Exception e) + { + Console.Error.WriteLine($"audio: disabled after error ({e.Message})"); + Close(); + } + Thread.Sleep(UpdateMs); + } + Close(); + } + + private void Open() + { + _alc = ALContext.GetApi(soft: true); + _al = AL.GetApi(soft: true); + _device = _alc.OpenDevice(""); + if (_device == null) throw new InvalidOperationException("no audio device"); + CreateContext(_mix.Hrtf); + } + + private void CreateContext(HrtfMode mode) + { + int hrtf = mode switch { HrtfMode.On => 1, HrtfMode.Off => 0, _ => AlcDontCareSoft }; + int* attributes = stackalloc int[] { AlcHrtfSoft, hrtf, AlcFrequency, Pcm.SampleRate, 0 }; + _context = _alc!.CreateContext(_device, attributes); + if (_context == null) throw new InvalidOperationException("no audio context"); + _alc.MakeContextCurrent(_context); + _hrtfApplied = mode; + + var al = _al!; + al.DistanceModel(DistanceModel.InverseDistanceClamped); + fixed (uint* ids = _sources) al.GenSources(SourceCount, ids); + foreach (uint s in _sources) + { + al.SetSourceProperty(s, SourceFloat.ReferenceDistance, ReferenceDistance); + al.SetSourceProperty(s, SourceFloat.MaxDistance, MaxDistance); + al.SetSourceProperty(s, SourceFloat.RolloffFactor, 1f); + } + Array.Clear(_pausedByUs); + Array.Fill(_appliedGain, -1f); + (_appliedPosition, _appliedForward) = (new Vector3(float.NaN), Vector3.Zero); + InitReverb(); + + int status = -1; + _alc.GetContextProperty(_device, (GetContextInteger)AlcHrtfStatusSoft, 1, &status); + if (EngineDebug.Audio) + EngineDebug.Write("audio", $"device '{_alc.GetContextProperty(_device, GetContextString.DeviceSpecifier)}' hrtf request {mode} status {status}"); + } + + /// HRTF only changes on a new context; buffers belong to the device and stay. + private void DestroyContext() + { + if (_context == null) return; + ReleaseStreams(); + ReleaseReverb(); + fixed (uint* ids = _sources) _al!.DeleteSources(SourceCount, ids); + _loopSlots.Clear(); + _alc!.MakeContextCurrent(null); + _alc.DestroyContext(_context); + _context = null; + } + + private void Update() + { + var al = _al!; + if (_mix.Hrtf != _hrtfApplied) + { + DestroyContext(); + CreateContext(_mix.Hrtf); + } + + var state = (AudioPauseState)Volatile.Read(ref _state); + Vector3 position, forward, up; + lock (_poseLock) (position, forward, up) = (_listenerPosition, _listenerForward, _listenerUp); + if (position != _appliedPosition || forward != _appliedForward) + { + al.SetListenerProperty(ListenerVector3.Position, position); + float* orientation = stackalloc float[] { forward.X, forward.Y, forward.Z, up.X, up.Y, up.Z }; + al.SetListenerProperty(ListenerFloatArray.Orientation, orientation); + (_appliedPosition, _appliedForward) = (position, forward); + } + + bool pauseEffects = _mix.WorldPaused(state); + for (int i = 0; i < SourceCount; i++) + { + var s = SourceState(_sources[i]); + if (s is not (Silk.NET.OpenAL.SourceState.Playing or Silk.NET.OpenAL.SourceState.Paused)) continue; + SetGain(i, _gain[i] * Attenuation(i, position) * _mix.ChannelGain(_channel[i], state)); + if (!_mix.Pausable(_channel[i])) continue; + if (pauseEffects && s == Silk.NET.OpenAL.SourceState.Playing) + { + al.SourcePause(_sources[i]); + _pausedByUs[i] = true; + } + else if (!pauseEffects && _pausedByUs[i]) + { + al.SourcePlay(_sources[i]); + _pausedByUs[i] = false; + } + } + + while (_stops.TryDequeue(out int stop)) + for (int i = 0; i < SourceCount; i++) + if (_channel[i] == stop && !_streamSlot[i] && !_loopSlots.ContainsValue(i)) + al.SourceStop(_sources[i]); + while (_queue.TryDequeue(out var sound)) + { + if (pauseEffects && _mix.Pausable(sound.Channel)) continue; // a paused world makes no new sounds + int slot = FreeSource(); + if (slot < 0) break; + Start(slot, sound, state); + } + UpdateLoops(state); + UpdateStreams(state); + UpdateReverb(); + } + + private void UpdateLoops(AudioPauseState state) + { + var al = _al!; + lock (_loopLock) (_loopsPosted, _loopsWorking) = (_loopsWorking, _loopsPosted); + _loopSeen.Clear(); + foreach (var loop in _loopsWorking) + { + _loopSeen.Add(loop.Key); + if (!_loopSlots.TryGetValue(loop.Key, out int slot)) + { + if (_mix.WorldPaused(state) && _mix.Pausable(loop.Sound.Channel)) continue; + slot = FreeSource(); + if (slot < 0) continue; + Start(slot, loop.Sound, state, looping: true); + _loopSlots[loop.Key] = slot; + } + _gain[slot] = loop.Sound.Gain; // a fleeing critter gets louder and higher + al.SetSourceProperty(_sources[slot], SourceFloat.Pitch, loop.Sound.Pitch); + al.SetSourceProperty(_sources[slot], SourceVector3.Position, loop.Position); + _position[slot] = loop.Position; + al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, loop.Velocity); // doppler + } + _loopGone.Clear(); + foreach (var (key, slot) in _loopSlots) + if (!_loopSeen.Contains(key)) + { + al.SourceStop(_sources[slot]); + al.SetSourceProperty(_sources[slot], SourceBoolean.Looping, false); + al.SetSourceProperty(_sources[slot], SourceVector3.Velocity, Vector3.Zero); + _loopGone.Add(key); + } + foreach (var key in _loopGone) _loopSlots.Remove(key); + } + + /// The slot's own falloff at the listener; 1 without one. + private float Attenuation(int slot, Vector3 listener) => + _falloff[slot] is { } f ? f(Vector3.Distance(_position[slot], listener)) : 1f; + + private void SetGain(int slot, float gain) + { + if (gain == _appliedGain[slot]) return; + _al!.SetSourceProperty(_sources[slot], SourceFloat.Gain, gain); + _appliedGain[slot] = gain; + } + + private Silk.NET.OpenAL.SourceState SourceState(uint source) + { + _al!.GetSourceProperty(source, GetSourceInteger.SourceState, out int s); + return (Silk.NET.OpenAL.SourceState)s; + } + + private int FreeSource() + { + for (int i = 0; i < SourceCount; i++) + { + if (_streamSlot[i]) continue; // a starved stream stops, but its source stays taken + var s = SourceState(_sources[i]); + if (s is Silk.NET.OpenAL.SourceState.Initial or Silk.NET.OpenAL.SourceState.Stopped) return i; + } + return -1; + } + + private void Start(int slot, in Sound sound, AudioPauseState state, bool looping = false) + { + var al = _al!; + uint source = _sources[slot]; + if (!_buffers.TryGetValue(sound.Key, out uint buffer)) + { + buffer = al.GenBuffer(); + var clip = sound.Render(); + al.BufferData(buffer, BufferFormat.Mono16, clip.Samples, clip.SampleRate); + _buffers[sound.Key] = buffer; + } + _channel[slot] = sound.Channel; + _gain[slot] = sound.Gain; + _pausedByUs[slot] = false; + al.SetSourceProperty(source, SourceInteger.Buffer, buffer); + al.SetSourceProperty(source, SourceFloat.Pitch, sound.Pitch); + al.SetSourceProperty(source, SourceFloat.ReferenceDistance, sound.Reference); + al.SetSourceProperty(source, SourceFloat.MaxDistance, sound.Max); + bool positioned = sound.Position is not null; + _falloff[slot] = positioned ? sound.Falloff : null; + _position[slot] = sound.Position ?? Vector3.Zero; + al.SetSourceProperty(source, SourceFloat.RolloffFactor, _falloff[slot] is null ? 1f : 0f); + _appliedGain[slot] = -1f; // force + SetGain(slot, sound.Gain * Attenuation(slot, _appliedPosition) * _mix.ChannelGain(sound.Channel, state)); + al.SetSourceProperty(source, SourceBoolean.SourceRelative, !positioned); + var at = sound.Position ?? Vector3.Zero; + al.SetSourceProperty(source, SourceVector3.Position, at); + al.SetSourceProperty(source, SourceBoolean.Looping, looping); + SendToReverb(source, positioned); + al.SourcePlay(source); + if (EngineDebug.Audio) EngineDebug.Write("audio", $"play {sound.Key} {_mix.ChannelName(sound.Channel)} {(positioned ? at.ToString("F1", null) : "2D")}"); + } + + private void Close() + { + if (_al is not null && _alc is not null && _device != null) + { + if (_context != null) + foreach (uint b in _buffers.Values) _al.DeleteBuffer(b); // AL calls need the context + _buffers.Clear(); + DestroyContext(); + _alc.CloseDevice(_device); + } + _device = null; + _al = null; + } +} diff --git a/src/Godosa.Core.Tests/Audio/AudioMixTests.cs b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs new file mode 100644 index 0000000..93a0bed --- /dev/null +++ b/src/Godosa.Core.Tests/Audio/AudioMixTests.cs @@ -0,0 +1,22 @@ +using Godosa.Core.Audio; +using Xunit; + +namespace Godosa.Core.Tests.Audio; + +public class AudioMixTests +{ + private sealed class Plain : IAudioMix + { + public HrtfMode Hrtf => default; + public float ChannelGain(int channel, AudioPauseState state) => 1f; + public bool Pausable(int channel) => true; + public bool WorldPaused(AudioPauseState state) => false; + } + + [Fact] + public void ChannelName_DefaultsToTheNumber() + { + IAudioMix mix = new Plain(); + Assert.Equal("2", mix.ChannelName(2)); + } +} diff --git a/src/Godosa.Core.Tests/Audio/Mp3Tests.cs b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs new file mode 100644 index 0000000..9cdca1e --- /dev/null +++ b/src/Godosa.Core.Tests/Audio/Mp3Tests.cs @@ -0,0 +1,90 @@ +using System; +using Godosa.Core.Audio; +using Xunit; + +namespace Godosa.Core.Tests.Audio; + +/// Fixtures: our own sine tones (ffmpeg lavfi sine, amplitude 1/8, 0.25 s) encoded with lame 3.100: mono +/// 440 Hz at 22050 Hz (64 kbit/s, --resample 22.05); stereo 440 Hz left / 880 Hz right at 44100 Hz (128 kbit/s, +/// --resample 44.1). Oracle: a sine of f Hz crosses zero 2·f times a second; counted in a 0.1 s window inside the tone +/// (lame's encoder delay, ≈ 1105 samples, and frame padding are near-silent and cross zero at random). +public class Mp3Tests +{ + private static readonly byte[] Mono = Convert.FromBase64String("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"); + private static readonly byte[] Stereo = Convert.FromBase64String("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"); + + private static (float[] Left, float[] Right) Render(Mp3Stream s, int frames) + { + var buf = new float[2 * frames]; + s.Render(buf, frames); + var (l, r) = (new float[frames], new float[frames]); + for (var i = 0; i < frames; i++) + (l[i], r[i]) = (buf[2 * i], buf[2 * i + 1]); + return (l, r); + } + + [Fact] + public void Reader_decodes_mono_at_the_file_rate() + { + var clip = Mp3Reader.Read(Mono)!.Value; + Assert.Equal(22050, clip.SampleRate); + Assert.InRange(clip.Samples.Length, 5512, 5512 + 3 * 1152); + var f = Array.ConvertAll(clip.Samples, s => s / 32767f); + Assert.InRange(SignalStats.ZeroCrossings(f, 2400, 2400 + 2205), 86, 90); // 0.1 s of 440 Hz → 88 + } + + [Fact] + public void Reader_downmixes_stereo_to_mono() + { + var clip = Mp3Reader.Read(Stereo)!.Value; + Assert.Equal(44100, clip.SampleRate); + Assert.InRange(clip.Samples.Length, 11025, 11025 + 3 * 1152); + } + + [Fact] + public void Reader_rejects_garbage() => Assert.Null(Mp3Reader.Read(new byte[300])); + + /// Oracle: both tones are 0.25 s; the LAME tag's encoder delay and padding are not counted (the Info header's + /// 12 and 11 whole frames would be 0.313 and 0.287 s). Within 1 sample of rounding. + [Fact] + public void Seconds_come_from_the_headers_without_encoder_delay() + { + Assert.InRange(Mp3Reader.Seconds(Mono)!.Value, 0.25, 0.25 + 1 / 22050.0); + Assert.InRange(Mp3Reader.Seconds(Stereo)!.Value, 0.25, 0.25 + 1 / 44100.0); + Assert.Null(Mp3Reader.Seconds(new byte[300])); + } + + [Fact] + public void Stream_keeps_channels_apart() + { + using var s = new Mp3Stream(Stereo, loop: false); + var (l, r) = Render(s, 11025); + Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // 440 Hz → 88 in 0.1 s + Assert.InRange(SignalStats.ZeroCrossings(r, 4000, 4000 + 4410), 174, 178); // 880 Hz → 176 + } + + [Fact] + public void Stream_resamples_mono_to_44100_and_duplicates_it() + { + using var s = new Mp3Stream(Mono, loop: false); + var (l, r) = Render(s, 11025); + Assert.InRange(SignalStats.ZeroCrossings(l, 4000, 4000 + 4410), 86, 90); // still 440 Hz (176 if played as 44.1 kHz) + Assert.Equal(l, r); + } + + [Fact] + public void Once_only_goes_silent_and_finishes_looping_does_not() + { + using var once = new Mp3Stream(Mono, loop: false); + Render(once, 44100); // 1 s, past the 0.25 s file + Assert.True(once.Finished); + var tail = new float[200]; + Array.Fill(tail, 1f); // the stream must write the silence, not rely on a cleared buffer + once.Render(tail, 100); + Assert.All(tail, x => Assert.Equal(0f, x)); + using var loop = new Mp3Stream(Mono, loop: true); + var (l, _) = Render(loop, 44100); + Assert.False(loop.Finished); + Assert.InRange(SignalStats.Rms(l, 22050), 0.05f, 0.1f); // second half still sounding (sine 1/8: RMS 0.088 less gaps) + } +} diff --git a/src/Godosa.Core.Tests/Audio/SynthTests.cs b/src/Godosa.Core.Tests/Audio/SynthTests.cs new file mode 100644 index 0000000..7c2c29b --- /dev/null +++ b/src/Godosa.Core.Tests/Audio/SynthTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Linq; +using Godosa.Core.Audio; +using Xunit; + +namespace Godosa.Core.Tests.Audio; + +public class SynthTests +{ + [Fact] + public void Random_IsDeterministicAndInUnitRange() + { + var a = new SynthRandom(42); + var b = new SynthRandom(42); + var values = Enumerable.Range(0, 1000).Select(_ => a.Next01()).ToArray(); + + Assert.Equal(values, Enumerable.Range(0, 1000).Select(_ => b.Next01()).ToArray()); + Assert.All(values, v => Assert.InRange(v, 0f, 0.99999f)); + Assert.InRange(values.Average(), 0.45f, 0.55f); + } + + [Fact] + public void Random_IsXorshift32OfTheMixedSeed() // pinned: seeded songs must sound the same in every run + { + // oracle: independent Python murmur3 fmix32 + xorshift32 — fmix32(1) = 0x514E28B7, first state 0x1F48D1FB + Assert.Equal(0x514E28B7u, SynthRandom.Mix(1)); + Assert.Equal(2050257f / 16777216f, new SynthRandom(1).Next01()); + } + + [Fact] + public void Random_NearbySeeds_StartFarApart() + { + var firsts = Enumerable.Range(0, 20).Select(s => new SynthRandom((uint)s).Next01()).ToList(); + + Assert.True(firsts.Max() - firsts.Min() > 0.5f); + } + + [Fact] + public void Loop_CrossfadesTheTailIntoTheHead() + { + var x = new float[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + + var y = Synth.Loop(x, overlapSamples: 2); + + Assert.Equal(new float[] { 8, 5, 2, 3, 4, 5, 6, 7 }, y); // y[^1] = 7 wraps to y[0] = 8, like x[7] → x[8] + } + + [Fact] + public void Hash_IsFnv1a() // stable across runs, unlike string.GetHashCode + { + Assert.Equal(0xE40C292Cu, SynthRandom.Hash("a")); + Assert.Equal(0x1A47E90Bu, SynthRandom.Hash("abc")); + } + + [Fact] + public void Sine_SteadyPitch() + { + var x = Synth.Sine(1f, 100f); + + Assert.Equal(44100, x.Length); + Assert.InRange(SignalStats.ZeroCrossings(x), 198, 201); + } + + [Fact] + public void Sine_SweepIsExponential() + { + var x = Synth.Sine(1f, 100f, 400f); // f = 100·4^t: 144 crossings in the first half, 289 in the second + + Assert.InRange(SignalStats.ZeroCrossings(x, 0, 22050), 141, 148); + Assert.InRange(SignalStats.ZeroCrossings(x, 22050), 285, 292); + } + + [Fact] + public void LowPass_PassesLowCutsHigh() + { + float low = SignalStats.Rms(Synth.Sine(0.5f, 200f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 200f), 2000); + float high = SignalStats.Rms(Synth.Sine(0.5f, 8000f).LowPass(1000f), 2000) / SignalStats.Rms(Synth.Sine(0.5f, 8000f), 2000); + + Assert.InRange(low, 0.89f, 1.05f); // within 1 dB + Assert.InRange(high, 0f, 0.1f); // −20 dB or more (2nd order: ≈ −36 dB at 3 octaves) + } + + [Fact] + public void HighPass_PassesHighCutsLow() + { + float low = SignalStats.Rms(Synth.Sine(0.5f, 100f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 100f), 4000); + float high = SignalStats.Rms(Synth.Sine(0.5f, 6000f).HighPass(1000f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, 6000f), 4000); + + Assert.InRange(low, 0f, 0.05f); + Assert.InRange(high, 0.89f, 1.05f); + } + + [Fact] + public void BandPass_PassesCentreOnly() + { + float Ratio(float hz) => SignalStats.Rms(Synth.Sine(0.5f, hz).BandPass(1000f, 2f), 4000) / SignalStats.Rms(Synth.Sine(0.5f, hz), 4000); + + Assert.InRange(Ratio(1000f), 0.85f, 1.05f); + Assert.InRange(Ratio(100f), 0f, 0.1f); + Assert.InRange(Ratio(8000f), 0f, 0.1f); + } + + [Fact] + public void Perc_RisesThenDecaysByTimeConstant() + { + var x = Enumerable.Repeat(1f, 44100).ToArray().Perc(attack: 0.01f, decay: 0.1f); + + Assert.Equal(0f, x[0]); + Assert.InRange(x[441], 0.99f, 1f); // top at the end of the attack + Assert.InRange(x[441 + 4410], 0.36f, 0.375f); // e^-1 one time constant later + } + + [Fact] + public void ToPcm_NormalisesToLevel_AndFadesEnds() + { + var pcm = Synth.ToPcm(Enumerable.Repeat(0.3f, 4410).ToArray(), level: 0.5f); + + Assert.Equal((short)16384, pcm.Max()); + Assert.Equal(0, pcm[0]); + Assert.Equal(0, pcm[^1]); + } +} diff --git a/src/Godosa.Core.Tests/Audio/WavReaderTests.cs b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs new file mode 100644 index 0000000..6ed5485 --- /dev/null +++ b/src/Godosa.Core.Tests/Audio/WavReaderTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using Godosa.Core.Audio; +using Xunit; + +namespace Godosa.Core.Tests.Audio; + +public class WavReaderTests +{ + private static byte[] Wav(short format, short channels, int rate, short bits, byte[] data, bool extensible = false) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + int fmtSize = extensible ? 40 : 16; + w.Write("RIFF"u8); w.Write(4 + 8 + fmtSize + 8 + data.Length); w.Write("WAVE"u8); + w.Write("fmt "u8); w.Write(fmtSize); + w.Write(extensible ? (short)-2 : format); w.Write(channels); w.Write(rate); + w.Write(rate * channels * bits / 8); w.Write((short)(channels * bits / 8)); w.Write(bits); + if (extensible) + { + w.Write((short)22); w.Write(bits); w.Write(0); + w.Write((int)format); w.Write(new byte[12]); // sub-format GUID: first 4 bytes = format tag + } + w.Write("data"u8); w.Write(data.Length); w.Write(data); + return ms.ToArray(); + } + + private static byte[] Shorts(params short[] s) + { + var b = new byte[s.Length * 2]; + Buffer.BlockCopy(s, 0, b, 0, b.Length); + return b; + } + + [Fact] + public void Pcm16Stereo_DownmixesToMono() + { + var clip = WavReader.Read(Wav(1, 2, 22050, 16, Shorts(1000, 3000, -2000, -4000))); + + Assert.NotNull(clip); + Assert.Equal(22050, clip!.Value.SampleRate); + Assert.Equal(new short[] { 2000, -3000 }, clip.Value.Samples); + } + + [Fact] + public void Pcm24Mono() + { + // 0x400000 = +0.5 FS, 0xC00000 = −0.5 FS (little-endian 3-byte) + var clip = WavReader.Read(Wav(1, 1, 44100, 24, new byte[] { 0, 0, 0x40, 0, 0, 0xC0 })); + + Assert.Equal(new short[] { 16384, -16384 }, clip!.Value.Samples); + } + + [Fact] + public void Float32_Extensible() + { + var data = new byte[8]; + BitConverter.GetBytes(0.5f).CopyTo(data, 0); + BitConverter.GetBytes(-1f).CopyTo(data, 4); + + var clip = WavReader.Read(Wav(3, 1, 48000, 32, data, extensible: true)); + + Assert.Equal(new short[] { 16384, -32767 }, clip!.Value.Samples); + Assert.Equal(48000, clip.Value.SampleRate); + } + + [Theory] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 })] + public void Garbage_IsNull(byte[] bytes) => Assert.Null(WavReader.Read(bytes)); + + [Fact] + public void Pcm8_UnsignedCentredOn128() + { + var clip = WavReader.Read(Wav(1, 1, 8000, 8, new byte[] { 128, 192, 0 })); + Assert.Equal(new short[] { 0, 16384, -32768 }, clip!.Value.Samples); + } + + // MS ADPCM fixtures: our own tones encoded by ffmpeg 7 (adpcm_ms, block 256, standard 7 coefficient pairs). Oracle: + // ffmpeg's own adpcm_ms decode (pcm_s16le), stereo averaged with truncation, cut to the fact chunk's frame count. + private static readonly byte[] AdpcmMono = Convert.FromBase64String("UklGRnQEAABXQVZFZm10IDIAAAACAAEAIlYAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAACAYAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAQAAAAQAPwHAAB3d3MhAQAP/dzNvd3M7d3d3u8BNFNTRDNSMzQjIyEQ/tvM293M3dzd7d7vASREUzUzQ0MzNCIiAf7b283b3c3dzd7e7wEjVEQ1JDM0MzMzIhH+zL3MzdzN3dzt7f/xE1Q1NEM0M0MzMjIQDtrczM3czd3c7d7+ACNFQ0RDNDNDMzMiEQ7bzMzc3N3Nzt7e/gATRTRENDNDQyMzIhEO6829zN3M7c7t7u/xEkREQ0NEJCMjMiIgD+vb3M3Nzdzd3d7f8BNTVDRDQ0MzNCMiEQ7trb3b3c3c3d3d7/8iU1Q0Q0M0NCMjIiAf3bzM3M3czt3d7e7wEkU0REMASgLk/uj2QkIyMyIRH+zMzb3N3Nze3d3u8BI2Q0RDQzQzQyMiIQ/tvM292+3O3d3d7vADNjU0QzUjNCMyMiEP/Nrc3M3Nztzt7u7wETRTRENDNDQjMyIhH/zbzb3czd3N3d3e/xI1REQ0NDQzNCMjEgDtzL3M3Nzdze3d3+8RNUQ1NDQzQzNCIxEQ/czMzczdzc7d3t7/AjRFNTNDQ0MzMzMhEP28zNzM7N7O3e3u/wE0RERDQ0M0IzMzIhDuvbzc293c3dzt7v8BM1RENDRDM0IzMiIQ/sy9zNzc3N3d3d7vASRUNTQ0NDM0MjIiEP7a3Mzczd3N3O3u/wAjVDU0AEkC1PUA7jRCMzMzIhD+zMzM3M3dzc7e7u8AI1NTUzQ0MzQzIyIR/tvMzczc3c3dzu7vARNEU0NEM0M0MjIiEf/b283Nzc3N7c7u//ESREQ1M0M0M0IyIhAP3Mvc2+ztzt3e3u4AIlQ1NEM0NDMzMzIh/9vMzNzczs7e3e7uABNEU0Q0NDNDMzMjEQ7rzbzdvd3N3O3t/vESRERDRDNDQzIzIxEP3Mvczb7c7d3O3v4PEkQ1Q0NDQzQjMjIRD+2tvc3NzO3c7e7v8BFDVDRDQ0M0MjMiIQ/trczNzN3Ozt7t7/ACNUNTQ0NDNDIzIxIP7a3Mzczd3Nzt7e/wAjRTUwDmAfTseOVEMzQzMzIiAOzMzM3M3c3d3O3u8AJ3cQDx8fHx8fHx8fAQ8BDx8B8Q8fAfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8="); // 440 Hz ×4 gain, 22050 Hz, fact 1544 + private static readonly byte[] AdpcmStereo = Convert.FromBase64String("UklGRnQFAABXQVZFZm10IDIAAAACAAIAIlYAAIA+AAAAAQQAIAD0AAcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAATwQAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAUAAAAAEAAQAP8Bs/gAAJLxeHd4RyATGycbEQ8EDeTs0sHAz87RwsLa0NPN39TP4NDi+eDxAh8uQ0JOTj5QPUcwMUtELjIwMT4wPSQgHQ7w5820ztC/38Pew9DN1d7SzN/U793k4v4BESpPQ14+Q08wQT5GPUMxMj9OLz4vJy8PEf/j27LMwt6y38Pa08Dez9Pf1dzU3+zj7g8VIE5RPl80TUAxQjtDPzNOMC8wMDUtLBUOAOHAwsC93cbdxM/S2sHQwu3Q1O3Q4uHwDR8TQExETEQwXzI6Qz8wTjNNITMpMSAQIwD+/dKw0rrB38XRzcLd0dDD3NTcwe7i/vTxHBQ8QUAAAEoACA649VUMRPRlAm4/NlAsQS8zPzExSyEvMR8THv700c2w37LSztHKwt7T38zW39Lf3dLg4w4CKDFjMF4/QUI9RE8/PjZNMDM+Mz8oMREd8fPMws3DzcTC28/R0MHO5N7U3t/e4e/n8RsSMVBNQ0A9UDU/TzJAOjBCPiQ9MiAhHxIN/NKz0rjP0rHhzNDS0s3SzuTd4tvg4uACCxMxQEpQNE5BPkNNPzQ/TjI/QiwhLiUgDgDx48/DysDO1sDO3tDB38PQ3e/X0NHu8vvyIitRP1NAMEBNPkc/MU8/QC4yMC4vIiUQDf/nwMyw0cLN0MLez9XP0tzS0M7j4eHvAABTAH8GBvDJ2Q3wlegXEDFMRE9OQTFCP04+TzEyPT8xQSAnER0MBe3Gz87RsMHS0LDo0cDi3t/U393Q4eXtDxQwTk9SPl81TTNAP0I+Pk0kPDMwJDsSLw7w4sXAvdHCydKx7cDg0dTcz9Tg0ODg7fT8ES80bzFPQUExTE89P0AwN04iPi42ICEeAfL5wMDDwM3UzsLfwdvB0NDS3dDD4uDp8QEQET1RPFM9UjIwTzdMMUM7IUAhIi0gIw8f/vviwr3exMLQuuPfwN/d0cHX7t7V7+7w8BUtM19ATTJUPkAyTTFPMTAwQTwlLz0fJgAA7OK+w9K4083A0t3BwOTd3wAAhACACpH0huoO9tQc1O/g4P31HR8yQ1k0XTBDPUBDPTFOIEMlLD8hIBUtDwTw3dOg0bzfw9LN3cHQ3s3Q19DV39zg8fIPIj5bREBPTUUwTTIwTUIvMTQ9NTwiPh8UAu/hsM3NxtC91N3D3MTc08Df7dLQ7uPqBQ4fMENcQTJRO0ExQTFPPj4wRD8jPCMvHBEA8uvAtt+w0sDQysLQ0M3S0NLQzuHu5O0DCREhMm0+UD9HPk9BMzwzQT5BLT4kLCERHgUP0dG+wMHD2LPf393Ewe/f09Dd0NDb9f3/ICRNUUM8VDBNQzwzQDE/TCMwPDIyLhArBPDdw8Kw2rHTy9IAAIUA0RBwAFYCbwKB6s7SzeHU0dzR3uPs8fAUME9DWjFQPjRRPjE+T0QgPTQ7IjAgIB4O8+zToNW70cDC3s7V3s7Swu/e0OXR7ODwAwskTk5CQ0swUTA/RD1PMz0wNU0jLDQeHxT/7dTAvcLO1MDcv+PQz+Hd1N7UzeHi/fwDBxA7QVAwUT4wUiBBHwAAAAEPAQ8BDwEAH/AR8B/wEf8R8A8AAQAPAQ8AAQ8BAA8BDwEPAQ8AAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8B"); // 440 Hz left, noise right, fact 1103 + + private static readonly byte[] AdpcmQuiet = Convert.FromBase64String("UklGRnQBAABXQVZFZm10IDIAAAACAAEAESsAAIA+AAAAAQQAIAD0AQcAAAEAAAACAP8AAAAAwABAAPAAAADMATD/iAEY/2ZhY3QEAAAASwEAAExJU1QaAAAASU5GT0lTRlQNAAAATGF2ZjYxLjcuMTAzAABkYXRhAAEAAAAQAAMAAAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAQAAAAAPAAAPAAAAAAAAABAAABAAAAAA8AAA8AAAAAAAAAEAAAEAAAAADwAADwAAAAAAAAEAAAAQAAAADwAAAPAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); // 300 Hz ×0.004, 11025 Hz, fact 331 + + [Fact] + public void MsAdpcm_QuietKeepsTheStepFloorOf16() + { + var s = WavReader.Read(AdpcmQuiet)!.Value.Samples; // ffmpeg: steps stay at the floor, samples move by 16 + Assert.Equal(331, s.Length); + Assert.Equal(new short[] { 0, 3, 3, 3, 3, 19, 19, 19, 19, 19, 19, 19 }, s[..12]); + Assert.Equal((302, 3682), (Sum(s), AbsSum(s))); + } + + private static int Sum(short[] s) { int n = 0; foreach (var x in s) n += x; return n; } + private static int AbsSum(short[] s) { int n = 0; foreach (var x in s) n += Math.Abs(x); return n; } + + [Fact] + public void MsAdpcm_Mono_MatchesFfmpeg() + { + var s = WavReader.Read(AdpcmMono)!.Value.Samples; + Assert.Equal(1544, s.Length); + Assert.Equal(new short[] { 0, 2044, 2156, 2422, 3059, 4585 }, s[..6]); + Assert.Equal(new short[] { -6016, -4057, -2328, -284, 2060, 3464 }, s[498..504]); // across the block edge at 500 + Assert.Equal(new short[] { -16364, -16364, -15876 }, s[^3..]); + Assert.Equal((74278, 16090432), (Sum(s), AbsSum(s))); + } + + [Fact] + public void MsAdpcm_Stereo_MatchesFfmpeg() + { + var clip = WavReader.Read(AdpcmStereo)!.Value; + Assert.Equal(22050, clip.SampleRate); + var s = clip.Samples; + Assert.Equal(1103, s.Length); + Assert.Equal(new short[] { -1847, -679, -687, -386, -527, 1116 }, s[..6]); + Assert.Equal(new short[] { 277, 3542, 2416, 255, -1687, -2380 }, s[498..504]); + Assert.Equal(new short[] { -1115, -937, 606 }, s[^3..]); + Assert.Equal((-183764, 2966742), (Sum(s), AbsSum(s))); + } + + [Fact] + public void Downmix_InterleavedFloat() + { + Assert.Equal(new short[] { 16384, 0 }, Pcm.Downmix(new[] { 0.25f, 0.75f, 1f, -1f }, 2)); + } +} diff --git a/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs new file mode 100644 index 0000000..c67042e --- /dev/null +++ b/src/Godosa.Core.Tests/Diagnostics/DebugFlagsTests.cs @@ -0,0 +1,59 @@ +using Godosa.Core.Diagnostics; +using Xunit; + +namespace Godosa.Core.Tests.Diagnostics; + +public class DebugFlagsTests +{ + [Fact] + public void Categories_AreCommaSeparated_Trimmed_CaseInsensitive() + { + var flags = new DebugFlags(" input , AUDIO"); + Assert.True(flags.On("input")); + Assert.True(flags.On("audio")); + Assert.False(flags.On("frames")); + } + + [Fact] + public void All_TurnsOnEveryCategory_ButNotExactSwitches() + { + var flags = new DebugFlags("all"); + Assert.True(flags.On("script")); + Assert.False(flags.Exact("nohud")); + } + + [Fact] + public void Exact_MatchesOnlyTheNamedSwitch() + { + var flags = new DebugFlags("frames,NoHud"); + Assert.True(flags.Exact("nohud")); + Assert.False(flags.Exact("input")); + } + + [Fact] + public void MissingOrEmpty_TurnsNothingOn() + { + Assert.False(new DebugFlags(null).On("input")); + Assert.False(new DebugFlags(" , ,").On("input")); + } + + [Fact] + public void EngineDebug_Apply_SetsEachLibCategory() + { + try + { + EngineDebug.Apply(new DebugFlags("script")); + Assert.True(EngineDebug.Script); + Assert.False(EngineDebug.Input); + Assert.False(EngineDebug.Audio); + EngineDebug.Apply(new DebugFlags("input,audio")); + Assert.True(EngineDebug.Input); + Assert.True(EngineDebug.Audio); + Assert.False(EngineDebug.Script); + } + finally + { + EngineDebug.Apply(new DebugFlags(null)); + } + } +} diff --git a/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs new file mode 100644 index 0000000..44b6758 --- /dev/null +++ b/src/Godosa.Core.Tests/Diagnostics/FrameTimeStatsTests.cs @@ -0,0 +1,97 @@ +using System; +using Godosa.Core.Diagnostics; +using Xunit; + +namespace Godosa.Core.Tests.Diagnostics; + +public class FrameTimeStatsTests +{ + /// 99 smooth 10 ms frames plus one 100 ms hitch. + private static FrameTimeStats OneHitch() + { + var stats = new FrameTimeStats(capacity: 100); + for (int i = 0; i < 99; i++) + stats.Record(0.010, workSeconds: 0.002, simTicks: 0, simSeconds: 0); + stats.Record(0.100, workSeconds: 0.002, simTicks: 0, simSeconds: 0); + return stats; + } + + [Fact] + public void Fps_IsFramesOverTotalTime_NotMeanOfInstantaneousFps() + { + var s = OneHitch().Summarize(); + + Assert.Equal(100, s.Frames); + // 100 frames / 1.09 s. (Mean of per-frame FPS would be (99*100 + 10)/100 = 99.1.) + Assert.Equal(91.743, s.Fps, 3); + Assert.Equal(10.9, s.AvgMs, 6); + } + + [Fact] + public void Percentiles_UseNearestRank_AndMaxCatchesTheHitch() + { + var s = OneHitch().Summarize(); + + Assert.Equal(10.0, s.P50Ms, 6); + Assert.Equal(10.0, s.P95Ms, 6); + Assert.Equal(10.0, s.P99Ms, 6); // rank ceil(0.99*100) = 99th of 100 + Assert.Equal(100.0, s.MaxMs, 6); + } + + [Fact] + public void OnePercentLow_AveragesTheSlowestOnePercentOfFrames() + { + var s = OneHitch().Summarize(); + + Assert.Equal(10.0, s.OnePercentLowFps, 6); // worst 1 of 100 frames = 100 ms + } + + [Fact] + public void Hitches_CountFramesOverTwiceTheMedian() + { + var stats = new FrameTimeStats(capacity: 10); + foreach (var ms in new[] { 10, 10, 10, 10, 10, 20, 21, 35 }) + stats.Record(ms / 1000.0, 0, 0, 0); + + Assert.Equal(2, stats.Summarize().Hitches); // median 10 → 21 and 35 (20 is not > 20) + } + + [Fact] + public void Window_DropsOldestFrames() + { + var stats = new FrameTimeStats(capacity: 4); + foreach (var ms in new[] { 100, 100, 3, 4, 5, 6 }) + stats.Record(ms / 1000.0, 0, 0, 0); + + Span intervals = stackalloc float[4]; + int n = stats.CopyIntervalsMs(intervals); + + Assert.Equal(4, n); + Assert.Equal(new float[] { 3, 4, 5, 6 }, intervals.ToArray()); + Assert.Equal(6.0, stats.Summarize().MaxMs, 4); + } + + [Fact] + public void Sim_ReportsAchievedTickRateAndCostPerTick() + { + var stats = new FrameTimeStats(capacity: 60); + for (int i = 0; i < 60; i++) // 1 s at 60 fps; a tick every other frame, 1 ms each + stats.Record(1.0 / 60.0, workSeconds: 0.004, simTicks: i % 2, simSeconds: (i % 2) * 0.001); + + var s = stats.Summarize(); + + Assert.Equal(30.0, s.SimTickRate, 3); + Assert.Equal(1.0, s.AvgTickMs, 6); + Assert.Equal(4.0, s.AvgWorkMs, 6); + } + + [Fact] + public void Empty_SummarizesToZerosWithoutDividingByZero() + { + var s = new FrameTimeStats().Summarize(); + + Assert.Equal(0, s.Frames); + Assert.Equal(0.0, s.Fps); + Assert.Equal(0.0, s.AvgTickMs); + } +} diff --git a/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs new file mode 100644 index 0000000..46cf5ed --- /dev/null +++ b/src/Godosa.Core.Tests/Diagnostics/GpuAndCsvTests.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Threading; +using Godosa.Core.Diagnostics; +using Xunit; + +namespace Godosa.Core.Tests.Diagnostics; + +public class GpuAndCsvTests +{ + [Fact] + public void GpuSamples_AverageSeparatelyFromFrames() + { + var stats = new FrameTimeStats(capacity: 4); + stats.Record(0.016, 0.002, 1, 0.0001); + Assert.Equal(0, stats.Summarize().GpuSamples); + Assert.Equal(0.0, stats.Summarize().AvgGpuMs); + + stats.RecordGpu(0.001); + stats.RecordGpu(0.003); + + var s = stats.Summarize(); + Assert.Equal(2, s.GpuSamples); + Assert.Equal(2.0, s.AvgGpuMs, precision: 9); + } + + [Fact] + public void GpuSamples_RollOverTheWindow() + { + var stats = new FrameTimeStats(capacity: 2); + stats.RecordGpu(0.010); + stats.RecordGpu(0.001); + stats.RecordGpu(0.003); + + Assert.Equal(2.0, stats.Summarize().AvgGpuMs, precision: 9); + } + + [Fact] + public void CsvRow_IsInvariantCulture_MsWithThreeDecimals_GpuBlankWhenUnknown() + { + var previous = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // comma decimals must not leak in + try + { + Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header()); + Assert.Equal("12.500,16.667,2.000,1,0.100,1.250", PerfCsv.Row(12.5, 0.0166667, 0.002, 1, 0.0001, 0.00125)); + Assert.Equal("0.000,16.667,2.000,0,0.000,", PerfCsv.Row(0, 0.0166667, 0.002, 0, 0, null)); + } + finally + { + Thread.CurrentThread.CurrentCulture = previous; + } + } +} diff --git a/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs new file mode 100644 index 0000000..fd64778 --- /dev/null +++ b/src/Godosa.Core.Tests/Diagnostics/PerfCsvTests.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using Godosa.Core.Diagnostics; +using Xunit; + +namespace Godosa.Core.Tests.Diagnostics; + +public class PerfCsvTests +{ + [Fact] + public void Header_AppendsCallerColumnsInOrder() + { + Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms", PerfCsv.Header()); + Assert.Equal("t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms,draw_calls,triangles", + PerfCsv.Header("draw_calls", "triangles")); + } + + [Fact] + public void Row_AppendsCallerValuesInOrder() + { + Assert.Equal("1.500,16.000,4.000,2,1.000,,12,2.500,", + PerfCsv.Row(1.5, 0.016, 0.004, 2, 0.001, null, "12", PerfCsv.Ms(0.0025), PerfCsv.Ms(null))); + } + + [Fact] + public void Ms_IsInvariant_ThreeDecimals() + { + var previous = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + Assert.Equal("12.500", PerfCsv.Ms(0.0125)); + } + finally + { + Thread.CurrentThread.CurrentCulture = previous; + } + } +} diff --git a/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj new file mode 100644 index 0000000..76a07ab --- /dev/null +++ b/src/Godosa.Core.Tests/Godosa.Core.Tests.csproj @@ -0,0 +1,13 @@ + + + false + + + + + + + + + + diff --git a/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs new file mode 100644 index 0000000..9c0c9dc --- /dev/null +++ b/src/Godosa.Core.Tests/Input/GamepadScriptTests.cs @@ -0,0 +1,56 @@ +using System.Numerics; +using Godosa.Core.Input; +using Xunit; + +namespace Godosa.Core.Tests.Input; + +public class GamepadScriptTests +{ + [Theory] + [InlineData("PadA", PadButtons.A)] + [InlineData("PadB", PadButtons.B)] + [InlineData("PadX", PadButtons.X)] + [InlineData("PadY", PadButtons.Y)] + [InlineData("PadBack", PadButtons.Back)] + [InlineData("PadStart", PadButtons.Start)] + [InlineData("PadUp", PadButtons.DpadUp)] + [InlineData("PadDown", PadButtons.DpadDown)] + [InlineData("PadLeft", PadButtons.DpadLeft)] + [InlineData("PadRight", PadButtons.DpadRight)] + [InlineData("PadLB", PadButtons.LeftShoulder)] + [InlineData("PadRB", PadButtons.RightShoulder)] + public void Button_DownHolds_UpReleases(string name, PadButtons button) + { + var down = GamepadScript.Apply(default, name, true)!.Value; + Assert.Equal(button, down.Buttons); + Assert.True(down.Held(button)); + Assert.Equal(PadButtons.None, GamepadScript.Apply(down, name, false)!.Value.Buttons); + } + + [Fact] + public void Button_LeavesOtherButtonsAndSticksAlone() + { + var start = new GamepadState(new Vector2(0.5f, 0f), Vector2.Zero, 0f, 0f, PadButtons.B); + var next = GamepadScript.Apply(start, "PadA", true)!.Value; + Assert.Equal(PadButtons.A | PadButtons.B, next.Buttons); + Assert.Equal(new Vector2(0.5f, 0f), next.LeftStick); + Assert.Equal(PadButtons.A, GamepadScript.Apply(next, "PadB", false)!.Value.Buttons); + } + + [Fact] + public void Triggers_GoFullOnAndOff() + { + var lt = GamepadScript.Apply(default, "PadLT", true)!.Value; + Assert.Equal(1f, lt.LeftTrigger); + Assert.Equal(0f, lt.RightTrigger); + Assert.Equal(0f, GamepadScript.Apply(lt, "PadLT", false)!.Value.LeftTrigger); + Assert.Equal(1f, GamepadScript.Apply(default, "PadRT", true)!.Value.RightTrigger); + } + + [Fact] + public void Names_AreCaseInsensitive_UnknownIsNull() + { + Assert.True(GamepadScript.Apply(default, "padstart", true)!.Value.Held(PadButtons.Start)); + Assert.Null(GamepadScript.Apply(default, "PadZ", true)); + } +} diff --git a/src/Godosa.Core.Tests/Input/InputScriptTests.cs b/src/Godosa.Core.Tests/Input/InputScriptTests.cs new file mode 100644 index 0000000..d208240 --- /dev/null +++ b/src/Godosa.Core.Tests/Input/InputScriptTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Godosa.Core.Input; +using Xunit; + +namespace Godosa.Core.Tests.Input; + +public class InputScriptTests +{ + [Fact] + public void Parse_AbsoluteRelativeHoldPressClick_SortedByTime() + { + var script = InputScript.Parse(""" + # walk to the Elder + 1.0 hold W 2.5 + +0.5 press E + 5 click Accept + 4.0 down Space + 4.2 up Space + """); + + Assert.Equal(new[] + { + new ScriptEvent(1.0, ScriptAction.Down, "W"), + new ScriptEvent(1.5, ScriptAction.Down, "E"), + new ScriptEvent(1.5 + InputScript.PressSeconds, ScriptAction.Up, "E"), + new ScriptEvent(3.5, ScriptAction.Up, "W"), + new ScriptEvent(4.0, ScriptAction.Down, "Space"), + new ScriptEvent(4.2, ScriptAction.Up, "Space"), + new ScriptEvent(5.0, ScriptAction.Click, "Accept"), + }, script.Events); + } + + [Fact] + public void Type_KeepsTheWholeText() => + Assert.Equal(new ScriptEvent(1.0, ScriptAction.Type, "Ada Lovelace"), InputScript.Parse("1 type Ada Lovelace").Events.Single()); + + [Fact] + public void Click_KeepsTheWholeLabel() => + Assert.Equal("Take all", InputScript.Parse("2 click Take all").Events.Single().Arg); + + [Theory] + [InlineData("1.0 jump W")] + [InlineData("abc down W")] + [InlineData("1.0 down")] + [InlineData("1.0 hold W")] + [InlineData("+0.5 down W")] + public void Parse_BadLine_ReportsItsLineNumber(string bad) + { + var ex = Assert.Throws(() => InputScript.Parse("# ok\n" + bad)); + Assert.Contains("line 2", ex.Message); + } + + [Fact] + public void Due_ReturnsEachEventOnceWhenItsTimeComes() + { + var script = InputScript.Parse("1 down W\n2 up W\n2 press E"); + var got = new List(); + + script.Due(0.99, got); + Assert.Empty(got); + script.Due(1.0, got); + Assert.Single(got); + script.Due(2.0, got); + Assert.Equal(3, got.Count); + script.Due(10.0, got); + Assert.Equal(4, got.Count); + script.Due(20.0, got); + Assert.Equal(4, got.Count); + Assert.True(script.Finished); + } +} diff --git a/src/Godosa.Core.Tests/Input/MenuCursorTests.cs b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs new file mode 100644 index 0000000..79a1184 --- /dev/null +++ b/src/Godosa.Core.Tests/Input/MenuCursorTests.cs @@ -0,0 +1,42 @@ +using Godosa.Core.Input; +using Xunit; + +namespace Godosa.Core.Tests.Input; + +public class MenuCursorTests +{ + [Fact] + public void Move_WrapsAround() + { + var c = new MenuCursor(); + c.Clamp(3); + + c.Move(-1, 3); + Assert.Equal(2, c.Index); + c.Move(+1, 3); + Assert.Equal(0, c.Index); + c.Move(+1, 3); + Assert.Equal(1, c.Index); + } + + [Fact] + public void Clamp_KeepsIndexInsideAShrinkingList_AndEmptyMeansNone() + { + var c = new MenuCursor(); + c.Clamp(5); + c.Move(+1, 5); + c.Move(+1, 5); + c.Move(+1, 5); + + c.Clamp(2); + Assert.Equal(1, c.Index); + + c.Clamp(0); + Assert.Equal(-1, c.Index); + c.Move(+1, 0); + Assert.Equal(-1, c.Index); + + c.Clamp(4); + Assert.Equal(0, c.Index); + } +} diff --git a/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs new file mode 100644 index 0000000..31295ab --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/RouteLegsTests.cs @@ -0,0 +1,116 @@ +using Godosa.Core.Runs; +using Xunit; + +namespace Godosa.Core.Tests.Runs; + +public class RouteLegsTests +{ + // 5×5 grid, rows top to bottom; '#' blocked, 's' soft (sealed by the game's scripts, opened later). + private static Func Grid(params string[] rows) => (x, y) => rows[y][(int)x] is '#' or 's'; + private static Func Soft(params string[] rows) => (x, y) => rows[y][(int)x] == 's'; + + [Fact] + public void Path_GoesRoundAWall_StartExcluded() + { + var blocked = Grid( + ".#...", + ".#...", + ".#...", + ".....", + "....."); + // From 0,0 the only way past the wall is under it: down the left column to row 3, then up-right diagonally. + var path = RouteLegs.Path((0, 0), (2, 0), 5, 5, blocked); + Assert.NotNull(path); + Assert.Equal((2, 0), path![^1]); + Assert.DoesNotContain((0L, 0L), path); + Assert.Equal(6, path.Count); // 0,1 0,2 1,3 2,2 2,1 2,0 + Assert.All(path, c => Assert.False(blocked(c.X, c.Y))); + } + + [Fact] + public void Path_NoWay_Null() + { + var blocked = Grid( + "..#..", + "..#..", + "..#..", + "..#..", + "..#.."); + Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, blocked)); + } + + // The game's router takes a leg only within 2 cells (Chebyshev): from each stop the farthest accepted path cell. + [Fact] + public void Legs_FarthestAcceptedStopEachTime() + { + var path = new List<(long X, long Y)> { (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0) }; + var legs = RouteLegs.Legs((0, 0), path, (a, b) => Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)) <= 2); + Assert.Equal([(2L, 0L), (4L, 0L), (6L, 0L), (7L, 0L)], legs); + } + + [Fact] + public void Legs_RouterAcceptsNothing_NullWithTheStuckStop() + { + var path = new List<(long X, long Y)> { (1, 0), (2, 0) }; + Assert.Null(RouteLegs.Legs((0, 0), path, (a, b) => b == (1, 0), out var stuck)); + Assert.Equal((1, 0), stuck); + } + + // Closed off only by a sealed (soft) cell: the seals a way would cross once open, start to goal. + [Fact] + public void Seals_ListedInOrder() + { + string[] rows = + [ + "..s..", + "#####", + ".....", + ".....", + ".....", + ]; + Assert.Null(RouteLegs.Path((0, 0), (4, 0), 5, 5, Grid(rows))); + Assert.Equal([(2L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(rows), Soft(rows))); + Assert.Empty(RouteLegs.Seals((0, 0), (4, 4), 5, 5, Grid(rows), Soft(rows))); // walled, not sealed + } + + [Fact] + public void Seals_CappedAndNoneWhenOpenAlready() + { + string[] twice = + [ + ".s.s.", + "#####", + ".....", + ".....", + ".....", + ]; + Assert.Equal([(1L, 0L), (3L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice))); + Assert.Equal([(1L, 0L)], RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(twice), Soft(twice), max: 1)); + // Open the long way round (row 2): a seal on the short way is no reason to wait. + string[] detour = + [ + "..s..", + "#.#.#", + ".....", + ".....", + ".....", + ]; + Assert.Empty(RouteLegs.Seals((0, 0), (4, 0), 5, 5, Grid(detour), Soft(detour))); + } + + // Hops: walk to a portal, click it, arrive at its exit; BFS over portal exits. + [Fact] + public void Hops_ThroughTwoPortals() + { + // Three rooms on a line, x 0–9 / 10–19 / 20–29 (walking stays inside a room); portal A in room 1 at 5 → 12, B in + // room 2 at 18 → 25; a dead-end portal C in room 1 at 2 → 40. + static int Room(long x) => (int)(x / 10); + bool Reaches(long from, long to) => Room(from) == Room(to); + var portals = new[] { ("A", 5L, 12L), ("B", 18L, 25L), ("C", 2L, 40L) }; + var hops = RouteLegs.Hops(1L, 27L, portals, p => p.Item2, p => p.Item3, Reaches); + Assert.NotNull(hops); + Assert.Equal(["A", "B"], hops!.Select(p => p.Item1)); + Assert.Null(RouteLegs.Hops(1L, 35L, portals, p => p.Item2, p => p.Item3, Reaches)); + Assert.Empty(RouteLegs.Hops(1L, 7L, portals, p => p.Item2, p => p.Item3, Reaches)!); + } +} diff --git a/src/Godosa.Core.Tests/Runs/RunsTests.cs b/src/Godosa.Core.Tests/Runs/RunsTests.cs new file mode 100644 index 0000000..1b6d6d4 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/RunsTests.cs @@ -0,0 +1,149 @@ +using Godosa.Core.Runs; +using Xunit; + +namespace Godosa.Core.Tests.Runs; + +public class RunsTests +{ + private static RunFlags Parse(params string[] args) + { + var flags = new RunFlags(); + for (var i = 0; i < args.Length; i++) + Assert.True(RunFlags.TryTake(args, ref i, ref flags), args[i]); + return flags; + } + + [Fact] + public void Flags_DeterministicWithAndWithoutSeed() + { + Assert.Equal(new RunFlags(Deterministic: true, Seed: 7), Parse("--deterministic", "7")); + Assert.Equal(new RunFlags(Deterministic: true), Parse("--deterministic")); + Assert.Equal(1UL, Parse("--deterministic").SeedOr(1)); + Assert.Equal(7UL, Parse("--deterministic", "7").SeedOr(1)); + } + + [Fact] + public void Flags_SeedNotTakenWhenNextIsAnOption() + { + string[] args = ["--deterministic", "--mute"]; + var flags = new RunFlags(); + var i = 0; + Assert.True(RunFlags.TryTake(args, ref i, ref flags)); + Assert.Equal(0, i); + i = 1; + Assert.False(RunFlags.TryTake(args, ref i, ref flags)); + } + + [Fact] + public void Flags_DigestEveryAndHeadless() + { + Assert.Equal(new RunFlags(Digest: true), Parse("--digest")); + Assert.Equal(new RunFlags(Digest: true, DigestEvery: 60), Parse("--digest", "every=60")); + Assert.Equal(new RunFlags(Deterministic: true, Headless: true), Parse("--headless")); + Assert.Equal(new RunFlags(Deterministic: true, Seed: 3, Headless: true), Parse("--headless", "--deterministic", "3")); + } + + [Fact] + public void Flags_BadDigestEvery_Throws() => + Assert.Throws(() => Parse("--digest", "every=0")); + + [Fact] + public void Clock_DeterministicFixedStep() + { + var clock = new FrameClock(true); + Assert.Equal(1.0 / 60, clock.Next(0.5)); + Assert.Equal(1.0 / 60, clock.Next(0.001)); + Assert.Equal(2, clock.Frame); + Assert.Equal(2.0 / 60, clock.Seconds, 12); + } + + [Fact] + public void Clock_WallClamped() + { + var clock = new FrameClock(false); + Assert.Equal(0.1, clock.Next(0.5)); + Assert.Equal(0.02, clock.Next(0.02)); + Assert.Equal(0, clock.Next(-1)); + Assert.Equal(3, clock.Frame); + } + + [Fact] + public void Clock_Held_NoTimeNoFrame() + { + var clock = new FrameClock(true) { Held = true }; + Assert.Equal(0, clock.Next(1.0 / 60)); + Assert.Equal(0, clock.Frame); + Assert.Equal(0, clock.Seconds); + clock.Held = false; + clock.Next(0); + Assert.Equal(1, clock.Frame); + } + + // Published FNV-1a 64 vectors (isthe.com/chongo/tech/comp/fnv). + [Theory] + [InlineData("", 0xcbf29ce484222325UL)] + [InlineData("a", 0xaf63dc4c8601ec8cUL)] + [InlineData("foobar", 0x85944171f73967e8UL)] + public void Digest_FnvVectors(string text, ulong expected) + { + var d = new StateDigest(); + d.Add(text); + Assert.Equal(expected, d.Value); + Assert.Equal(expected, StateDigest.Of(System.Text.Encoding.UTF8.GetBytes(text))); + } + + // Hand-checked: FNV-1a 64 of the little-endian bytes 01 00 00 00 and of 1.5 as an IEEE double. + [Fact] + public void Digest_NumbersLittleEndian() + { + var u = new StateDigest(); + u.Add(1u); + Assert.Equal(0xad2aca7747985764UL, u.Value); + var d = new StateDigest(); + d.Add(1.5); + Assert.Equal(0xaa95e93229a27c80UL, d.Value); + Assert.Equal("aa95e93229a27c80", d.Hex); + } + + [Fact] + public void Line_Format() => + Assert.Equal("digest 120 3.5 000000000000beef", DigestLine.Format(120, 3.5, 0xbeef)); + + [Fact] + public void Line_FormatRoundsGameTime() => + Assert.Equal("digest 1 0.017 0000000000000001", DigestLine.Format(1, 1.0 / 60, 1)); + + [Fact] + public void Line_Due() + { + Assert.True(DigestLine.Due(60, 60)); + Assert.False(DigestLine.Due(59, 60)); + Assert.False(DigestLine.Due(0, 60)); + Assert.False(DigestLine.Due(60, 0)); + } + + // SplitMix64 seeded 0: outputs e220a8397b1dcdaf, 6e789e6aa1b965f4 (published reference sequence). + [Fact] + public void Seeds_SplitMixReference() + { + Assert.Equal(0xe220a8397b1dcdafUL, SeedStreams.Of(0, 0)); + Assert.Equal(0x6e789e6aa1b965f4UL, SeedStreams.Of(0, 1)); + Assert.Equal(0x63cbe1e459320dd7UL, SeedStreams.Of(7, 0)); + } + + [Fact] + public void Loop_StopsWhenFrameReturnsFalse() + { + var seen = new List(); + var (frames, capped) = HeadlessLoop.Run(n => { seen.Add(n); return n < 3; }, null); + Assert.Equal([1L, 2, 3], seen); + Assert.Equal(3, frames); + Assert.False(capped); + } + + [Fact] + public void Loop_FramesLimit() => Assert.Equal((5L, false), HeadlessLoop.Run(_ => true, 5)); + + [Fact] + public void Loop_Cap() => Assert.Equal((10L, true), HeadlessLoop.Run(_ => true, null, cap: 10)); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs new file mode 100644 index 0000000..1b53910 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ActionInputTests.cs @@ -0,0 +1,47 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// Multi-click verbs (walk-path, fight, travel-to): a poll may hand the runner more input; the plan plays from +/// the next frame (its frame 0), the action is not polled until it is sent. +public sealed class ActionInputTests +{ + [Fact] + public void PollInput_PlaysFromTheNextFrame_ThenPollsAgain() + { + var g = new FakeGame(); + var polls = new List(); + var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(2, new InputEvent(InputKind.KeyUp, Key: "B"))]); + g.ActionTable["walk"] = new FakeAction(VerbKind.Player, + _ => new ActionStart(new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "A"))])), + n => + { + polls.Add(n); + return n == 1 ? StepStatus.Input(follow) : StepStatus.Done; + }); + var (r, output) = Run("format 1\nmode player\nwalk\nlog done", g); + + Assert.True(r.Outcome == RunOutcome.Passed, string.Join("\n", output)); + Assert.Equal(["A", "B", "B"], g.Sent.Select(s => s.Event.Key)); + var a = g.Sent[0].Frame; + Assert.Equal(a + 1, g.Sent[1].Frame); // polled after frame a → B on the next frame + Assert.Equal(a + 3, g.Sent[2].Frame); // its frame 2 + Assert.Equal([1, 2], polls); // no poll while the follow-up plan is unsent + Assert.Contains("done", output); + } + + [Fact] + public void PollInput_HalfSent_ReleasedOnFailure() + { + var g = new FakeGame(); + var follow = new InputPlan([new(0, new InputEvent(InputKind.KeyDown, Key: "B")), new(50, new InputEvent(InputKind.KeyUp, Key: "B"))]); + g.ActionTable["walk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), n => StepStatus.Input(follow)); + var (r, _) = Run("format 1\nmode player\nwalk", g, new ScriptRunOptions { StepCap = 10 }); + + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Equal(InputKind.KeyUp, g.Sent[^1].Event.Kind); // the held key is let go + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs new file mode 100644 index 0000000..a84cf94 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/CheckpointCacheTests.cs @@ -0,0 +1,185 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public sealed class CheckpointCacheTests : IDisposable +{ + private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private (ScriptRunner Runner, RunResult Result, List Output) Run(string main, FakeGame g, ScriptRunOptions? o = null, Dictionary? files = null) + { + var all = new Dictionary(files ?? []) { ["main.steps"] = main }; + var output = new List(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary()), g, + o ?? new ScriptRunOptions { CacheDir = _dir }, output.Add); + return (runner, runner.Run(10_000), output); + } + + private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C"; + + // --no-snapshots: checkpoints are markers only — no digest (a whole-world serialization: seconds late in a big game, + // the window froze), nothing cached; a later run resumes from nothing. + [Fact] + public void NoSnapshots_CheckpointsNeitherDigestNorCache() + { + var g = new FakeGame(); + var (r, res, output) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Snapshots = false }); + Assert.Equal(RunOutcome.Passed, res.Outcome); + Assert.Equal(0, g.Digests); + Assert.Equal(["marker", "marker"], r.Report!.Checkpoints.Select(c => c.Source)); + Assert.Contains("checkpoint two f=4", output); + Assert.False(Directory.Exists(_dir) && Directory.EnumerateFiles(_dir, "*", SearchOption.AllDirectories).Any()); + var (_, stopped, _) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Snapshots = false, Until = "one" }); + Assert.Equal((RunOutcome.Stopped, 2L), (stopped.Outcome, stopped.Frames)); + } + + [Fact] + public void SecondRun_ResumesAtTheLastCachedCheckpoint() + { + var first = new FakeGame(); + first.OnAdvance = f => first.State += 1; + var (r1, _, _) = Run(Script, first); + Assert.Equal(6, r1.Report!.Frames); + Assert.Equal(["replayed", "replayed"], r1.Report.Checkpoints.Select(c => c.Source)); + + var second = new FakeGame(); + second.OnAdvance = f => second.State += 1; + var (r2, res, output) = Run(Script, second); + Assert.Equal(RunOutcome.Passed, res.Outcome); + Assert.Contains("resumed at two (cache) f=4", output); + Assert.Equal(["C"], second.Sent.Where(s => s.Event.Kind == InputKind.KeyDown).Select(s => s.Event.Key)); + Assert.Equal((6L, 6), (res.Frames, second.State)); // restored frame 4 / state 4, key C = 2 frames → 6 / 6 + Assert.Equal(1, r2.Report!.CacheHits); + } + + // A window restore rebuilds the game view after the frame: the runner pauses (Restoring) for that frame, no game frame + // passes, and the run ends as an immediate restore would. + [Fact] + public void DeferredRestore_PausesOneFrame_SameResult() + { + var first = new FakeGame(); + first.OnAdvance = f => first.State += 1; + Run(Script, first); + + var second = new FakeGame { RestoreDeferred = true }; + second.OnAdvance = f => second.State += 1; + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = Script }), + new Dictionary()), second, new ScriptRunOptions { CacheDir = _dir }, _ => { }); + runner.BeforeFrame(); + Assert.True(runner.Restoring); + Assert.Empty(second.Sent); + + var third = new FakeGame { RestoreDeferred = true }; + third.OnAdvance = f => third.State += 1; + var (r3, res, _) = Run(Script, third); + Assert.False(r3.Restoring); + Assert.Equal((6L, 6), (res.Frames, third.State)); // the paused frame advanced nothing + } + + [Fact] + public void ChangedStepBeforeACheckpoint_InvalidatesIt_LaterStepsDoNot() + { + Run(Script, new FakeGame()); + var (r, _, output) = Run(Script.Replace("key B", "key X"), new FakeGame()); + Assert.Contains("resumed at one (cache) f=2", output); + var (r3, _, output3) = Run(Script.Replace("key C", "key Y"), new FakeGame()); + Assert.Contains("resumed at two (cache) f=4", output3); + } + + [Fact] + public void CommentEdits_KeepTheCache() + { + Run(Script, new FakeGame()); + var (_, _, output) = Run("# new comment\n" + Script.Replace("key A", "key A # aim"), new FakeGame()); + Assert.Contains("resumed at two (cache) f=4", output); + } + + [Fact] + public void SeedContentOrVars_InvalidateEverything() + { + Run(Script, new FakeGame()); + var (_, _, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Seed = 5 }); + Assert.DoesNotContain(output, l => l.StartsWith("resumed")); + var (_, _, output2) = Run(Script, new FakeGame { ContentVersion = "c2" }); + Assert.DoesNotContain(output2, l => l.StartsWith("resumed")); + } + + [Fact] + public void NoCache_ReplaysAndWarnsOnDigestMismatch() + { + Run(Script, new FakeGame()); + var drift = new FakeGame { State = 100 }; + var (r, _, output) = Run(Script, drift, new ScriptRunOptions { CacheDir = _dir, UseCache = false }); + Assert.DoesNotContain(output, l => l.StartsWith("resumed")); + Assert.Contains(r.Report!.Warnings, w => w.StartsWith("checkpoint one digest differs from cache")); + // Cache replaced: a cached run now resumes from the new state. + var after = new FakeGame(); + Run(Script, after); + Assert.Equal(100, after.State); + } + + [Fact] + public void From_RequiresACachedCheckpoint_Until_StopsEarly() + { + var (_, missing, out1) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "two" }); + Assert.Equal((RunOutcome.Invalid, 2), (missing.Outcome, missing.ExitCode)); + Assert.Contains("checkpoint two not cached", out1); + var (_, unknown, out2) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "nope" }); + Assert.Contains("unknown checkpoint nope", out2); + var g = new FakeGame(); + var (_, stopped, _) = Run(Script, g, new ScriptRunOptions { CacheDir = _dir, Until = "one" }); + Assert.Equal((RunOutcome.Stopped, 0, 2L), (stopped.Outcome, stopped.ExitCode, stopped.Frames)); + var (_, from, outFrom) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir, From = "one" }); + Assert.Equal(RunOutcome.Passed, from.Outcome); + Assert.Contains("resumed at one (cache) f=2", outFrom); + } + + // Review focus 3: a checkpoint inside an include + def call restores vars, output level and labels. + [Fact] + public void Resume_InsideIncludeAndCall_RestoresScopes() + { + var files = new Dictionary + { + ["ch.steps"] = "format 1\noutput steps\ndef d(who):\n set inner = ${who}-x\n checkpoint deep\n log \"${who} ${inner} ${top}\"\nstep s:\n call d(gil)\n key Q", + }; + const string main = "format 1\nmode player\nset top = T\ninclude ch.steps\nkey Z"; + var (_, _, replay) = Run(main, new FakeGame(), files: files); + var (_, _, resumed) = Run(main, new FakeGame(), files: files); + Assert.Contains("resumed at deep (cache) f=0", resumed); + string[] Tail(List o) => [.. o.Where(l => !l.StartsWith("resumed") && !l.StartsWith("checkpoint") && !l.StartsWith("digest"))]; + Assert.Equal(Tail(replay), Tail(resumed)); + Assert.Contains("gil gil-x T", resumed); + Assert.Contains(resumed, l => l.StartsWith("[ch.steps:9 s] key Q done")); // output level steps + label restored + } + + [Fact] + public void Eviction_KeepsTotalUnderTheCap() + { + var cache = new CheckpointCache(_dir, capBytes: 300); + var state = new RunnerState(0, 0, [], []); + for (var i = 0; i < 5; i++) + { + cache.Save($"k{i}", new CachedCheckpoint(state, new byte[100])); + File.SetLastWriteTimeUtc(Path.Combine(_dir, $"k{i}.snap"), new DateTime(2026, 1, 1, 0, 0, i, DateTimeKind.Utc)); + } + var left = Directory.GetFiles(_dir, "*.snap").Select(Path.GetFileNameWithoutExtension).Order().ToList(); + Assert.True(Directory.GetFiles(_dir, "*.snap").Sum(f => new FileInfo(f).Length) <= 300); + Assert.Contains("k4", left); + Assert.DoesNotContain("k0", left); + } + + [Fact] + public void NoSnapshotSupport_NoCache() + { + var (r, _, _) = Run(Script, new FakeGame { SupportsSnapshot = false }); + Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir).Length > 0); + Assert.Equal(0, r.Report!.CacheMisses); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs new file mode 100644 index 0000000..d14ea6a --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ConditionTests.cs @@ -0,0 +1,63 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ConditionTests +{ + private sealed class Ctx : IConditionContext + { + public Dictionary Values { get; } = new() + { + ["quest(1)"] = RunValue.Of(2), ["map"] = RunValue.Of("tarant"), ["dialog-open"] = RunValue.Of(true), + ["global(gold)"] = RunValue.Of(500), ["alive"] = RunValue.Of(false), + }; + + public bool HasProbe(string name) => name is "quest" or "map" or "dialog-open" or "global" or "alive"; + + public RunValue Probe(string name, IReadOnlyList args) => + Values[args.Count == 0 ? name : $"{name}({string.Join(",", args)})"]; + + public long? Constant(string name) => name switch { "accepted" => 2, "completed" => 4, _ => null }; + } + + private static bool Test(string text) => ConditionParser.Parse(text).Test(new Ctx()); + + [Theory] + [InlineData("quest(1) >= accepted", true)] + [InlineData("quest(1) = completed", false)] + [InlineData("map = \"tarant\"", true)] + [InlineData("map != \"ashbury\"", true)] + [InlineData("dialog-open", true)] + [InlineData("alive", false)] + [InlineData("not alive", true)] + [InlineData("dialog-open and global(gold) > 499", true)] + [InlineData("alive or global(gold) < 100", false)] + [InlineData("not (alive or dialog-open)", false)] + [InlineData("alive or dialog-open and quest(1) = 2", true)] // and binds tighter than or + [InlineData("global(gold) <= 500", true)] + public void Evaluates(string text, bool expected) => Assert.Equal(expected, Test(text)); + + [Fact] + public void MismatchedTypes_Fault() => + Assert.Equal("cannot compare tarant with 3", Assert.Throws(() => Test("map > 3")).Message); + + [Theory] + [InlineData("quest(1) >=")] + [InlineData("(alive")] + [InlineData("alive alive")] + [InlineData("\"open")] + public void BadSyntax_Throws(string text) => Assert.Throws(() => ConditionParser.Parse(text)); + + [Fact] + public void ListsProbesAndBareNames() + { + var c = ConditionParser.Parse("quest(1) >= accepted and not dialog-open"); + Assert.Equal(["quest(1)", "accepted", "dialog-open"], c.Probes().Select(p => p.Args.Count == 0 ? p.Name : $"{p.Name}({string.Join(",", p.Args)})")); + Assert.Equal(["accepted", "dialog-open"], c.Bare()); + } + + [Fact] + public void Describe_ListsProbeValues() => + Assert.Equal("quest(1)=2 map=tarant", Cond.Describe(ConditionParser.Parse("quest(1) > 3 and map = \"x\""), new Ctx())); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs new file mode 100644 index 0000000..8318148 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/FakeGame.cs @@ -0,0 +1,100 @@ +using Godosa.Core.Runs.Scripts; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// Scriptable game for runner tests: probes are a dictionary, actions are lambdas, input is recorded with the +/// frame it arrived on. +public sealed class FakeGame : IRunGame, IInputRouter +{ + public long Frame { get; private set; } + public List<(long Frame, InputEvent Event)> Sent { get; } = []; + public Dictionary, RunValue>> ProbeFns { get; } = new(); + public Dictionary ActionTable { get; } = new(); + public Dictionary Objects { get; } = new(); + public Dictionary ConstantTable { get; } = new() { ["accepted"] = 2 }; + public Action? OnAdvance { get; set; } + public int State { get; set; } + public FakeRng? RngImpl { get; set; } = new(); + + public string Name => "fake"; + public string ContentVersion { get; set; } = "c1"; + public IInputRouter Input => this; + public IReadOnlyDictionary Probes => ProbeFns.ToDictionary(p => p.Key, p => (IProbe)new FnProbe(p.Value)); + public IReadOnlyDictionary Constants => ConstantTable; + public IReadOnlyDictionary Locators => new Dictionary { ["obj"] = new ObjLocator(this) }; + public IReadOnlyDictionary Actions => ActionTable; + public IRunRng? Rng => RngImpl; + public bool SupportsSnapshot { get; set; } = true; + public bool RestoreDeferred { get; set; } + + public Action? OnSend { get; set; } + public bool RestoreThrows { get; set; } + public string? Refusal { get; set; } + public string? SnapshotRefusal() => Refusal; + + public void Send(InputEvent e) + { + OnSend?.Invoke(e); + Sent.Add((Frame, e)); + } + + public byte[] SnapshotSave() => BitConverter.GetBytes(State).Concat(BitConverter.GetBytes(Frame)).ToArray(); + + public void SnapshotRestore(byte[] data) + { + if (RestoreThrows) + throw new IOException("restore broke"); + State = BitConverter.ToInt32(data, 0); + Frame = BitConverter.ToInt64(data, 4); + } + + public int Digests { get; private set; } + public ulong Digest() { Digests++; return (ulong)State * 1_000_003UL + (ulong)Frame; } + public string Dump() => $"state={State} frame={Frame}"; + + public void Advance() + { + Frame++; + OnAdvance?.Invoke(Frame); + } + + private sealed class FnProbe(Func, RunValue> fn) : IProbe + { + public int MinArgs => 0; + public int MaxArgs => 4; + public RunValue Eval(IReadOnlyList args) => fn(args); + } + + private sealed class ObjLocator(FakeGame g) : ILocator + { + public LocatorResult Locate(IReadOnlyList args) => + g.Objects.TryGetValue(args[0], out var p) ? LocatorResult.Ok(p.X, p.Y) : LocatorResult.Fail($"no object {args[0]} on screen"); + } +} + +public sealed class FakeRng : IRunRng +{ + public List Calls { get; } = []; + public void Seed(long seed) => Calls.Add($"seed {seed}"); + public void ForceNext(string kind, long value) => Calls.Add($"next {kind} {value}"); +} + +/// An action: optional input plan, then Poll() answers from a function of the frames polled so far. +public sealed class FakeAction(VerbKind kind, Func, ActionStart> begin, Func? poll = null, + int min = 0, int max = 4) : IRunAction +{ + private int _polls; + public VerbKind Kind => kind; + public int MinArgs => min; + public int MaxArgs => max; + public List> Begun { get; } = []; + + public ActionStart Begin(IReadOnlyList args) + { + Begun.Add(args); + _polls = 0; + return begin(args); + } + + public StepStatus Poll() => (poll ?? (_ => StepStatus.Done))(++_polls); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs new file mode 100644 index 0000000..1b7257b --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ReviewFixTests.cs @@ -0,0 +1,157 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +/// Final-review findings (lib core): each test failed before its fix. +public sealed class ReviewFixTests : IDisposable +{ + private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private const string Script = "format 1\nmode player\nkey A\ncheckpoint one\nkey B\ncheckpoint two\nkey C"; + + [Fact] + public void NoArgVerb_AndEmptySet_Run() + { + var g = new FakeGame(); + g.ActionTable["close-panel"] = new FakeAction(VerbKind.Player, _ => new ActionStart(Status: StepStatus.Done), min: 0, max: 0); + var (r, output) = Run("format 1\nmode player\nset x =\nclose-panel\nlog \"[${x}]\"", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains("[]", output); + } + + // A probe value (dialog text) with quotes inside a quoted argument stays text: the line is split into tokens first. + [Fact] + public void QuotedArgument_ValueWithQuotes_StaysOneToken() + { + var (r, output) = Run("format 1\nmode player\nset x = \"\\\"Hi,\\\" he said\"\nlog \"R: ${x}\"", new FakeGame()); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains("R: \"Hi,\" he said", output); + } + + [Fact] + public void SubstitutedValues_AreNotRescanned() + { + var values = new Dictionary { ["d"] = "$", ["e"] = "{x}", ["raw"] = "${y}" }; + Assert.Equal("${x}", VarExpander.Expand("${d}${e}", n => values.GetValueOrDefault(n), null)); + Assert.Equal("say ${y}!", VarExpander.Expand("say ${raw}!", n => values.GetValueOrDefault(n), null)); + } + + [Theory] + [InlineData("log Done:", "log", "Done:")] + [InlineData("type name:", "type", "name:")] + public void LineEndingInColon_IsAVerbUnlessABlockKeyword(string line, string verb, string args) + { + var f = ScriptParser.Parse("main.steps", $"format 1\nmode debug\n{line}"); + Assert.Empty(f.Errors); + var v = Assert.IsType(f.Body.Single()); + Assert.Equal((verb, args), (v.Verb, v.Args)); + } + + [Fact] + public void ThrowingInputRouter_FailsTheRun() + { + var g = new FakeGame { OnSend = _ => throw new InvalidOperationException("router down") }; + var (r, output) = Run("format 1\nmode player\nkey R", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 key R: InvalidOperationException: router down"); + } + + [Fact] + public void CorruptCacheEntry_IsAMiss() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + foreach (var f in Directory.GetFiles(_dir, "*.snap")) + File.WriteAllBytes(f, [.. "GRS1"u8, 3, 0, 0, 0, (byte)'{', (byte)'x', (byte)'!']); + var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.DoesNotContain(output, l => l.StartsWith("resumed")); + } + + // A game state the snapshot can't hold (an open dialog) → checkpoint not cached, run unharmed. + [Fact] + public void RefusedSnapshot_CheckpointNotCached() + { + var g = new FakeGame { Refusal = "dialog open" }; + var output = new List(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary + { + ["main.steps"] = Script, + }), new Dictionary()), g, new ScriptRunOptions { CacheDir = _dir }, output.Add); + Assert.Equal(RunOutcome.Passed, runner.Run(1000).Outcome); + Assert.Equal(["checkpoint one not cached: dialog open", "checkpoint two not cached: dialog open"], runner.Report!.Warnings); + Assert.False(Directory.Exists(_dir) && Directory.GetFiles(_dir, "*.snap", SearchOption.AllDirectories).Length > 0); + } + + [Fact] + public void FailingRestore_FailsTheRun() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); + var (r, output) = Run(Script, new FakeGame { RestoreThrows = true }, new ScriptRunOptions { CacheDir = _dir }); + Assert.Equal((RunOutcome.Failed, 1), (r.Outcome, r.ExitCode)); + Assert.Contains(output, l => l.Contains("IOException: restore broke")); + } + + [Fact] + public void UnwritableCacheAndReportDirs_WarnInsteadOfThrowing() + { + Directory.CreateDirectory(_dir); + var file = Path.Combine(_dir, "file"); + File.WriteAllText(file, "x"); + var g = new FakeGame(); + g.ProbeFns["no"] = _ => RunValue.Of(false); + var output = new List(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary + { + ["main.steps"] = "format 1\nmode player\ncheckpoint one\nexpect no", + }), new Dictionary()), g, new ScriptRunOptions { CacheDir = Path.Combine(file, "cache"), ReportDir = Path.Combine(file, "report") }, output.Add); + var r = runner.Run(1000); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(runner.Report!.Warnings, w => w.StartsWith("checkpoint cache not written:")); + Assert.Contains(output, l => l.StartsWith("warning: report not written:")); + } + + [Fact] + public void Until_UnknownName_Invalid() + { + var (r, output) = Run(Script, new FakeGame(), new ScriptRunOptions { Until = "nowhere" }); + Assert.Equal((RunOutcome.Invalid, 2), (r.Outcome, r.ExitCode)); + Assert.Contains("unknown label or checkpoint nowhere", output); + } + + [Fact] + public void Until_ResumesOnlyBeforeIt() + { + Run(Script, new FakeGame(), new ScriptRunOptions { CacheDir = _dir }); // caches one and two + var output = new List(); + var r = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = Script }), + new Dictionary()), new FakeGame(), new ScriptRunOptions { CacheDir = _dir, Until = "two" }, output.Add).Run(1000); + Assert.Contains("resumed at one (cache) f=2", output); + Assert.Equal((RunOutcome.Stopped, 4L), (r.Outcome, r.Frames)); + } + + [Fact] + public void ScriptSeedHeader_SeedsTheGame_CommandLineWins() + { + var g = new FakeGame(); + var output = new List(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }), + new Dictionary()), g, new ScriptRunOptions(), output.Add); + runner.Run(1000); + Assert.Equal(["seed 7"], g.RngImpl!.Calls); + Assert.Equal(7UL, runner.Report!.Seed); + var g2 = new FakeGame(); + var runner2 = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = "format 1\nmode player\nseed 7\nkey A" }), + new Dictionary()), g2, new ScriptRunOptions { Seed = 9 }, output.Add); + runner2.Run(1000); + Assert.Equal(["seed 9"], g2.RngImpl!.Calls); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs new file mode 100644 index 0000000..00ee4b0 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/RunReportTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public sealed class RunReportTests : IDisposable +{ + private readonly string _dir = Path.Combine(AppContext.BaseDirectory, "rs-tmp", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private static (ScriptRunner Runner, RunResult Result, List Output) Run(string main, FakeGame g, ScriptRunOptions o, Dictionary? files = null) + { + var all = new Dictionary(files ?? []) { ["main.steps"] = main }; + var output = new List(); + var runner = new ScriptRunner(ScriptLoader.Load("main.steps", new MemoryScriptSource(all), new Dictionary()), g, o, output.Add); + return (runner, runner.Run(10_000), output); + } + + [Fact] + public void Failure_ReportHoldsChainStepReasonRecentDumpVars() + { + var g = new FakeGame { State = 7 }; + g.ProbeFns["quest"] = _ => RunValue.Of(1); + var (runner, _, _) = Run("format 1\nmode player\nset who = Gilbert\nkey A\ninclude act2.steps", g, new ScriptRunOptions { ReportDir = _dir }, + new() { ["act2.steps"] = "format 1\nstep talk-gilbert:\n key B\n expect quest(5) >= accepted" }); + var f = runner.Report!.Failure!; + Assert.Equal("act2.steps:4", f.At); + Assert.Equal(["main.steps:5"], f.Chain); + Assert.Equal(("expect quest(5) >= accepted", "talk-gilbert"), (f.Step, f.Label)); + Assert.Equal("expect failed: quest(5) >= accepted (quest(5)=1)", f.Reason); + Assert.Equal(["main.steps:4 key A f=2", "act2.steps:3 key B f=4"], f.Recent); + Assert.Equal("state=7 frame=4", f.Dump); + Assert.Equal("Gilbert", f.Vars["who"]); + Assert.True(File.Exists(Path.Combine(_dir, "report.txt"))); + Assert.True(File.Exists(Path.Combine(_dir, "fail.snap"))); + var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(_dir, "report.json"))); + Assert.Equal("Failed", json.RootElement.GetProperty("Outcome").GetString()); + var text = File.ReadAllText(Path.Combine(_dir, "report.txt")); + Assert.Contains("main.steps:5 → act2.steps:4", text); + Assert.Contains("state=7 frame=4", text); + } + + [Fact] + public void Recent_KeepsLast20() + { + var g = new FakeGame(); + g.ProbeFns["no"] = _ => RunValue.Of(false); + var steps = string.Join("\n", Enumerable.Range(1, 25).Select(n => $"key K{n}")); + var (runner, _, _) = Run($"format 1\nmode player\n{steps}\nexpect no", g, new ScriptRunOptions()); + var recent = runner.Report!.Failure!.Recent; + Assert.Equal(20, recent.Count); + Assert.StartsWith("main.steps:8 key K6", recent[0]); + } + + [Fact] + public void Summary_StepsFramesCheckpointsDigests() + { + var g = new FakeGame { State = 3 }; + var (runner, r, output) = Run("format 1\nmode player\nkey A\ncheckpoint one\nstep s:\n key B", g, new ScriptRunOptions { Seed = 9 }); + var rep = runner.Report!; + Assert.Equal((RunOutcome.Passed, 4L, 2, 9UL), (rep.Outcome, rep.Frames, rep.Steps, rep.Seed)); + // Digest = State * 1_000_003 + Frame (FakeGame), at frame 2: 3 * 1000003 + 2 = 3000011. + Assert.Equal([new CheckpointRecord("one", 2, 3000011UL.ToString("x16"), "replayed")], rep.Checkpoints); + Assert.Contains(DigestLine.Format(2, 2 / 60.0, 3000011UL), output); + Assert.Contains(DigestLine.Format(4, 4 / 60.0, 3000013UL), output); // after the labelled step + } + + [Fact] + public void NonDeterministic_NoDigestLines_FlagInReport() + { + var (runner, _, output) = Run("format 1\nmode player\ncheckpoint one", new FakeGame(), new ScriptRunOptions { Deterministic = false }); + Assert.DoesNotContain(output, l => l.StartsWith("digest ")); + Assert.False(runner.Report!.Deterministic); + Assert.Contains("deterministic: no", ReportWriter.Text(runner.Report)); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs new file mode 100644 index 0000000..f28f330 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptLoaderTests.cs @@ -0,0 +1,103 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptLoaderTests +{ + private static ScriptProgram Load(Dictionary files, Dictionary? vars = null) => + ScriptLoader.Load("main.steps", new MemoryScriptSource(files), vars ?? []); + + private static string Errors(ScriptProgram p) => string.Join(" | ", p.Errors.Select(e => e.ToString())); + + private static string Ops(ScriptProgram p) => string.Join(" ", p.Code.Select(i => i.Op switch + { + Op.Verb => $"{i.A}", Op.ScopeBegin => i.LocalVars ? "{L" : "{", Op.ScopeEnd => "}", Op.LabelBegin => $"[{i.A}", + Op.LabelEnd => "]", Op.TryBegin => "try(", Op.TryEnd => ")", Op.SetupBegin => "setup(", Op.SetupEnd => ")", + _ => i.Op.ToString().ToLowerInvariant(), + })); + + [Fact] + public void Flattens_IncludesDefsBlocks() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode player\nsetup:\n cheat gold\ndef rest(h):\n key R\nstep a:\n include ch/one.steps\n call rest(8)\ncheckpoint done", + ["ch/one.steps"] = "format 1\nclick 1,1\ninclude ../common.steps", + ["common.steps"] = "format 1\ntry:\n key Q", + }); + Assert.Equal("", Errors(p)); + Assert.Equal("setup( cheat ) [a { click { try( key ) } } {L key } ] checkpoint", Ops(p)); + var tryBegin = p.Code.Single(i => i.Op == Op.TryBegin); + Assert.Equal(Op.TryEnd, p.Code[tryBegin.Jump].Op); + var q = p.Code.Single(i => i.Op == Op.Verb && i.A == "key" && i.B == "Q"); + Assert.Equal("common.steps:3", q.At.ToString()); + Assert.Equal(["main.steps:8", "ch/one.steps:3"], q.Chain.Select(c => c.ToString())); + var bind = p.Code.Single(i => i.Op == Op.ScopeBegin && i.LocalVars); + Assert.Equal([new KeyValuePair("h", "8")], bind.Bindings); + } + + [Fact] + public void VarsFiles_PathExpandedWithCommandLine_FileDoesNotOverrideIt() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode player\nvars ui/${res}.vars\nclick ${ui.ok}", + ["ui/hd.vars"] = "ui.ok = 10,20\nres = sd", + }, new() { ["res"] = "hd" }); + Assert.Equal("", Errors(p)); + Assert.Equal(("10,20", "hd"), (p.Vars["ui.ok"], p.Vars["res"])); + Assert.Contains("res", p.Locked); + } + + [Theory] + [InlineData("format 1\nclick 1,1", "main.steps:1: top-level file needs 'mode'")] + [InlineData("format 1\nmode player\ninclude x.steps", "main.steps:3: include not found: x.steps")] + [InlineData("format 1\nmode player\ncall nope()", "main.steps:3: unknown def nope")] + [InlineData("format 1\nmode player\ndef d(a):\n key R\ncall d()", "main.steps:5: def d takes 1 arguments, got 0")] + [InlineData("format 1\nmode player\ndef d():\n call d()\ncall d()", "main.steps:4: recursive call d")] + [InlineData("format 1\nmode player\ndef d():\n key R\ndef d():\n key Q", "main.steps:5: def d already defined")] + [InlineData("format 1\nmode player\ncheckpoint a\ncheckpoint a", "main.steps:4: duplicate checkpoint a")] + [InlineData("format 1\nmode player\nclick ${nowhere}", "main.steps:3: unresolved variable ${nowhere}")] + [InlineData("format 1\nmode player\nkey R\nsetup:\n cheat x", "main.steps:4: setup must be the first block")] + [InlineData("format 1\nmode player\ntry:\n checkpoint c", "main.steps:4: checkpoint inside try")] + public void Reports(string main, string error) => Assert.Contains(error, Errors(Load(new() { ["main.steps"] = main }))); + + [Fact] + public void IncludeCycle_NamesThePath() => + Assert.Contains("b.steps:2: include cycle: main.steps → a.steps → b.steps → a.steps", Errors(Load(new() + { + ["main.steps"] = "format 1\nmode debug\ninclude a.steps", + ["a.steps"] = "format 1\ninclude b.steps", + ["b.steps"] = "format 1\ninclude a.steps", + }))); + + [Fact] + public void IncludedFile_ModeOrSetup_Rejected() + { + var p = Load(new() + { + ["main.steps"] = "format 1\nmode debug\ninclude a.steps", + ["a.steps"] = "format 1\nmode player\nsetup:\n cheat x", + }); + Assert.Contains("a.steps:2: mode only in the top-level file", Errors(p)); + Assert.Contains("a.steps:3: setup only in the top-level file", Errors(p)); + } + + [Fact] + public void LockedVariable_SetIsAnError() => + Assert.Contains("main.steps:3: variable res is set on the command line", + Errors(Load(new() { ["main.steps"] = "format 1\nmode debug\nset res = x" }, new() { ["res"] = "hd" }))); + + [Fact] + public void DefParamsAndEarlierSets_AreKnownVariables() => + Assert.Equal("", Errors(Load(new() + { + ["main.steps"] = "format 1\nmode debug\nset n = 3\ndef d(a):\n key ${a}\n wait-frames ${n}\ncall d(R)", + }))); + + [Fact] + public void IncludedFileKeepsItsOwnFormat() => + Assert.Equal(1, Load(new() { ["main.steps"] = "format 1\nmode debug\ninclude a.steps", ["a.steps"] = "format 1\nkey R" }) + .Code.Single(i => i.Op == Op.Verb).Format); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs new file mode 100644 index 0000000..352ff03 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptParserTests.cs @@ -0,0 +1,111 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptParserTests +{ + private static ScriptFile Parse(string text) => ScriptParser.Parse("main.steps", text); + + private static string Errors(ScriptFile f) => string.Join(" | ", f.Errors.Select(e => e.ToString())); + + [Fact] + public void Header_AndStatements() + { + var f = Parse(""" + # comment + format 1 + mode player + seed 7 + vars ui/${res}.vars + click 10,20 + wait dialog-open 2s + expect quest(1) >= accepted + set x = "a b" + include ch/one.steps + call rest(8, inn) + checkpoint done + """); + Assert.Equal("", Errors(f)); + Assert.Equal((1, RunMode.Player, 7UL), (f.Format, f.Mode, f.Seed!.Value)); + Assert.Equal(["ui/${res}.vars"], f.VarFiles); + var v = Assert.IsType(f.Body[0]); + Assert.Equal(("click", "10,20", 6), (v.Verb, v.Args, v.At.Line)); + var w = Assert.IsType(f.Body[1]); + Assert.Equal(("dialog-open", 120), (w.Condition, w.Timeout)); + Assert.Equal("quest(1) >= accepted", Assert.IsType(f.Body[2]).Condition); + var s = Assert.IsType(f.Body[3]); + Assert.Equal(("x", "a b"), (s.Name, s.Value)); + Assert.Equal("ch/one.steps", Assert.IsType(f.Body[4]).Path); + var c = Assert.IsType(f.Body[5]); + Assert.Equal("rest", c.Name); + Assert.Equal(["8", "inn"], c.Args); + Assert.Equal("done", Assert.IsType(f.Body[6]).Name); + } + + [Fact] + public void Blocks_NestByIndent() + { + var f = Parse(""" + format 1 + mode debug + setup: + cheat gold 5 + step talk-virgil: + click 1,2 + try: + key R + def rest(hours): + key R + wait-frames ${hours} + key Q + """); + Assert.Equal("", Errors(f)); + var setup = Assert.IsType(f.Body[0]); + Assert.Equal((BlockKind.Setup, 1), (setup.Kind, setup.Body.Count)); + var step = Assert.IsType(f.Body[1]); + Assert.Equal((BlockKind.Step, "talk-virgil", 2), (step.Kind, step.Label, step.Body.Count)); + Assert.Equal(BlockKind.Try, Assert.IsType(step.Body[1]).Kind); + var def = Assert.IsType(f.Body[2]); + Assert.Equal(("rest", 2), (def.Name, def.Body.Count)); + Assert.Equal(["hours"], def.Params); + Assert.Equal("Q", Assert.IsType(f.Body[3]).Args); + } + + [Theory] + [InlineData("mode player\nclick 1,1", "main.steps:1: first line must be 'format N'")] + [InlineData("format 9\nmode player", "main.steps:1: unknown format 9 (latest 1)")] + [InlineData("format 1\nmode sneaky", "main.steps:2: mode must be player or debug")] + [InlineData("format 1\nmode debug\nclick 1,1\nseed 3", "main.steps:4: header line after steps")] + [InlineData("format 1\nmode debug\nwait dialog-open 0.01s", "main.steps:3: bad timeout '0.01s'")] + [InlineData("format 1\nmode debug\nwait 2s", "main.steps:3: wait needs a condition and a timeout")] + [InlineData("format 1\nmode debug\nstep a:\n click 1,1", "main.steps:4: indent not a multiple of 2")] + [InlineData("format 1\nmode debug\nstep a:\n\tclick 1,1", "main.steps:4: tab in indentation")] + [InlineData("format 1\nmode debug\nstep a:\nclick 1,1", "main.steps:3: empty block")] + [InlineData("format 1\nmode debug\nloop:\n click 1,1", "main.steps:3: unknown block 'loop:'")] + [InlineData("format 1\nmode debug\nclick 1,1\n key R", "main.steps:4: unexpected indent")] + [InlineData("format 1\nmode debug\nlog \"abc", "main.steps:3: unterminated quote")] + [InlineData("format 1\nmode debug\nset 9x = 1", "main.steps:3: bad set (set name = value)")] + public void Errors_NameTheLine(string text, string error) => Assert.Contains(error, Errors(Parse(text))); + + // Review focus 5: CRLF, BOM and trailing spaces parse as LF. + [Fact] + public void CrlfBomTrailingSpaces_SameAsLf() + { + var lf = Parse("format 1\nmode debug\nstep a:\n click 1,2\n"); + var crlf = Parse("\uFEFFformat 1 \r\nmode debug\r\nstep a: \r\n click 1,2 \r\n"); + Assert.Equal("", Errors(crlf)); + var a = Assert.IsType(lf.Body[0]); + var b = Assert.IsType(crlf.Body[0]); + Assert.Equal(a.Text, b.Text); + Assert.Equal(((VerbNode)a.Body[0]).Text, ((VerbNode)b.Body[0]).Text); + Assert.Equal(((VerbNode)a.Body[0]).At, ((VerbNode)b.Body[0]).At); + } + + [Fact] + public void ReportsEveryError_NotOnlyTheFirst() + { + var f = Parse("format 1\nmode debug\nwait x 2m\nset = 1\nloop:\n key R"); + Assert.Equal(3, f.Errors.Count); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs new file mode 100644 index 0000000..6f3be4e --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunFlagsTests.cs @@ -0,0 +1,54 @@ +using Godosa.Core.Runs; +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunFlagsTests +{ + private static ScriptRunFlags Parse(params string[] args) + { + var f = new ScriptRunFlags(); + for (var i = 0; i < args.Length; i++) + Assert.True(ScriptRunFlags.TryTake(args, ref i, f), args[i]); + return f; + } + + [Fact] + public void ParsesEveryOption() + { + var f = Parse("--script", "main.steps", "--var", "res=1920x1080", "--var", "who=gil", "--output", "trace", + "--no-cache", "--no-snapshots", "--from", "one", "--until", "two", "--step-cap", "500", "--report-dir", "out/runs/a"); + Assert.True(f.NoSnapshots); + Assert.False(f.ToOptions(new RunFlags(), null).Snapshots); + Assert.Equal("main.steps", f.Script); + Assert.Equal(new Dictionary { ["res"] = "1920x1080", ["who"] = "gil" }, f.Vars); + Assert.Equal((OutputLevel.Trace, true, "one", "two", 500, "out/runs/a"), (f.Output!.Value, f.NoCache, f.From, f.Until, f.StepCap!.Value, f.ReportDir)); + } + + [Fact] + public void NotOurs_ReturnsFalse_IndexUnchanged() + { + var i = 0; + Assert.False(ScriptRunFlags.TryTake(["--mute"], ref i, new ScriptRunFlags())); + Assert.Equal(0, i); + } + + [Theory] + [InlineData("--var", "novalue")] + [InlineData("--output", "loud")] + [InlineData("--step-cap", "x")] + public void BadValues_Throw(string option, string value) + { + var i = 0; + Assert.Throws(() => ScriptRunFlags.TryTake([option, value], ref i, new ScriptRunFlags())); + } + + [Fact] + public void ToOptions_DeterministicSeedOutput() + { + var o = Parse("--output", "steps", "--no-cache").ToOptions(new RunFlags(Deterministic: true, Seed: 7), "out/runs/cache"); + Assert.Equal((true, (ulong?)7UL, OutputLevel.Steps, OutputLevel.Steps, false, "out/runs/cache"), (o.Deterministic, o.Seed, o.Output, o.Floor, o.UseCache, o.CacheDir)); + Assert.False(Parse("--nondeterministic").ToOptions(new RunFlags(), null).Deterministic); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs new file mode 100644 index 0000000..9129456 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerStructureTests.cs @@ -0,0 +1,85 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; +using static Godosa.Core.Tests.Runs.Scripts.ScriptRunnerTests; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunnerStructureTests +{ + [Fact] + public void Output_DefaultLabels_StepsLevelShowsEveryStep() + { + var (_, labels) = Run("format 1\nmode player\nstep a:\n key R\n log \"hi\"\nkey Q", new FakeGame()); + Assert.Equal(["hi", "[main.steps:3 a] done f=2 (+2)", "digest 2 0.033 0000000000000002"], labels); + var (_, steps) = Run("format 1\nmode player\noutput steps\nstep a:\n key R\nkey Q", new FakeGame()); + Assert.Equal(["[main.steps:5 a] key R done f=2 (+2)", "[main.steps:4 a] done f=2 (+2)", "digest 2 0.033 0000000000000002", "[main.steps:6] key Q done f=4 (+2)"], steps); + } + + [Fact] + public void Output_ScopedToIncludedFile() + { + var (_, output) = Run("format 1\nmode player\ninclude loud.steps\nkey Q", new FakeGame(), + files: new() { ["loud.steps"] = "format 1\noutput steps\nkey R" }); + Assert.Equal(["[loud.steps:3] key R done f=2 (+2)"], output); // key Q at labels level: silent + } + + [Fact] + public void Output_FloorCannotBeLowered() + { + var (_, output) = Run("format 1\nmode player\noutput quiet\nkey R", new FakeGame(), new ScriptRunOptions { Floor = OutputLevel.Steps }); + Assert.Equal(["[main.steps:4] key R done f=2 (+2)"], output); + } + + [Fact] + public void DefCall_BindsParams_ParamUnknownOutside() + { + var g = new FakeGame(); + var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3); + g.ActionTable["cheat"] = a; + var (ok, _) = Run("format 1\nmode debug\ndef d(x):\n set local = in\n cheat ${x} ${local}\ncall d(gold)", g); + Assert.Equal(RunOutcome.Passed, ok.Outcome); + Assert.Equal(["gold", "in"], a.Begun[0]); + var (bad, output) = Run("format 1\nmode debug\ndef d(x):\n cheat ${x}\ncall d(gold)\ncheat ${x}", g); + Assert.Equal(RunOutcome.Invalid, bad.Outcome); + Assert.Contains(output, l => l == "main.steps:6: unresolved variable ${x}"); + } + + [Fact] + public void Try_FaultInsideContinuesAfterTheBlock_NonDeterministic() + { + var g = new FakeGame(); + g.ProbeFns["never"] = _ => RunValue.Of(false); + var (r, output) = Run("format 1\nmode player\ntry:\n wait never 3\n key A\nkey B", g, new ScriptRunOptions { Deterministic = false }); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Contains(output, l => l == "try failed at main.steps:4: timeout after 3 frames: never (never=0)"); + Assert.DoesNotContain(g.Sent, s => s.Event.Key == "A"); + Assert.Contains(g.Sent, s => s.Event.Key == "B"); + } + + // Review focus 1: a fault while a button is down releases it. + [Fact] + public void Fault_ReleasesHeldButtonAndKey() + { + var g = new FakeGame(); + g.ProbeFns["never"] = _ => RunValue.Of(false); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, + _ => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.KeyDown, Key: "Shift")), new PlannedInput(0, new InputEvent(InputKind.Down, 9, 9)), + new PlannedInput(5, new InputEvent(InputKind.Up, 9, 9)), new PlannedInput(5, new InputEvent(InputKind.KeyUp, Key: "Shift"))])), + polls => StepStatus.Running, 1, 1); + g.OnAdvance = f => { if (f == 2) throw new InvalidOperationException("crash"); }; + var (r, _) = Run("format 1\nmode player\nwalk-to 9,9", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.Up, 9, 9)); + Assert.Contains(g.Sent, s => s.Event == new InputEvent(InputKind.KeyUp, Key: "Shift")); + } + + [Fact] + public void Setup_RunsFirstAtFrameZero() + { + var g = new FakeGame(); + var order = new List(); + g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, a => { order.Add($"cheat@{g.Frame}"); return new ActionStart(Status: StepStatus.Done); }, min: 1, max: 3); + Run("format 1\nmode player\nsetup:\n cheat stats max\nkey R", g); + Assert.Equal(["cheat@0"], order); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs new file mode 100644 index 0000000..0738f7b --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptRunnerTests.cs @@ -0,0 +1,224 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptRunnerTests +{ + internal static (RunResult Result, List Output) Run(string main, FakeGame game, ScriptRunOptions? options = null, + Dictionary? files = null, Dictionary? vars = null) + { + var all = new Dictionary(files ?? []) { ["main.steps"] = main }; + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(all), vars ?? []); + var output = new List(); + var runner = new ScriptRunner(program, game, options ?? new ScriptRunOptions(), output.Add); + return (runner.Run(cap: 10_000), output); + } + + [Fact] + public void Click_DownAtStepFrameUpNextFrame() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nclick 100,200\nkey R", g); + Assert.Equal((RunOutcome.Passed, 0), (r.Outcome, r.ExitCode)); + // Hand-derived: frame 0 (game.Frame before first Advance) move+down, frame 1 up; key starts on frame 2: down, frame 3 up. + Assert.Equal( + [ + (0L, new InputEvent(InputKind.Move, 100, 200)), (0L, new InputEvent(InputKind.Down, 100, 200)), + (1L, new InputEvent(InputKind.Up, 100, 200)), + (2L, new InputEvent(InputKind.KeyDown, Key: "R")), (3L, new InputEvent(InputKind.KeyUp, Key: "R")), + ], g.Sent); + Assert.Equal(4, r.Frames); + } + + // A window host ends its batch of game frames when a step completes, so the next step resolves on a fresh draw. + [Fact] + public void StepsDone_CountsCompletedSteps() + { + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = "format 1\nmode player\nclick 100,200\nkey R" }), new Dictionary()); + var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { }); + Assert.Equal(0, runner.StepsDone); + runner.Run(cap: 10_000); + Assert.Equal(2, runner.StepsDone); + } + + // A window host skips the poll of a frame drawn later (a batch frame that closed a panel: a click resolved then + // would aim on the draw from before): the frame counts, the step is polled on the next one. + [Fact] + public void AfterFrame_NoPoll_CountsTheFrameOnly() + { + var program = ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = "format 1\nmode player\nwait-frames 1" }), new Dictionary()); + var runner = new ScriptRunner(program, new FakeGame(), new ScriptRunOptions(), _ => { }); + runner.BeforeFrame(); + runner.AfterFrame(poll: false); + Assert.Equal((1, 0), (runner.FrameCount, runner.StepsDone)); + runner.BeforeFrame(); + runner.AfterFrame(); + Assert.Equal((2, 1), (runner.FrameCount, runner.StepsDone)); + } + + [Fact] + public void Locator_AimsTheClick_OffScreenFaults() + { + var g = new FakeGame(); + g.Objects["Virgil"] = (640, 360); + var (ok, _) = Run("format 1\nmode player\nclick @obj(Virgil)", g); + Assert.Equal(RunOutcome.Passed, ok.Outcome); + Assert.Contains((0L, new InputEvent(InputKind.Down, 640, 360)), g.Sent); + var (bad, output) = Run("format 1\nmode player\nclick @obj(Bates)", new FakeGame()); + Assert.Equal((RunOutcome.Failed, 1), (bad.Outcome, bad.ExitCode)); + Assert.Contains(output, l => l == "FAIL main.steps:3 click @obj(Bates): locator @obj(Bates): no object Bates on screen"); + } + + [Fact] + public void Wait_HoldsUntilConditionOrTimesOut() + { + var g = new FakeGame(); + g.ProbeFns["ready"] = _ => RunValue.Of(g.Frame >= 5); + var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Equal(5L, g.Sent[0].Frame); // condition true after the 5th advance → key starts at frame 5 + + var g2 = new FakeGame(); + g2.ProbeFns["ready"] = _ => RunValue.Of(false); + g2.ProbeFns["quest"] = _ => RunValue.Of(1); + var (t, output) = Run("format 1\nmode player\nwait ready and quest(3) > 1 4", g2); + Assert.Equal(RunOutcome.Failed, t.Outcome); + Assert.Equal(4, t.Frames); + Assert.Contains(output, l => l == "FAIL main.steps:3 wait ready and quest(3) > 1 4: timeout after 4 frames: ready and quest(3) > 1 (ready=0 quest(3)=1)"); + } + + [Fact] + public void Wait_AlreadyTrue_TakesNoFrame() + { + var g = new FakeGame(); + g.ProbeFns["ready"] = _ => RunValue.Of(true); + var (r, _) = Run("format 1\nmode player\nwait ready 10\nkey A", g); + Assert.Equal(0L, g.Sent[0].Frame); + Assert.Equal(2, r.Frames); + } + + [Fact] + public void Expect_FailsWithValues() + { + var g = new FakeGame(); + g.ProbeFns["quest"] = _ => RunValue.Of(1); + var (r, output) = Run("format 1\nmode player\nexpect quest(1) >= accepted", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 expect quest(1) >= accepted: expect failed: quest(1) >= accepted (quest(1)=1)"); + } + + [Fact] + public void Action_PlanThenPollUntilDone() + { + var g = new FakeGame(); + var talk = new FakeAction(VerbKind.Player, args => new ActionStart(new InputPlan([new PlannedInput(0, new InputEvent(InputKind.Down, 5, 6)), new PlannedInput(1, new InputEvent(InputKind.Up, 5, 6))])), + polls => polls >= 3 ? StepStatus.Done : StepStatus.Running, 1, 1); + g.ActionTable["talk"] = talk; + var (r, _) = Run("format 1\nmode player\ntalk @obj(x)\nkey Z", g); + Assert.Equal(RunOutcome.Passed, r.Outcome); + Assert.Equal(["@obj(x)"], talk.Begun.Single()); + // Down sent f0, Up f1; polls run after advances to 2, 3, 4 (Done on the 3rd) → key starts at frame 4. + Assert.Equal(4L, g.Sent.Single(s => s.Event.Kind == InputKind.KeyDown).Frame); + } + + [Fact] + public void Action_ArgsGetVariablesExpanded_LocatorsLeftToTheGame() + { + var g = new FakeGame(); + var a = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 3); + g.ActionTable["cheat"] = a; + Run("format 1\nmode debug\nset n = 50\ncheat gold ${n}", g); + Assert.Equal(["gold", "50"], a.Begun.Single()); + } + + [Fact] + public void Action_FaultStatusFailsTheRun() + { + var g = new FakeGame(); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), polls => polls == 2 ? StepStatus.Fault("stuck at 3,4") : StepStatus.Running, 1, 1); + var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g); + Assert.Equal((RunOutcome.Failed, 2L), (r.Outcome, r.Frames)); + Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: stuck at 3,4"); + } + + // Review focus 2: a hook that never finishes hits the step cap. + [Fact] + public void StepCap_StopsAHangingAction() + { + var g = new FakeGame(); + g.ActionTable["walk-to"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), _ => StepStatus.Running, 1, 1); + var (r, output) = Run("format 1\nmode player\nwalk-to 3,4", g, new ScriptRunOptions { StepCap = 50 }); + Assert.Equal((RunOutcome.Failed, 50L), (r.Outcome, r.Frames)); + Assert.Contains(output, l => l == "FAIL main.steps:3 walk-to 3,4: step exceeded 50 frames"); + } + + [Fact] + public void GameHookException_IsAFailureNotACrash() + { + var g = new FakeGame(); + g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => throw new InvalidOperationException("boom"), min: 1, max: 1); + var (r, output) = Run("format 1\nmode player\ntalk x", g); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 talk x: InvalidOperationException: boom"); + } + + [Fact] + public void InvalidScript_Exit2_NoFrames() + { + var g = new FakeGame(); + var (r, output) = Run("format 1\nmode player\ndance", g); + Assert.Equal((RunOutcome.Invalid, 2, 0L), (r.Outcome, r.ExitCode, r.Frames)); + Assert.Contains(output, l => l == "main.steps:3: unknown verb dance"); + Assert.Equal(0, g.Frame); + } + + [Fact] + public void Rng_CallsTheGameHook_NoFrame() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nrng seed 9\nrng next hit 1", g); + Assert.Equal((RunOutcome.Passed, 0L), (r.Outcome, r.Frames)); + Assert.Equal(["seed 9", "next hit 1"], g.RngImpl!.Calls); + } + + [Fact] + public void WaitFrames_CountsFrames() + { + var g = new FakeGame(); + var (r, _) = Run("format 1\nmode player\nwait-frames 7\nwait-frames 0\nwait-frames 0.5s", g); + Assert.Equal(37, r.Frames); + } + + [Theory] + [InlineData("dclick 1,2", "Move@0 Down@0 Up@1 Down@2 Up@3")] + [InlineData("rclick 1,2", "Move@0 Down(R)@0 Up(R)@1")] + [InlineData("drag 0,0 30,60", "Move(0,0)@0 Down@0 Move(10,20)@1 Move(20,40)@2 Move(30,60)@3 Up@4")] + [InlineData("hold W 3", "KeyDown(W)@0 KeyUp(W)@3")] + [InlineData("chord Ctrl+S", "KeyDown(Ctrl)@0 KeyDown(S)@0 KeyUp(S)@1 KeyUp(Ctrl)@1")] + [InlineData("type \"hi there\"", "Text(hi there)@0")] + [InlineData("wheel -2 5,5", "Move@0 Wheel(-2)@0")] + public void InputPlans_Shapes(string line, string expected) + { + var g = new FakeGame(); + Run($"format 1\nmode player\n{line}", g); + string Show((long Frame, InputEvent E) s) => s.E.Kind switch + { + InputKind.Move when line.StartsWith("drag") => $"Move({s.E.X},{s.E.Y})@{s.Frame}", + InputKind.Down or InputKind.Up when s.E.Button == MouseButton.Right => $"{s.E.Kind}(R)@{s.Frame}", + InputKind.KeyDown or InputKind.KeyUp => $"{s.E.Kind}({s.E.Key})@{s.Frame}", + InputKind.Text => $"Text({s.E.Text})@{s.Frame}", + InputKind.Wheel => $"Wheel({s.E.Delta})@{s.Frame}", + _ => $"{s.E.Kind}@{s.Frame}", + }; + Assert.Equal(expected, string.Join(" ", g.Sent.Select(Show))); + } + + [Fact] + public void BadPosition_Faults() + { + var (r, output) = Run("format 1\nmode player\nclick 10;20", new FakeGame()); + Assert.Equal(RunOutcome.Failed, r.Outcome); + Assert.Contains(output, l => l == "FAIL main.steps:3 click 10;20: bad position '10;20'"); + } +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs new file mode 100644 index 0000000..98dcc73 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/ScriptValidatorTests.cs @@ -0,0 +1,63 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class ScriptValidatorTests +{ + private static FakeGame Game() + { + var g = new FakeGame(); + g.ProbeFns["quest"] = _ => RunValue.Of(0); + g.ProbeFns["dialog-open"] = _ => RunValue.Of(false); + g.ActionTable["talk"] = new FakeAction(VerbKind.Player, _ => new ActionStart(), min: 1, max: 1); + g.ActionTable["cheat"] = new FakeAction(VerbKind.Debug, _ => new ActionStart(Status: StepStatus.Done), min: 1, max: 20); + return g; + } + + private static string Validate(string main, bool deterministic = true, FakeGame? game = null) => + string.Join(" | ", ScriptValidator.Validate( + ScriptLoader.Load("main.steps", new MemoryScriptSource(new Dictionary { ["main.steps"] = main }), new Dictionary()), + game ?? Game(), deterministic).Select(e => e.ToString())); + + [Fact] + public void ValidPlayerScript_NoErrors() => + Assert.Equal("", Validate("format 1\nmode player\nsetup:\n cheat gold 5\ntalk @obj(Virgil)\nwait dialog-open 2s\nexpect quest(1) >= accepted\nclick 1,2\nkey R\nlog \"hi\"")); + + [Theory] + [InlineData("format 1\nmode player\ndance", "main.steps:3: unknown verb dance")] + [InlineData("format 1\nmode player\nwalk-to 3,4", "main.steps:3: verb walk-to not supported by game fake")] + [InlineData("format 1\nmode player\nclick", "main.steps:3: verb click takes 1..1 arguments, got 0")] + [InlineData("format 1\nmode player\ncheat gold 5", "main.steps:3: debug verb cheat outside setup in player mode")] + [InlineData("format 1\nmode player\nwait nope 10", "main.steps:3: unknown probe or constant nope")] + [InlineData("format 1\nmode player\nexpect quest(1) >=", "main.steps:3: condition ends early")] + [InlineData("format 1\nmode player\ntalk @npc(Virgil)", "main.steps:3: unknown locator @npc")] + [InlineData("format 1\nmode player\ntry:\n key R", "main.steps:3: try needs a non-deterministic run")] + [InlineData("format 1\nmode player\noutput loud", "main.steps:3: output level must be quiet|checkpoints|labels|steps|trace")] + public void Errors(string main, string error) => Assert.Contains(error, Validate(main)); + + [Fact] + public void DebugMode_DebugVerbAnywhere() => Assert.Equal("", Validate("format 1\nmode debug\ncheat gold 5\nkey R")); + + [Fact] + public void Try_AllowedNonDeterministic() => Assert.Equal("", Validate("format 1\nmode player\ntry:\n key R", deterministic: false)); + + [Fact] + public void Rng_NeedsTheGameHook() + { + var g = Game(); + g.RngImpl = null; + Assert.Contains("main.steps:3: verb rng not supported by game fake", Validate("format 1\nmode player\nrng seed 4", game: g)); + } + + [Fact] + public void GameVerb_ShadowingALibVerbWithAnotherKind_Rejected() + { + var g = Game(); + g.ActionTable["click"] = new FakeAction(VerbKind.Debug, _ => new ActionStart()); + Assert.Contains("game verb click shadows a lib verb", Validate("format 1\nmode debug\nkey R", game: g)); + } + + [Fact] + public void LoaderErrorsComeThrough() => Assert.Contains("main.steps:3: unknown def x", Validate("format 1\nmode debug\ncall x()")); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs new file mode 100644 index 0000000..0f787dc --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/StepLexerTests.cs @@ -0,0 +1,58 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class StepLexerTests +{ + [Theory] + [InlineData("click 10,20 # aim", "click 10,20 ")] + [InlineData("log \"a # b\" # c", "log \"a # b\" ")] + [InlineData("# only", "")] + [InlineData("type \"say \\\"#1\\\"\"", "type \"say \\\"#1\\\"\"")] + public void StripComment_OutsideQuotesOnly(string line, string expected) => + Assert.Equal(expected, StepLexer.StripComment(line)); + + [Fact] + public void Tokens_SplitOnSpaceQuotedStaysWhole() + { + var t = StepLexer.Tokens("use @inv(Lockpicks) on \"Big Door\""); + Assert.Equal(["use", "@inv(Lockpicks)", "on", "Big Door"], t.Select(x => x.Text)); + Assert.Equal([false, false, false, true], t.Select(x => x.Quoted)); + } + + [Fact] + public void Tokens_EscapesInQuotes() + { + var t = StepLexer.Tokens("type \"a \\\"b\\\" \\\\ c\""); + Assert.Equal("a \"b\" \\ c", t[1].Text); + } + + [Fact] + public void Tokens_ParenthesesKeepSpacesInOneToken() + { + // locator args may contain spaces: @npc(Gilbert Bates) + var t = StepLexer.Tokens("talk @npc(Gilbert Bates)"); + Assert.Equal(["talk", "@npc(Gilbert Bates)"], t.Select(x => x.Text)); + } + + [Fact] + public void Tokens_UnterminatedQuote_Throws() => + Assert.Equal("unterminated quote", Assert.Throws(() => StepLexer.Tokens("log \"abc")).Message); + + [Theory] + [InlineData("120", 120)] + [InlineData("2s", 120)] + [InlineData("0.5s", 30)] + [InlineData("0", 0)] + [InlineData("1.25s", 75)] + public void Durations_FramesAndSeconds(string text, int frames) => Assert.Equal(frames, Durations.Frames(text)); + + [Theory] + [InlineData("0.01s")] // 0.6 frames: not whole + [InlineData("-5")] + [InlineData("2m")] + [InlineData("")] + [InlineData("1e3")] + public void Durations_Invalid_Null(string text) => Assert.Null(Durations.Frames(text)); +} diff --git a/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs new file mode 100644 index 0000000..c6d3286 --- /dev/null +++ b/src/Godosa.Core.Tests/Runs/Scripts/VarsTests.cs @@ -0,0 +1,84 @@ +using Godosa.Core.Runs.Scripts; +using Xunit; + +namespace Godosa.Core.Tests.Runs.Scripts; + +public class VarsTests +{ + private static readonly Dictionary Values = new() + { + ["res"] = "1920x1080", ["hours"] = "8", ["ui.rest.8"] = "400,300", ["a"] = "b", ["b"] = "deep", + }; + + private static string Expand(string text) => VarExpander.Expand(text, n => Values.GetValueOrDefault(n), + p => p == "global(gold)" ? RunValue.Of(1250) : throw new ScriptFault($"no probe {p}")); + + [Theory] + [InlineData("ui/${res}.vars", "ui/1920x1080.vars")] + [InlineData("click ${ui.rest.${hours}}", "click 400,300")] + [InlineData("${${a}}", "deep")] + [InlineData("gold ${probe:global(gold)}", "gold 1250")] + [InlineData("cost $5 and $", "cost $5 and $")] // review focus 4: lone $ is literal + [InlineData("no vars", "no vars")] + public void Expand_Values(string text, string expected) => Assert.Equal(expected, Expand(text)); + + [Theory] + [InlineData("${missing}", "unresolved variable ${missing}")] + [InlineData("x ${a", "unclosed ${ in 'x ${a'")] + [InlineData("${}", "empty ${}")] + public void Expand_Errors_AreFaults(string text, string message) => + Assert.Equal(message, Assert.Throws(() => Expand(text)).Message); + + [Fact] + public void Names_InnermostPlainOnly() => + Assert.Equal(["hours", "res"], VarExpander.Names("${ui.${hours}} ${res} ${probe:quest(1)}").Order()); + + [Fact] + public void VarsFile_ParsesAndReportsBadLines() + { + var (values, errors) = VarsFile.Parse("ui/hd.vars", "# hd\nui.answer1 = 412,520\n\nui.rest.8=400,300\nbroken line\n"); + Assert.Equal("412,520", values["ui.answer1"]); + Assert.Equal("400,300", values["ui.rest.8"]); + Assert.Equal(["ui/hd.vars:5: bad vars line (name = value)"], errors.Select(e => e.ToString())); + } + + [Fact] + public void Scopes_SetGoesToLocalOrRoot() + { + var s = new VarScopes(OutputLevel.Labels); + s.Set("g", "1"); + s.Push(local: false, OutputLevel.Labels, []); // include + s.Set("inc", "2"); // global: root + s.Push(local: true, OutputLevel.Labels, [new("p", "x")]); // def call + s.Set("loc", "3"); + Assert.Equal(("1", "2", "3", "x"), (s.Get("g"), s.Get("inc"), s.Get("loc"), s.Get("p"))); + s.Pop(); + Assert.Null(s.Get("loc")); + Assert.Null(s.Get("p")); + s.Pop(); + Assert.Equal("2", s.Get("inc")); + } + + [Fact] + public void Scopes_OutputLevelRestoredOnPop() + { + var s = new VarScopes(OutputLevel.Labels); + s.Push(local: false, s.Level, []); + s.Level = OutputLevel.Trace; + s.Pop(); + Assert.Equal(OutputLevel.Labels, s.Level); + } + + [Fact] + public void Scopes_SnapshotRestore_RoundTrips() + { + var s = new VarScopes(OutputLevel.Labels); + s.Set("g", "1"); + s.Push(local: true, OutputLevel.Steps, [new("p", "x")]); + var copy = new VarScopes(OutputLevel.Quiet); + copy.Restore(s.Snapshot()); + Assert.Equal(("1", "x", OutputLevel.Steps), (copy.Get("g"), copy.Get("p"), copy.Level)); + s.Set("p", "changed"); + Assert.Equal("x", copy.Get("p")); // deep copy + } +} diff --git a/src/Godosa.Core.Tests/SettingsFileTests.cs b/src/Godosa.Core.Tests/SettingsFileTests.cs new file mode 100644 index 0000000..7308c2b --- /dev/null +++ b/src/Godosa.Core.Tests/SettingsFileTests.cs @@ -0,0 +1,90 @@ +using Xunit; + +namespace Godosa.Core.Tests; + +public sealed class SettingsFileTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "godosa-settings-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); + } + + private readonly List _reports = new(); + + private SettingsFile Store(string path, Func? parse = null) => + new(path, parse ?? (text => "parsed:" + text), value => value, () => "defaults", _reports.Add); + + [Fact] + public void Missing_GivesDefaults() + { + var file = Store(Path.Combine(_dir, "settings.json"), _ => throw new InvalidOperationException("parsed")); + Assert.Equal("defaults", file.Load()); + Assert.Empty(_reports); // a first run is not a problem + } + + [Fact] + public void Save_CreatesFolder_ThenLoadParsesWhatWasWritten_NoTempLeft() + { + string path = Path.Combine(_dir, "sub", "settings.json"); + var file = Store(path); + file.Save("{\"a\":1}"); + Assert.Equal("{\"a\":1}", System.IO.File.ReadAllText(path)); + Assert.Equal("parsed:{\"a\":1}", file.Load()); + Assert.False(System.IO.File.Exists(path + ".tmp")); + } + + [Fact] + public void ParseFailure_FallsBackToDefaults() + { + string path = Path.Combine(_dir, "settings.json"); + Directory.CreateDirectory(_dir); + System.IO.File.WriteAllText(path, "\u0000junk"); + var file = Store(path, _ => throw new FormatException("bad")); + Assert.Equal("defaults", file.Load()); + Assert.Contains("load failed", Assert.Single(_reports)); + } + + [Fact] + public void SaveFailure_DoesNotThrow() + { + Directory.CreateDirectory(Path.Combine(_dir, "settings.json")); // a folder where the file should go + Store(Path.Combine(_dir, "settings.json")).Save("x"); + Assert.Contains("save failed", Assert.Single(_reports)); + Assert.False(System.IO.File.Exists(Path.Combine(_dir, "settings.json.tmp")), "temp file cleaned up"); + } + + [Fact] + public void BareRelativePath_SavesInTheWorkingFolder() + { + string name = "godosa-settings-" + Guid.NewGuid().ToString("N") + ".json"; + try + { + Store(name).Save("x"); + Assert.Empty(_reports); + Assert.Equal("x", System.IO.File.ReadAllText(Path.GetFullPath(name))); + } + finally + { + System.IO.File.Delete(Path.GetFullPath(name)); + } + } + + [Fact] + public void DefaultPath_UsesOverrideWhenSet_ElseAppDataFolder() + { + string variable = "GODOSA_TEST_SETTINGS_" + Guid.NewGuid().ToString("N"); + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + Assert.Equal(Path.Combine(appData, "some-game", "settings.json"), SettingsFile.DefaultPath(variable, "some-game")); + Environment.SetEnvironmentVariable(variable, "/tmp/x.json"); + try + { + Assert.Equal("/tmp/x.json", SettingsFile.DefaultPath(variable, "some-game")); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } +} diff --git a/src/Godosa.Core/Audio/AudioTypes.cs b/src/Godosa.Core/Audio/AudioTypes.cs new file mode 100644 index 0000000..1ef0730 --- /dev/null +++ b/src/Godosa.Core/Audio/AudioTypes.cs @@ -0,0 +1,45 @@ +namespace Godosa.Core.Audio; + +/// 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