aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Runs/Scripts/CheckpointCache.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/CheckpointCache.cs')
-rw-r--r--src/Godosa.Core/Runs/Scripts/CheckpointCache.cs112
1 files changed, 112 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();
+ }
+ }
+}