From 13852d7b6992ac649a4886bd78042b8289621a6d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 17:48:29 +0200 Subject: [PATCH] feat(replay): add input movie recording format --- src/Chip8.Replay/InputMovie.cs | 332 +++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 src/Chip8.Replay/InputMovie.cs diff --git a/src/Chip8.Replay/InputMovie.cs b/src/Chip8.Replay/InputMovie.cs new file mode 100644 index 0000000..f6c9809 --- /dev/null +++ b/src/Chip8.Replay/InputMovie.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Chip8.Core; + +namespace Chip8.Replay; + +public sealed class InputMovie +{ + public const string textMagic = "CHIP8MOVIE"; + public const int textVersion = 1; + static readonly byte[] binaryMagic = Encoding.ASCII.GetBytes("C8MV"); + const byte binaryVersion = 1; + + public Platform platform { get; set; } = Platform.Chip8; + public Quirks quirks { get; set; } = Quirks.modern(); + public int? randomSeed { get; set; } + public int instructionsPerFrame { get; set; } = 11; + public int length { get; private set; } + public ulong romHash { get; set; } + public string romName { get; set; } = "-"; + + readonly SortedDictionary frames = new SortedDictionary(); + + public int entryCount => frames.Count; + + public IReadOnlyCollection recordedIndices => frames.Keys; + + public void setFrame(int index, KeyMask keys) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (keys.isEmpty) + { + frames.Remove(index); + } + else + { + frames[index] = keys; + } + + if (index + 1 > length) + { + length = index + 1; + } + } + + public void recordFrame(int index, KeyMask keys) + { + setFrame(index, keys); + if (index + 1 > length) + { + length = index + 1; + } + } + + public void extendLength(int frameCount) + { + if (frameCount > length) + { + length = frameCount; + } + } + + public KeyMask keysAt(int index) => + frames.TryGetValue(index, out KeyMask keys) ? keys : KeyMask.empty; + + public IEnumerable entries() + { + foreach (var pair in frames) + { + yield return new MovieFrame(pair.Key, pair.Value); + } + } + + public void setRom(byte[] rom, string name) + { + romHash = RomHash.compute(rom); + romName = string.IsNullOrEmpty(name) ? "-" : name; + } + + public bool matchesRom(byte[] rom) => RomHash.compute(rom) == romHash; + + public string toText() + { + var builder = new StringBuilder(); + builder.Append(textMagic).Append(' ').Append(textVersion).Append('\n'); + builder.Append("platform ").Append(platform).Append('\n'); + builder.Append("quirks ").Append(QuirksCodec.toTokens(quirks)).Append('\n'); + builder.Append("seed ").Append(randomSeed.HasValue + ? randomSeed.Value.ToString(CultureInfo.InvariantCulture) + : "none").Append('\n'); + builder.Append("ipf ").Append(instructionsPerFrame.ToString(CultureInfo.InvariantCulture)).Append('\n'); + builder.Append("length ").Append(length.ToString(CultureInfo.InvariantCulture)).Append('\n'); + builder.Append("rom ").Append(romName).Append(' ').Append(RomHash.format(romHash)).Append('\n'); + + foreach (var pair in frames) + { + builder.Append("frame ") + .Append(pair.Key.ToString(CultureInfo.InvariantCulture)) + .Append(' ') + .Append(pair.Value.toKeyList()) + .Append('\n'); + } + + return builder.ToString(); + } + + public static InputMovie parseText(string text) + { + var movie = new InputMovie(); + bool headerSeen = false; + int explicitLength = -1; + + foreach (string rawLine in text.Split('\n')) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + if (!headerSeen) + { + string[] header = line.Split(' '); + if (header.Length < 2 || header[0] != textMagic) + { + throw new FormatException("not a chip8 movie (missing '" + textMagic + "' header)"); + } + if (!int.TryParse(header[1], out int version) || version != textVersion) + { + throw new FormatException("unsupported movie version '" + header[1] + "'"); + } + headerSeen = true; + continue; + } + + int separator = line.IndexOf(' '); + string keyword = separator < 0 ? line : line.Substring(0, separator); + string value = separator < 0 ? string.Empty : line.Substring(separator + 1).Trim(); + + switch (keyword) + { + case "platform": + movie.platform = Enum.Parse(value, ignoreCase: true); + break; + case "quirks": + movie.quirks = QuirksCodec.fromTokens(value); + break; + case "seed": + movie.randomSeed = value == "none" + ? (int?)null + : int.Parse(value, CultureInfo.InvariantCulture); + break; + case "ipf": + movie.instructionsPerFrame = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "length": + explicitLength = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "rom": + parseRomLine(movie, value); + break; + case "frame": + parseFrameLine(movie, value); + break; + default: + throw new FormatException("unknown movie directive '" + keyword + "'"); + } + } + + if (!headerSeen) + { + throw new FormatException("empty movie text"); + } + + if (explicitLength > movie.length) + { + movie.length = explicitLength; + } + + return movie; + } + + static void parseRomLine(InputMovie movie, string value) + { + int split = value.LastIndexOf(' '); + if (split < 0) + { + movie.romName = value; + movie.romHash = 0; + return; + } + + movie.romName = value.Substring(0, split).Trim(); + movie.romHash = RomHash.parse(value.Substring(split + 1).Trim()); + } + + static void parseFrameLine(InputMovie movie, string value) + { + int split = value.IndexOf(' '); + string indexPart = split < 0 ? value : value.Substring(0, split); + string keyPart = split < 0 ? "-" : value.Substring(split + 1).Trim(); + int index = int.Parse(indexPart, CultureInfo.InvariantCulture); + movie.setFrame(index, KeyMask.parseKeyList(keyPart)); + } + + public byte[] toBytes() + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII); + + writer.Write(binaryMagic); + writer.Write(binaryVersion); + writer.Write((byte)platform); + writer.Write(QuirksCodec.toByte(quirks)); + writer.Write(randomSeed.HasValue); + writer.Write(randomSeed ?? 0); + writer.Write(instructionsPerFrame); + writer.Write(length); + writer.Write(romHash); + writer.Write(romName ?? "-"); + writer.Write(frames.Count); + foreach (var pair in frames) + { + writer.Write(pair.Key); + writer.Write(pair.Value.bits); + } + + writer.Flush(); + return stream.ToArray(); + } + + public static InputMovie parseBytes(byte[] data) + { + using var stream = new MemoryStream(data); + using var reader = new BinaryReader(stream, Encoding.ASCII); + + byte[] magic = reader.ReadBytes(binaryMagic.Length); + for (int index = 0; index < binaryMagic.Length; index++) + { + if (magic.Length <= index || magic[index] != binaryMagic[index]) + { + throw new InvalidDataException("not a chip8 movie (bad magic)"); + } + } + + byte version = reader.ReadByte(); + if (version != binaryVersion) + { + throw new InvalidDataException("unsupported movie binary version " + version); + } + + var movie = new InputMovie + { + platform = (Platform)reader.ReadByte(), + quirks = QuirksCodec.fromByte(reader.ReadByte()), + }; + + bool hasSeed = reader.ReadBoolean(); + int seed = reader.ReadInt32(); + movie.randomSeed = hasSeed ? seed : (int?)null; + movie.instructionsPerFrame = reader.ReadInt32(); + int storedLength = reader.ReadInt32(); + movie.romHash = reader.ReadUInt64(); + movie.romName = reader.ReadString(); + + int entries = reader.ReadInt32(); + for (int entry = 0; entry < entries; entry++) + { + int frameIndex = reader.ReadInt32(); + ushort keyBits = reader.ReadUInt16(); + movie.setFrame(frameIndex, new KeyMask(keyBits)); + } + + if (storedLength > movie.length) + { + movie.length = storedLength; + } + + return movie; + } + + public InputMovie clone() + { + var copy = new InputMovie + { + platform = platform, + quirks = QuirksCodec.clone(quirks), + randomSeed = randomSeed, + instructionsPerFrame = instructionsPerFrame, + romHash = romHash, + romName = romName, + }; + foreach (var pair in frames) + { + copy.frames[pair.Key] = pair.Value; + } + copy.length = length; + return copy; + } + + public bool sameContent(InputMovie other) + { + if (platform != other.platform + || !QuirksCodec.sameFlags(quirks, other.quirks) + || randomSeed != other.randomSeed + || instructionsPerFrame != other.instructionsPerFrame + || length != other.length + || romHash != other.romHash + || romName != other.romName + || frames.Count != other.frames.Count) + { + return false; + } + + foreach (var pair in frames) + { + if (!other.frames.TryGetValue(pair.Key, out KeyMask keys) || keys != pair.Value) + { + return false; + } + } + + return true; + } +}