aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/ScriptRunner.cs')
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunner.cs568
1 files changed, 568 insertions, 0 deletions
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
new file mode 100644
index 0000000..edc7b97
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptRunner.cs
@@ -0,0 +1,568 @@
+// Provenance: own design — shared run scripts (spec §5)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record ScriptRunOptions
+{
+ public bool Deterministic { get; init; } = true;
+ /// <summary>Overrides the script's <c>seed</c> header; neither → 0 and the game's rng is left alone.</summary>
+ public ulong? Seed { get; init; }
+ public OutputLevel Output { get; init; } = OutputLevel.Labels;
+ public OutputLevel Floor { get; init; } = OutputLevel.Quiet;
+ public int StepCap { get; init; } = 36_000;
+ public string? CacheDir { get; init; }
+ public bool UseCache { get; init; } = true;
+ /// <summary>false: checkpoints are markers — no digest (a whole-state serialization, slow in a big game), nothing
+ /// cached (<c>--no-snapshots</c>).</summary>
+ public bool Snapshots { get; init; } = true;
+ public string? From { get; init; }
+ public string? Until { get; init; }
+ public string? ReportDir { get; init; }
+ public long CacheCapBytes { get; init; } = 2L << 30;
+}
+
+public enum RunOutcome { Passed, Failed, Invalid, Stopped }
+
+public sealed record RunResult(RunOutcome Outcome, int ExitCode, long Frames, string? Failure);
+
+public sealed class ScriptRunner : IConditionContext
+{
+ private sealed class Active(Instr instr, long startFrame)
+ {
+ public Instr Instr { get; } = instr;
+ public long StartFrame { get; } = startFrame;
+ public int Frames { get; set; }
+ public InputPlan? Plan { get; set; }
+ /// <summary>The <see cref="Frames"/> value a plan's frame 0 lands on (0 for the step's own plan).</summary>
+ public int PlanBase { get; set; }
+ public int Sent { get; set; }
+ public IRunAction? Action { get; init; }
+ public Cond? Wait { get; init; }
+ public int? WaitFrames { get; init; }
+ }
+
+ private sealed record Label(string Name, long Start, SourceRef At);
+
+ private readonly ScriptProgram _program;
+ private readonly IRunGame _game;
+ private readonly ScriptRunOptions _options;
+ private readonly RunOutput _out;
+ private readonly VarScopes _vars;
+ private readonly List<Label> _labels = [];
+ private Active? _active;
+ private sealed record TryFrame(int End, int ScopeDepth, int LabelCount);
+ private readonly List<TryFrame> _tries = [];
+ private int _pc;
+ private bool _started;
+
+ public ScriptRunner(ScriptProgram program, IRunGame game, ScriptRunOptions options, Action<string> output)
+ {
+ (_program, _game, _options) = (program, game, options);
+ _out = new RunOutput(output, options.Floor);
+ _vars = new VarScopes(options.Output);
+ foreach (var (k, v) in program.Vars)
+ _vars.Set(k, v);
+ }
+
+ public long FrameCount { get; private set; }
+
+ /// <summary>Steps completed so far (a window host draws before the next one resolves its positions).</summary>
+ public int StepsDone => _steps;
+
+ /// <summary>Effective seed: options, else the script's <c>seed</c> header, else 0.</summary>
+ public ulong Seed => _options.Seed ?? _program.Seed ?? 0;
+ public RunResult? Result { get; private set; }
+ public RunReport? Report { get; private set; }
+
+ private readonly Queue<string> _recent = new();
+ private readonly List<CheckpointRecord> _checkpoints = [];
+ private readonly List<string> _warnings = [];
+ private readonly System.Diagnostics.Stopwatch _wall = new();
+ private int _steps, _cacheHits, _cacheMisses;
+ private CheckpointCache? _cache;
+ private IReadOnlyDictionary<int, string> _keys = new Dictionary<int, string>();
+
+ public RunResult Run(long cap = HeadlessLoop.DefaultCap)
+ {
+ var (_, capped) = HeadlessLoop.Run(_ => Frame(), null, cap);
+ if (capped && Result == null)
+ Fail(_active?.Instr, $"frame cap {cap}");
+ return Result ?? new RunResult(RunOutcome.Passed, 0, FrameCount, null);
+ }
+
+ /// <summary>A deferred checkpoint restore (<see cref="IRunGame.RestoreDeferred"/>) is under way: the host advances no
+ /// game frame and calls <see cref="AfterFrame"/> not at all this frame; the next <see cref="BeforeFrame"/> goes on.</summary>
+ public bool Restoring { get; private set; }
+
+ public bool Frame()
+ {
+ BeforeFrame();
+ if (Result != null)
+ return false;
+ if (Restoring)
+ return true;
+ Guard(_active?.Instr, _game.Advance);
+ if (Result == null)
+ AfterFrame();
+ return Result == null;
+ }
+
+ public void BeforeFrame()
+ {
+ if (Result != null)
+ return;
+ if (!_started)
+ {
+ var ok = false;
+ Guard(null, () => ok = Start());
+ if (!ok || Result != null)
+ return;
+ if (Restoring = _cacheHits > 0 && _game.RestoreDeferred)
+ return;
+ }
+ Restoring = false;
+ Guard(_active?.Instr, () =>
+ {
+ while (_active == null && Result == null)
+ {
+ if (_pc >= _program.Code.Count)
+ {
+ Finish(new RunResult(RunOutcome.Passed, 0, FrameCount, null), null);
+ return;
+ }
+ Exec(_program.Code[_pc++]);
+ }
+ });
+ if (_active is { Plan: { } plan } a && Result == null)
+ {
+ Guard(a.Instr, () =>
+ {
+ foreach (var e in plan.Events.Where(e => e.Frame + a.PlanBase == a.Frames))
+ {
+ _game.Input.Send(e.Event);
+ a.Sent++;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" input f={FrameCount} {e.Event}");
+ }
+ });
+ }
+ }
+
+ /// <param name="poll">false: the frame counts but the step is not polled (a window host whose next draw shows what this
+ /// frame changed: a click resolved now would aim on the draw from before); the next frame polls it.</param>
+ public void AfterFrame(bool poll = true)
+ {
+ if (Result != null || _active is not { } a)
+ return;
+ FrameCount++;
+ a.Frames++;
+ if (!poll)
+ return;
+ Guard(a.Instr, () =>
+ {
+ var status = Poll(a);
+ if (status.Plan is { } more)
+ (a.Plan, a.PlanBase, a.Sent) = (more, a.Frames, 0);
+ if (status.State == StepState.Running && a.Frames >= _options.StepCap)
+ status = StepStatus.Fault($"step exceeded {_options.StepCap} frames");
+ if (status.State == StepState.Fault)
+ throw new ScriptFault(status.Message ?? "fault");
+ if (status.State == StepState.Done)
+ Complete(a.Instr, a.Frames);
+ });
+ }
+
+ private bool Start()
+ {
+ _started = true;
+ _wall.Start();
+ var errors = ScriptValidator.Validate(_program, _game, _options.Deterministic);
+ if (errors.Count > 0)
+ {
+ foreach (var e in errors)
+ _out.Always(e.ToString());
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, errors[0].ToString()), null);
+ return false;
+ }
+ var until = int.MaxValue;
+ if (_options.Until is { } stop)
+ {
+ until = _program.Code.Select((c, n) => (c, n)).FirstOrDefault(x => x.c.A == stop && x.c.Op is Op.Checkpoint or Op.LabelEnd, (null!, -1)).Item2;
+ if (until < 0)
+ return Invalid($"unknown label or checkpoint {stop}");
+ }
+ if ((_options.Seed ?? _program.Seed) is { } seed && _game.Rng is { } rng)
+ rng.Seed(unchecked((long)seed));
+ if (_options.Deterministic && _options.CacheDir != null && _game.SupportsSnapshot)
+ {
+ _cache = new CheckpointCache(_options.CacheDir, _options.CacheCapBytes);
+ _keys = CheckpointKeys.Compute(_program, _game.ContentVersion, Seed);
+ }
+ if (_options.From is { } from)
+ {
+ var index = _keys.Keys.Concat(_program.Code.Select((c, n) => (c, n)).Where(x => x.c.Op == Op.Checkpoint).Select(x => x.n))
+ .Distinct().FirstOrDefault(n => _program.Code[n].A == from, -1);
+ if (index < 0)
+ return Invalid($"unknown checkpoint {from}");
+ if (_cache?.Load(_keys[index]) is not { } entry)
+ return Invalid($"checkpoint {from} not cached");
+ Resume(index, entry);
+ }
+ else if (_cache != null && _options.UseCache)
+ {
+ foreach (var index in _keys.Keys.Where(k => k < until).OrderDescending())
+ {
+ if (_cache.Load(_keys[index]) is { } entry)
+ {
+ Resume(index, entry);
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private bool Invalid(string message)
+ {
+ _out.Always(message);
+ Finish(new RunResult(RunOutcome.Invalid, 2, 0, message), null);
+ return false;
+ }
+
+ private void Resume(int index, CachedCheckpoint entry)
+ {
+ _game.SnapshotRestore(entry.Game);
+ FrameCount = entry.State.Frame;
+ _vars.Restore(entry.State.Scopes);
+ _labels.Clear();
+ _labels.AddRange(entry.State.Labels.Select(l => new Label(l.Name, l.Start, new SourceRef(l.File, l.Line))));
+ _pc = index + 1;
+ var name = _program.Code[index].A;
+ _checkpoints.Add(new CheckpointRecord(name, FrameCount, entry.State.Digest.ToString("x16"), "resumed"));
+ _cacheHits++;
+ _out.Always($"resumed at {name} (cache) f={FrameCount}");
+ }
+
+ private StepStatus Poll(Active a)
+ {
+ if (a.Plan is { } plan && a.Sent < plan.Events.Count)
+ return StepStatus.Running;
+ if (a.Action != null)
+ return a.Action.Poll();
+ if (a.WaitFrames is { } n)
+ return a.Frames >= n ? StepStatus.Done : StepStatus.Running;
+ if (a.Wait is { } cond)
+ {
+ if (cond.Test(this))
+ return StepStatus.Done;
+ _out.Emit(OutputLevel.Trace, _vars.Level, $" wait f={FrameCount} {a.Instr.A}: {Cond.Describe(cond, this)}");
+ return a.Frames >= a.Instr.Timeout
+ ? StepStatus.Fault($"timeout after {a.Instr.Timeout} frames: {a.Instr.A} ({Cond.Describe(cond, this)})")
+ : StepStatus.Running;
+ }
+ return StepStatus.Done;
+ }
+
+ private void Exec(Instr i)
+ {
+ switch (i.Op)
+ {
+ case Op.Set:
+ _vars.Set(i.A, Expand(i.B));
+ break;
+ case Op.Expect:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (!cond.Test(this))
+ throw new ScriptFault($"expect failed: {i.A} ({Cond.Describe(cond, this)})");
+ Complete(i, 0);
+ break;
+ }
+ case Op.Wait:
+ {
+ var cond = ConditionParser.Parse(Expand(i.A));
+ if (cond.Test(this))
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { Wait = cond };
+ break;
+ }
+ case Op.Verb:
+ Verb(i);
+ break;
+ case Op.ScopeBegin:
+ _vars.Push(i.LocalVars, _vars.Level, i.Bindings.Select(b => new KeyValuePair<string, string>(b.Key, Expand(b.Value))).ToList());
+ break;
+ case Op.ScopeEnd:
+ _vars.Pop();
+ break;
+ case Op.LabelBegin:
+ _labels.Add(new Label(i.A, FrameCount, i.At));
+ break;
+ case Op.LabelEnd:
+ {
+ var label = _labels[^1];
+ _labels.RemoveAt(_labels.Count - 1);
+ _out.Emit(OutputLevel.Labels, _vars.Level, $"[{label.At} {label.Name}] done f={FrameCount} (+{FrameCount - label.Start})");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Labels, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, _game.Digest()));
+ if (_options.Until == label.Name)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ case Op.TryBegin:
+ _tries.Add(new TryFrame(i.Jump, _vars.Depth, _labels.Count));
+ break;
+ case Op.TryEnd:
+ if (_tries.Count > 0)
+ _tries.RemoveAt(_tries.Count - 1);
+ break;
+ case Op.Checkpoint:
+ {
+ if (!_options.Snapshots)
+ {
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, "", "marker"));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ var digest = _game.Digest();
+ var source = "replayed";
+ if (_cache != null && _game.SnapshotRefusal() is { } refusal)
+ _warnings.Add($"checkpoint {i.A} not cached: {refusal}");
+ else if (_cache != null && _keys.TryGetValue(_pc - 1, out var key))
+ {
+ var old = _cache.Load(key);
+ if (old != null && old.State.Digest == digest)
+ source = "cached";
+ else
+ {
+ if (old != null)
+ _warnings.Add($"checkpoint {i.A} digest differs from cache ({old.State.Digest:x16} → {digest:x16}); cache replaced");
+ try
+ {
+ _cache.Save(key, new CachedCheckpoint(new RunnerState(FrameCount, digest, _vars.Snapshot(),
+ [.. _labels.Select(l => new LabelState(l.Name, l.Start, l.At.File, l.At.Line))]), _game.SnapshotSave()));
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"checkpoint cache not written: {e.Message}");
+ }
+ _cacheMisses++;
+ }
+ }
+ _checkpoints.Add(new CheckpointRecord(i.A, FrameCount, digest.ToString("x16"), source));
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, $"checkpoint {i.A} f={FrameCount}");
+ if (_options.Deterministic)
+ _out.Emit(OutputLevel.Checkpoints, _vars.Level, DigestLine.Format(FrameCount, FrameCount / 60.0, digest));
+ if (_options.Until == i.A)
+ Finish(new RunResult(RunOutcome.Stopped, 0, FrameCount, null), null);
+ break;
+ }
+ }
+ }
+
+ private void Verb(Instr i)
+ {
+ var tokens = ExpandTokens(i.B);
+ switch (i.A)
+ {
+ case "log":
+ _out.Always(tokens[0].Text);
+ return;
+ case "output":
+ _vars.Level = RunOutput.Parse(tokens[0].Text);
+ return;
+ case "rng":
+ Rng(tokens);
+ return;
+ case "wait-frames":
+ {
+ var n = Durations.Frames(tokens[0].Text) ?? throw new ScriptFault($"bad frame count '{tokens[0].Text}'");
+ if (n == 0)
+ Complete(i, 0);
+ else
+ _active = new Active(i, FrameCount) { WaitFrames = n };
+ return;
+ }
+ }
+ if (InputPlans.IsInputVerb(i.A))
+ {
+ _active = new Active(i, FrameCount) { Plan = InputPlans.For(i.A, tokens, t => Positions.Resolve(t, _game)) };
+ return;
+ }
+ var action = _game.Actions[i.A];
+ var start = action.Begin(tokens.Select(t => t.Text).ToList());
+ if (start.Status is { State: StepState.Fault } fault)
+ throw new ScriptFault(fault.Message ?? "fault");
+ if (start.Plan == null && start.Status is { State: StepState.Done })
+ {
+ Complete(i, 0);
+ return;
+ }
+ _active = new Active(i, FrameCount) { Plan = start.Plan, Action = action };
+ }
+
+ private void Rng(List<Token> t)
+ {
+ var rng = _game.Rng ?? throw new ScriptFault("game has no rng hook");
+ long N(string s) => long.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new ScriptFault($"bad number '{s}'");
+ switch (t[0].Text)
+ {
+ case "seed" when t.Count == 2:
+ rng.Seed(N(t[1].Text));
+ break;
+ case "next" when t.Count == 3:
+ rng.ForceNext(t[1].Text, N(t[2].Text));
+ break;
+ default:
+ throw new ScriptFault("rng seed <n> | rng next <kind> <value>");
+ }
+ }
+
+ private void Complete(Instr i, int frames)
+ {
+ _active = null;
+ var label = _labels.Count > 0 ? $" {_labels[^1].Name}" : "";
+ _out.Emit(OutputLevel.Steps, _vars.Level, $"[{i.At}{label}] {i.Text} done f={FrameCount} (+{frames})");
+ _steps++;
+ _recent.Enqueue($"{i.At} {i.Text} f={FrameCount}");
+ while (_recent.Count > 20)
+ _recent.Dequeue();
+ }
+
+ /// <summary>Quoted arguments expand inside their token (a value with quotes or spaces stays one argument); unquoted ones
+ /// expand, then split (a variable may hold several arguments).</summary>
+ private List<Token> ExpandTokens(string text) =>
+ [.. StepLexer.Tokens(text).SelectMany(t => t.Quoted ? [new Token(Expand(t.Text), true)] : StepLexer.Tokens(Expand(t.Text)))];
+
+ private string Expand(string text) => VarExpander.Expand(text, _vars.Get, p =>
+ {
+ var cond = ConditionParser.Parse(p);
+ return cond.Eval(this);
+ });
+
+ private void Guard(Instr? at, Action step)
+ {
+ try
+ {
+ step();
+ }
+ catch (ScriptFault e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (FormatException e)
+ {
+ Fail(at ?? CurrentOrLast(), e.Message);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ Fail(at ?? CurrentOrLast(), $"{e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ private Instr? CurrentOrLast() => _pc > 0 && _pc <= _program.Code.Count ? _program.Code[_pc - 1] : null;
+
+ private void Fail(Instr? at, string reason)
+ {
+ ReleaseHeld();
+ _active = null;
+ if (_tries.Count > 0)
+ {
+ var t = _tries[^1];
+ _tries.RemoveAt(_tries.Count - 1);
+ _vars.PopTo(t.ScopeDepth);
+ _labels.RemoveRange(t.LabelCount, _labels.Count - t.LabelCount);
+ _out.Always($"try failed at {at?.At}: {reason}");
+ _pc = t.End + 1;
+ return;
+ }
+ var where = at == null ? "" : $" {at.At} {at.Text}";
+ byte[]? snap = null;
+ if (_game.SupportsSnapshot)
+ {
+ try
+ {
+ snap = _game.SnapshotSave();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ _warnings.Add($"failure save not written: {e.Message}");
+ }
+ }
+ string dump;
+ try
+ {
+ dump = _game.Dump();
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ dump = $"(dump failed: {e.Message})";
+ }
+ var failure = new FailureInfo(at?.At.ToString() ?? "", at?.Chain.Select(c => c.ToString()).ToList() ?? [], at?.Text ?? "",
+ _labels.Count > 0 ? _labels[^1].Name : null, reason, [.. _recent], dump, _vars.Visible(),
+ snap != null && _options.ReportDir != null ? Path.Combine(_options.ReportDir, "fail.snap") : null);
+ _out.Always($"FAIL{where}: {reason}");
+ Finish(new RunResult(RunOutcome.Failed, 1, FrameCount, reason), failure, snap);
+ }
+
+ private void Finish(RunResult result, FailureInfo? failure, byte[]? failSnapshot = null)
+ {
+ Result = result;
+ _wall.Stop();
+ Report = new RunReport(_program.Entry, _program.Mode, Seed, _options.Deterministic, result.Outcome, FrameCount,
+ _steps, _wall.Elapsed.TotalSeconds, _cacheHits, _cacheMisses, [.. _checkpoints], [.. _warnings], failure);
+ if (_options.ReportDir is not { } dir)
+ return;
+ try
+ {
+ ReportWriter.Write(dir, Report, failSnapshot);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _warnings.Add($"report not written: {e.Message}");
+ Report = Report with { Warnings = [.. _warnings] };
+ _out.Always($"warning: report not written: {e.Message}");
+ }
+ }
+
+ /// <summary>Review focus 1: a half-sent plan leaves no button or key held — pending ups are sent now.</summary>
+ private void ReleaseHeld()
+ {
+ if (_active is not { Plan: { } plan } a)
+ return;
+ var sent = plan.Events.Take(a.Sent).Select(e => e.Event).ToList();
+ foreach (var pending in plan.Events.Skip(a.Sent).Select(e => e.Event))
+ {
+ var held = pending.Kind switch
+ {
+ InputKind.Up => sent.Count(e => e.Kind == InputKind.Down && e.Button == pending.Button)
+ > sent.Count(e => e.Kind == InputKind.Up && e.Button == pending.Button),
+ InputKind.KeyUp => sent.Count(e => e.Kind == InputKind.KeyDown && e.Key == pending.Key)
+ > sent.Count(e => e.Kind == InputKind.KeyUp && e.Key == pending.Key),
+ _ => false,
+ };
+ if (held)
+ {
+ try
+ {
+ _game.Input.Send(pending);
+ }
+ catch (Exception e) when (e is not OutOfMemoryException)
+ {
+ // The game is failing anyway; the report carries the first error.
+ }
+ sent.Add(pending);
+ }
+ }
+ }
+
+ bool IConditionContext.HasProbe(string name) => _game.Probes.ContainsKey(name);
+
+ RunValue IConditionContext.Probe(string name, IReadOnlyList<string> args) =>
+ _game.Probes.TryGetValue(name, out var p) ? p.Eval(args) : throw new ScriptFault($"unknown probe {name}");
+
+ long? IConditionContext.Constant(string name) => _game.Constants.TryGetValue(name, out var v) ? v : null;
+}