// 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 _labels = [];
private Active? _active;
private sealed record TryFrame(int End, int ScopeDepth, int LabelCount);
private readonly List _tries = [];
private int _pc;
private bool _started;
public ScriptRunner(ScriptProgram program, IRunGame game, ScriptRunOptions options, Action 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; }
/// Steps completed so far (a window host draws before the next one resolves its positions).
public int StepsDone => _steps;
/// Effective seed: options, else the script's seed header, else 0.
public ulong Seed => _options.Seed ?? _program.Seed ?? 0;
public RunResult? Result { get; private set; }
public RunReport? Report { get; private set; }
private readonly Queue _recent = new();
private readonly List _checkpoints = [];
private readonly List _warnings = [];
private readonly System.Diagnostics.Stopwatch _wall = new();
private int _steps, _cacheHits, _cacheMisses;
private CheckpointCache? _cache;
private IReadOnlyDictionary _keys = new Dictionary();
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);
}
/// A deferred checkpoint restore ( ) is under way: the host advances no
/// game frame and calls not at all this frame; the next goes on.
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}");
}
});
}
}
/// 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.
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(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 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 | rng next ");
}
}
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();
}
/// 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).
private List 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}");
}
}
/// Review focus 1: a half-sent plan leaves no button or key held — pending ups are sent now.
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 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;
}