diff options
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/ScriptRunner.cs')
| -rw-r--r-- | src/Godosa.Core/Runs/Scripts/ScriptRunner.cs | 568 |
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; +} |
