diff --git a/src/Chip8.Replay/Chip8.Replay.csproj b/src/Chip8.Replay/Chip8.Replay.csproj
new file mode 100644
index 0000000..c64a386
--- /dev/null
+++ b/src/Chip8.Replay/Chip8.Replay.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net6.0
+ disable
+ enable
+ Chip8.Replay
+ Chip8.Replay
+ 10
+
+
+
+
+
+
+
diff --git a/src/Chip8.Replay/MachineBuilder.cs b/src/Chip8.Replay/MachineBuilder.cs
new file mode 100644
index 0000000..611abd8
--- /dev/null
+++ b/src/Chip8.Replay/MachineBuilder.cs
@@ -0,0 +1,31 @@
+using Chip8.Core;
+
+namespace Chip8.Replay;
+
+public static class MachineBuilder
+{
+ public static Machine fromMovie(InputMovie movie)
+ {
+ return new Machine(QuirksCodec.clone(movie.quirks), movie.randomSeed, movie.platform);
+ }
+
+ public static Machine fromMovie(InputMovie movie, byte[] rom)
+ {
+ var machine = fromMovie(movie);
+ machine.loadRom(rom);
+ return machine;
+ }
+
+ public static InputMovie headerFor(Machine machine, byte[] rom, string romName, int instructionsPerFrame, int? randomSeed)
+ {
+ var movie = new InputMovie
+ {
+ platform = machine.platform,
+ quirks = QuirksCodec.clone(machine.quirks),
+ randomSeed = randomSeed,
+ instructionsPerFrame = instructionsPerFrame,
+ };
+ movie.setRom(rom, romName);
+ return movie;
+ }
+}
diff --git a/src/Chip8.Replay/MovieFrame.cs b/src/Chip8.Replay/MovieFrame.cs
new file mode 100644
index 0000000..1d3afe9
--- /dev/null
+++ b/src/Chip8.Replay/MovieFrame.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace Chip8.Replay;
+
+public readonly struct MovieFrame : IEquatable
+{
+ public int index { get; }
+ public KeyMask keys { get; }
+
+ public MovieFrame(int index, KeyMask keys)
+ {
+ if (index < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(index), "movie frame index cannot be negative");
+ }
+ this.index = index;
+ this.keys = keys;
+ }
+
+ public MovieFrame(int index, ushort keyBits)
+ : this(index, new KeyMask(keyBits))
+ {
+ }
+
+ public bool Equals(MovieFrame other) => index == other.index && keys == other.keys;
+
+ public override bool Equals(object? obj) => obj is MovieFrame other && Equals(other);
+
+ public override int GetHashCode() => (index * 397) ^ keys.bits;
+
+ public override string ToString() => "frame " + index + " " + keys.toKeyList();
+}
diff --git a/src/Chip8.Replay/RomHash.cs b/src/Chip8.Replay/RomHash.cs
new file mode 100644
index 0000000..56e8d6d
--- /dev/null
+++ b/src/Chip8.Replay/RomHash.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Globalization;
+
+namespace Chip8.Replay;
+
+public static class RomHash
+{
+ const ulong fnvOffsetBasis = 14695981039346656037UL;
+ const ulong fnvPrime = 1099511628211UL;
+
+ public static ulong compute(byte[] rom)
+ {
+ ulong hash = fnvOffsetBasis;
+ foreach (byte value in rom)
+ {
+ hash ^= value;
+ hash *= fnvPrime;
+ }
+ return hash;
+ }
+
+ public static string toHex(byte[] rom) => format(compute(rom));
+
+ public static string format(ulong hash) => hash.ToString("X16", CultureInfo.InvariantCulture);
+
+ public static ulong parse(string hex) =>
+ ulong.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+}
diff --git a/src/Chip8.Replay/TimelineSnapshot.cs b/src/Chip8.Replay/TimelineSnapshot.cs
new file mode 100644
index 0000000..2857f17
--- /dev/null
+++ b/src/Chip8.Replay/TimelineSnapshot.cs
@@ -0,0 +1,17 @@
+namespace Chip8.Replay;
+
+public sealed class TimelineSnapshot
+{
+ public int frameIndex { get; }
+ public byte[] state { get; }
+
+ public TimelineSnapshot(int frameIndex, byte[] state)
+ {
+ this.frameIndex = frameIndex;
+ this.state = state;
+ }
+
+ public int stateSize => state.Length;
+
+ public override string ToString() => "snapshot frame=" + frameIndex + " bytes=" + state.Length;
+}