aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Runs/Scripts
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts')
-rw-r--r--src/Godosa.Core/Runs/Scripts/CheckpointCache.cs112
-rw-r--r--src/Godosa.Core/Runs/Scripts/Condition.cs234
-rw-r--r--src/Godosa.Core/Runs/Scripts/Input.cs24
-rw-r--r--src/Godosa.Core/Runs/Scripts/InputPlans.cs111
-rw-r--r--src/Godosa.Core/Runs/Scripts/LibVerbs.cs32
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunGame.cs68
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunOutput.cs21
-rw-r--r--src/Godosa.Core/Runs/Scripts/RunReport.cs58
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptAst.cs25
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptLoader.cs218
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptParser.cs231
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptProgram.cs57
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunFlags.cs100
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptRunner.cs568
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptTypes.cs46
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptValidator.cs109
-rw-r--r--src/Godosa.Core/Runs/Scripts/StepLexer.cs104
-rw-r--r--src/Godosa.Core/Runs/Scripts/Vars.cs174
18 files changed, 2292 insertions, 0 deletions
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<ScopeState> Scopes, List<LabelState> Labels);
+
+public sealed record CachedCheckpoint(RunnerState State, byte[] Game);
+
+public static class CheckpointKeys
+{
+ public const string RunnerVersion = "run-scripts 1";
+
+ public static IReadOnlyDictionary<int, string> 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<int, string>();
+ 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<RunnerState>(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<byte> 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<string> args);
+ long? Constant(string name);
+}
+
+/// <summary>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.</summary>
+public abstract record Cond
+{
+ public abstract RunValue Eval(IConditionContext c);
+
+ public bool Test(IConditionContext c) => Truthy(Eval(c));
+
+ public abstract IEnumerable<(string Name, IReadOnlyList<string> Args)> Probes();
+
+ public abstract IEnumerable<string> 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<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> 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<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> 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<string>)> Probes() => A.Probes();
+ public override IEnumerable<string> 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<string>)> Probes() => A.Probes().Concat(B.Probes());
+ public override IEnumerable<string> 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<string>)> Probes() => [];
+ public override IEnumerable<string> Bare() => [];
+}
+
+/// <summary>A probe call <c>name(args)</c>, or a bare <c>name</c> (probe if the game has it, else a constant).</summary>
+public sealed record NameCond(string Name, IReadOnlyList<string> 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<string>)> Probes() => [(Name, Args)];
+ public override IEnumerable<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> Lex(string text)
+ {
+ var tokens = new List<string>();
+ 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 }
+
+/// <summary>One simulated user input event at virtual-screen coordinates.</summary>
+public sealed record InputEvent(InputKind Kind, int X = 0, int Y = 0, MouseButton Button = MouseButton.Left, string Key = "",
+ string Text = "", int Delta = 0);
+
+/// <summary>An event due <see cref="Frame"/> frames after its step began.</summary>
+public sealed record PlannedInput(int Frame, InputEvent Event);
+
+public sealed record InputPlan(IReadOnlyList<PlannedInput> Events)
+{
+ public int LastFrame => Events.Count == 0 ? 0 : Events.Max(e => e.Frame);
+}
+
+/// <summary>The game's input entry: the real UI in a window, its layout + hit-testing headless.</summary>
+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
+{
+ /// <summary><c>x,y</c> or a locator <c>@name(args)</c> (args comma separated).</summary>
+ 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<Token> args, Func<string, (int X, int Y)> position)
+ {
+ List<PlannedInput> 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;
+
+/// <summary>A verb the lib defines. <see cref="GameHook"/> = implemented by the game's <see cref="IRunGame.Actions"/>
+/// entry of the same name; otherwise the runner implements it.</summary>
+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<VerbSpec> 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<string, VerbSpec> 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;
+
+/// <summary>A game's run-script adapter. <see cref="Actions"/> holds the lib's game-hooked verbs by their lib name
+/// (<c>talk</c>, <c>walk-to</c>, <c>cheat</c>, …) and the game's own verbs. Player-kind actions return input plans only;
+/// the lib injects them.</summary>
+public interface IRunGame
+{
+ string Name { get; }
+ string ContentVersion { get; }
+ IInputRouter Input { get; }
+ IReadOnlyDictionary<string, IProbe> Probes { get; }
+ IReadOnlyDictionary<string, long> Constants { get; }
+ IReadOnlyDictionary<string, ILocator> Locators { get; }
+ IReadOnlyDictionary<string, IRunAction> Actions { get; }
+ IRunRng? Rng { get; }
+ bool SupportsSnapshot { get; }
+ byte[] SnapshotSave();
+ /// <summary>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.</summary>
+ string? SnapshotRefusal() => null;
+ void SnapshotRestore(byte[] data);
+ /// <summary>The restore completes after this frame (a window rebuilds its game view): the runner pauses one frame
+ /// (<see cref="ScriptRunner.Restoring"/>), in which the host advances no game frame.</summary>
+ bool RestoreDeferred => false;
+ ulong Digest();
+ string Dump();
+ void Advance();
+}
+
+public interface IProbe
+{
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ RunValue Eval(IReadOnlyList<string> 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;
+}
+
+/// <summary>Finds where to aim (virtual-screen position) from game state, as a player reads the screen.</summary>
+public interface ILocator
+{
+ LocatorResult Locate(IReadOnlyList<string> args);
+}
+
+/// <summary>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.</summary>
+public interface IRunAction
+{
+ VerbKind Kind { get; }
+ int MinArgs { get; }
+ int MaxArgs { get; }
+ ActionStart Begin(IReadOnlyList<string> 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;
+
+/// <summary>Auto-output filter: a line of <c>level</c> prints when <c>level ≤ max(current, floor)</c>; <see cref="Always"/>
+/// = log lines and failures.</summary>
+public sealed class RunOutput(Action<string> 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<string> Chain, string Step, string? Label, string Reason,
+ IReadOnlyList<string> Recent, string Dump, IReadOnlyDictionary<string, string> 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<CheckpointRecord> Checkpoints,
+ IReadOnlyList<string> 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 }
+
+/// <summary>One parsed line (or block header) of a <c>.steps</c> file; <see cref="Text"/> = the trimmed source line.</summary>
+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<string> 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<Node> Body) : Node(At, Text);
+public sealed record DefNode(SourceRef At, string Text, string Name, IReadOnlyList<string> Params, IReadOnlyList<Node> Body) : Node(At, Text);
+
+public sealed record ScriptFile(string Path, int Format, RunMode? Mode, ulong? Seed, IReadOnlyList<string> VarFiles,
+ IReadOnlyList<Node> Body, IReadOnlyList<ScriptError> Errors);
+
+public static class ScriptFormat
+{
+ /// <summary>Newest format version this runner knows; every older one stays supported.</summary>
+ 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<Instr> Code { get; } = [];
+ public List<ScriptError> Errors { get; } = [];
+ public Dictionary<string, (DefNode Def, string File, int Format)> Defs { get; } = new(StringComparer.Ordinal);
+ public List<string> Includes { get; } = [];
+ public List<string> Calls { get; } = [];
+ public List<SourceRef> Chain { get; } = [];
+ public HashSet<string> Checkpoints { get; } = new(StringComparer.Ordinal);
+ public List<HashSet<string>> Known { get; } = [];
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public HashSet<string> Locked { get; } = new(StringComparer.Ordinal);
+ public int TryDepth { get; set; }
+ }
+
+ public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary<string, string> 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<Node> 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<string, string>(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;
+
+/// <summary>Format v1 parser: one file → <see cref="ScriptFile"/>; every error collected (never throws).</summary>
+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<ScriptError>();
+ var lines = new List<Line>();
+ 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<string>();
+ 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<ScriptError> errors, ref RunMode? mode, ref ulong? seed, List<string> 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<Node> Block(List<Line> lines, ref int pos, int indent, string path, List<ScriptError> errors)
+ {
+ var nodes = new List<Node>();
+ 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;
+ }
+
+ /// <summary>A line ending in ':' is a block header when it has an indented body or starts with a block keyword;
+ /// otherwise a statement (<c>log Done:</c>).</summary>
+ 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<Node> body, List<ScriptError> 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<ScriptError> 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<ScriptError> 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 }
+
+/// <summary>One flattened instruction. <see cref="Chain"/> = include / call sites, outermost first.</summary>
+public sealed record Instr(Op Op, SourceRef At, string Text, IReadOnlyList<SourceRef> Chain, int Format)
+{
+ public string A { get; init; } = "";
+ public string B { get; init; } = "";
+ public int Timeout { get; init; }
+ public IReadOnlyList<KeyValuePair<string, string>> 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<string, string> Vars,
+ IReadOnlySet<string> Locked, IReadOnlyList<Instr> Code, IReadOnlyList<ScriptError> Errors);
+
+public interface IScriptSource
+{
+ string? Read(string path);
+}
+
+public sealed class MemoryScriptSource(IReadOnlyDictionary<string, string> files) : IScriptSource
+{
+ public string? Read(string path) => files.GetValueOrDefault(path);
+}
+
+/// <summary>Files under <paramref name="root"/>; paths are '/'-separated and relative to it.</summary>
+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<string>(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;
+
+/// <summary>Run-script CLI options (<c>--script</c>, <c>--var k=v</c>, <c>--output</c>, <c>--no-cache</c>, <c>--no-snapshots</c>, <c>--from</c>,
+/// <c>--until</c>, <c>--nondeterministic</c>, <c>--step-cap</c>, <c>--report-dir</c>); determinism and seed come from
+/// <see cref="RunFlags"/>.</summary>
+public sealed class ScriptRunFlags
+{
+ public string? Script { get; set; }
+ public Dictionary<string, string> 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;
+ /// <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;
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
new file mode 100644
index 0000000..909c26f
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptTypes.cs
@@ -0,0 +1,46 @@
+// Provenance: own design — shared run scripts (design notes kept privately by the author)
+using System.Globalization;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public enum RunMode { Player, Debug }
+
+/// <summary>Player = only injects user input (may read state to aim); Debug = calls the game directly.</summary>
+public enum VerbKind { Player, Debug }
+
+public enum OutputLevel { Quiet, Checkpoints, Labels, Steps, Trace }
+
+public enum StepState { Running, Done, Fault }
+
+public sealed record SourceRef(string File, int Line)
+{
+ public override string ToString() => $"{File}:{Line}";
+}
+
+public sealed record ScriptError(SourceRef At, string Message)
+{
+ public override string ToString() => $"{At}: {Message}";
+}
+
+/// <param name="Plan">Running with more input (multi-click verbs): the runner plays it from the next frame and polls
+/// again once it is sent.</param>
+public readonly record struct StepStatus(StepState State, string? Message = null, InputPlan? Plan = null)
+{
+ public static StepStatus Running => new(StepState.Running);
+ public static StepStatus Input(InputPlan plan) => new(StepState.Running, null, plan);
+ public static StepStatus Done => new(StepState.Done);
+ public static StepStatus Fault(string message) => new(StepState.Fault, message);
+}
+
+/// <summary>A step failure inside the runner (turned into a failed run, never escapes <c>ScriptRunner</c>).</summary>
+public sealed class ScriptFault(string message) : Exception(message);
+
+/// <summary>A probe / condition value: a number or a string.</summary>
+public readonly record struct RunValue(long? Number, string? Text)
+{
+ public static RunValue Of(long n) => new(n, null);
+ public static RunValue Of(string s) => new(null, s);
+ public static RunValue Of(bool b) => new(b ? 1 : 0, null);
+ public bool IsNumber => Number.HasValue;
+ public override string ToString() => Number?.ToString(CultureInfo.InvariantCulture) ?? Text ?? "";
+}
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
new file mode 100644
index 0000000..e67c48a
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptValidator.cs
@@ -0,0 +1,109 @@
+// Provenance: own design — shared run scripts (spec §3.7)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptValidator
+{
+ public static List<ScriptError> Validate(ScriptProgram p, IRunGame game, bool deterministic)
+ {
+ var errors = new List<ScriptError>(p.Errors);
+ foreach (var (name, action) in game.Actions)
+ {
+ if (LibVerbs.Find(name) is { } lib && lib.Kind != action.Kind)
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb with another kind"));
+ else if (LibVerbs.Find(name) is { GameHook: false })
+ errors.Add(new ScriptError(new SourceRef(p.Entry, 1), $"game verb {name} shadows a lib verb"));
+ }
+ var inSetup = false;
+ foreach (var i in p.Code)
+ {
+ switch (i.Op)
+ {
+ case Op.SetupBegin:
+ inSetup = true;
+ break;
+ case Op.SetupEnd:
+ inSetup = false;
+ break;
+ case Op.TryBegin when deterministic:
+ errors.Add(new ScriptError(i.At, "try needs a non-deterministic run"));
+ break;
+ case Op.Verb:
+ Verb(errors, i, game, p.Mode, inSetup);
+ break;
+ case Op.Wait or Op.Expect:
+ Condition(errors, i, game);
+ break;
+ }
+ }
+ return errors;
+ }
+
+ private static void Verb(List<ScriptError> errors, Instr i, IRunGame game, RunMode mode, bool inSetup)
+ {
+ var lib = LibVerbs.Find(i.A);
+ var gameVerb = lib == null ? game.Actions.GetValueOrDefault(i.A) : null;
+ if (lib == null && gameVerb == null)
+ {
+ errors.Add(new ScriptError(i.At, $"unknown verb {i.A}"));
+ return;
+ }
+ if ((lib is { GameHook: true } && !game.Actions.ContainsKey(i.A)) || (lib?.Name == "rng" && game.Rng == null))
+ {
+ errors.Add(new ScriptError(i.At, $"verb {i.A} not supported by game {game.Name}"));
+ return;
+ }
+ var (kind, min, max) = lib != null ? (lib.Kind, lib.MinArgs, lib.MaxArgs) : (gameVerb!.Kind, gameVerb.MinArgs, gameVerb.MaxArgs);
+ List<Token> tokens;
+ try
+ {
+ tokens = StepLexer.Tokens(i.B);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ if (tokens.Count < min || tokens.Count > max)
+ errors.Add(new ScriptError(i.At, $"verb {i.A} takes {min}..{max} arguments, got {tokens.Count}"));
+ if (kind == VerbKind.Debug && mode == RunMode.Player && !inSetup)
+ errors.Add(new ScriptError(i.At, $"debug verb {i.A} outside setup in player mode"));
+ if (i.A == "output" && tokens.Count == 1 && !LibVerbs.OutputLevels.Contains(tokens[0].Text))
+ errors.Add(new ScriptError(i.At, "output level must be quiet|checkpoints|labels|steps|trace"));
+ foreach (var t in tokens.Where(t => !t.Quoted && t.Text.StartsWith('@')))
+ {
+ var name = t.Text[1..(t.Text.IndexOf('(') is var p and > 0 ? p : t.Text.Length)];
+ if (!game.Locators.ContainsKey(name))
+ errors.Add(new ScriptError(i.At, $"unknown locator @{name}"));
+ }
+ }
+
+ private static void Condition(List<ScriptError> errors, Instr i, IRunGame game)
+ {
+ if (i.A.Contains("${", StringComparison.Ordinal))
+ return; // expanded at run time; checked then
+ Cond cond;
+ try
+ {
+ cond = ConditionParser.Parse(i.A);
+ }
+ catch (FormatException e)
+ {
+ errors.Add(new ScriptError(i.At, e.Message));
+ return;
+ }
+ foreach (var bare in cond.Bare())
+ {
+ if (!game.Probes.ContainsKey(bare) && !game.Constants.ContainsKey(bare))
+ errors.Add(new ScriptError(i.At, $"unknown probe or constant {bare}"));
+ }
+ foreach (var (name, args) in cond.Probes())
+ {
+ if (cond.Bare().Contains(name) && args.Count == 0)
+ continue;
+ if (!game.Probes.TryGetValue(name, out var probe))
+ errors.Add(new ScriptError(i.At, $"unknown probe {name}"));
+ else if (args.Count < probe.MinArgs || args.Count > probe.MaxArgs)
+ errors.Add(new ScriptError(i.At, $"probe {name} takes {probe.MinArgs}..{probe.MaxArgs} arguments, got {args.Count}"));
+ }
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/StepLexer.cs b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
new file mode 100644
index 0000000..4c54544
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/StepLexer.cs
@@ -0,0 +1,104 @@
+// Provenance: own design — shared run scripts
+using System.Globalization;
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public sealed record Token(string Text, bool Quoted);
+
+/// <summary>Line lexing for <c>.steps</c>: <c>#</c> comments outside quotes; tokens split on spaces, <c>"…"</c> (escapes
+/// <c>\"</c>, <c>\\</c>) and <c>(…)</c> groups stay one token.</summary>
+public static class StepLexer
+{
+ public static string StripComment(string line)
+ {
+ var quoted = false;
+ for (var i = 0; i < line.Length; i++)
+ {
+ var c = line[i];
+ if (quoted && c == '\\' && i + 1 < line.Length)
+ {
+ i++;
+ continue;
+ }
+ if (c == '"')
+ quoted = !quoted;
+ else if (c == '#' && !quoted)
+ return line[..i];
+ }
+ return line;
+ }
+
+ public static List<Token> Tokens(string text)
+ {
+ var tokens = new List<Token>();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == ' ')
+ {
+ i++;
+ continue;
+ }
+ if (text[i] == '"')
+ {
+ var sb = new StringBuilder();
+ i++;
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new FormatException("unterminated quote");
+ var c = text[i];
+ if (c == '\\' && i + 1 < text.Length)
+ {
+ sb.Append(text[i + 1]);
+ i += 2;
+ continue;
+ }
+ if (c == '"')
+ {
+ i++;
+ break;
+ }
+ sb.Append(c);
+ i++;
+ }
+ tokens.Add(new Token(sb.ToString(), true));
+ continue;
+ }
+ var start = i;
+ var depth = 0;
+ while (i < text.Length && (depth > 0 || text[i] != ' '))
+ {
+ if (text[i] == '(')
+ depth++;
+ else if (text[i] == ')' && depth > 0)
+ depth--;
+ i++;
+ }
+ tokens.Add(new Token(text[start..i], false));
+ }
+ return tokens;
+ }
+}
+
+/// <summary>Timeouts and waits in frames (1 frame = 1/60 s): <c>120</c> = 120 frames, <c>2s</c> = 120, <c>0.5s</c> = 30;
+/// negative, non-whole frame counts and other units → null.</summary>
+public static class Durations
+{
+ public const int FramesPerSecond = 60;
+
+ public static int? Frames(string text)
+ {
+ if (text.EndsWith('s'))
+ {
+ var number = text[..^1];
+ if (number.Length == 0 || !number.All(c => char.IsAsciiDigit(c) || c == '.')
+ || !decimal.TryParse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var seconds))
+ return null;
+ var frames = seconds * FramesPerSecond;
+ return frames == decimal.Truncate(frames) ? (int)frames : null;
+ }
+ return text.Length > 0 && text.All(char.IsAsciiDigit) && int.TryParse(text, CultureInfo.InvariantCulture, out var n) ? n : null;
+ }
+}
diff --git a/src/Godosa.Core/Runs/Scripts/Vars.cs b/src/Godosa.Core/Runs/Scripts/Vars.cs
new file mode 100644
index 0000000..a01da62
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/Vars.cs
@@ -0,0 +1,174 @@
+// Provenance: own design — shared run scripts (spec §3.3)
+using System.Text;
+
+namespace Godosa.Core.Runs.Scripts;
+
+public static class VarsFile
+{
+ public static (Dictionary<string, string> Values, List<ScriptError> Errors) Parse(string path, string text)
+ {
+ var values = new Dictionary<string, string>(StringComparer.Ordinal);
+ var errors = new List<ScriptError>();
+ var lines = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = StepLexer.StripComment(lines[i]).Trim();
+ if (line.Length == 0)
+ continue;
+ var eq = line.IndexOf('=');
+ var name = eq > 0 ? line[..eq].Trim() : "";
+ if (name.Length == 0 || name.Contains(' '))
+ {
+ errors.Add(new ScriptError(new SourceRef(path, i + 1), "bad vars line (name = value)"));
+ continue;
+ }
+ values[name] = line[(eq + 1)..].Trim();
+ }
+ return (values, errors);
+ }
+}
+
+/// <summary><c>${name}</c> expansion, innermost first (<c>${ui.rest.${hours}}</c>); <c>${probe:name(args)}</c> = a probe
+/// value; a <c>$</c> not followed by <c>{</c> is literal.</summary>
+public static class VarExpander
+{
+ public static string Expand(string text, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var sb = new StringBuilder();
+ var i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ sb.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ sb.Append(text[i++]);
+ }
+ return sb.ToString();
+ }
+
+ /// <summary>Resolves the reference opened before <paramref name="i"/> (nested references in its name first); the
+ /// value is used as is, never scanned again.</summary>
+ private static string Reference(string text, ref int i, Func<string, string?> lookup, Func<string, RunValue>? probe)
+ {
+ var name = new StringBuilder();
+ while (true)
+ {
+ if (i >= text.Length)
+ throw new ScriptFault($"unclosed ${{ in '{text}'");
+ if (text[i] == '}')
+ {
+ i++;
+ break;
+ }
+ if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{')
+ {
+ i += 2;
+ name.Append(Reference(text, ref i, lookup, probe));
+ continue;
+ }
+ name.Append(text[i++]);
+ }
+ var n = name.ToString();
+ if (n.Length == 0)
+ throw new ScriptFault("empty ${}");
+ if (n.StartsWith("probe:", StringComparison.Ordinal))
+ return (probe ?? throw new ScriptFault($"no probes here: ${{{n}}}"))(n["probe:".Length..]).ToString();
+ return lookup(n) ?? throw new ScriptFault($"unresolved variable ${{{n}}}");
+ }
+
+ /// <summary>Plain names inside the innermost <c>${…}</c> (no nested <c>${</c>, no <c>probe:</c>).</summary>
+ public static IEnumerable<string> Names(string text)
+ {
+ for (var i = 0; i < text.Length - 1; i++)
+ {
+ if (text[i] != '$' || text[i + 1] != '{')
+ continue;
+ var close = text.IndexOf('}', i + 2);
+ if (close < 0)
+ yield break;
+ var inner = text[(i + 2)..close];
+ if (inner.Length > 0 && !inner.Contains("${", StringComparison.Ordinal) && !inner.StartsWith("probe:", StringComparison.Ordinal))
+ yield return inner;
+ }
+ }
+}
+
+public sealed record ScopeState(Dictionary<string, string> Vars, bool Local, OutputLevel Level);
+
+/// <summary>Variable scopes: root (globals), include scopes (vars go to root, output level scoped), def-call scopes
+/// (local vars). Lookup walks from the top down.</summary>
+public sealed class VarScopes
+{
+ private readonly List<ScopeState> _stack;
+
+ public VarScopes(OutputLevel level) => _stack = [new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, level)];
+
+ public int Depth => _stack.Count;
+
+ public OutputLevel Level
+ {
+ get => _stack[^1].Level;
+ set => _stack[^1] = _stack[^1] with { Level = value };
+ }
+
+ public void Push(bool local, OutputLevel level, IEnumerable<KeyValuePair<string, string>> bindings) =>
+ _stack.Add(new ScopeState(new Dictionary<string, string>(bindings, StringComparer.Ordinal), local, level));
+
+ public void Pop()
+ {
+ if (_stack.Count > 1)
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public void PopTo(int depth)
+ {
+ while (_stack.Count > Math.Max(1, depth))
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+
+ public string? Get(string name)
+ {
+ for (var i = _stack.Count - 1; i >= 0; i--)
+ {
+ if (_stack[i].Vars.TryGetValue(name, out var v))
+ return v;
+ }
+ return null;
+ }
+
+ public void Set(string name, string value)
+ {
+ for (var i = _stack.Count - 1; i >= 1; i--)
+ {
+ if (_stack[i].Local)
+ {
+ _stack[i].Vars[name] = value;
+ return;
+ }
+ }
+ _stack[0].Vars[name] = value;
+ }
+
+ public Dictionary<string, string> Visible()
+ {
+ var all = new Dictionary<string, string>(StringComparer.Ordinal);
+ foreach (var scope in _stack)
+ foreach (var (k, v) in scope.Vars)
+ all[k] = v;
+ return all;
+ }
+
+ public List<ScopeState> Snapshot() =>
+ [.. _stack.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) })];
+
+ public void Restore(IReadOnlyList<ScopeState> scopes)
+ {
+ _stack.Clear();
+ _stack.AddRange(scopes.Select(s => s with { Vars = new Dictionary<string, string>(s.Vars, StringComparer.Ordinal) }));
+ if (_stack.Count == 0)
+ _stack.Add(new ScopeState(new Dictionary<string, string>(StringComparer.Ordinal), false, OutputLevel.Labels));
+ }
+}