diff --git a/src/Chip8.Core/Chip8.Core.csproj b/src/Chip8.Core/Chip8.Core.csproj
new file mode 100644
index 0000000..3483111
--- /dev/null
+++ b/src/Chip8.Core/Chip8.Core.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net6.0
+ disable
+ enable
+ Chip8.Core
+ Chip8.Core
+ 10
+
+
+
+
+
+
+
diff --git a/src/Chip8.Core/Palette.cs b/src/Chip8.Core/Palette.cs
new file mode 100644
index 0000000..de8ce5f
--- /dev/null
+++ b/src/Chip8.Core/Palette.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+
+namespace Chip8.Core;
+
+public sealed class Palette
+{
+ readonly Rgb[] colors;
+
+ public Palette(params Rgb[] colors)
+ {
+ if (colors is null || colors.Length < 2)
+ {
+ throw new ArgumentException("a palette needs at least two colours", nameof(colors));
+ }
+ this.colors = (Rgb[])colors.Clone();
+ }
+
+ public int colorCount => colors.Length;
+
+ public Rgb this[int index] => colors[index];
+
+ public Rgb resolve(int index) => colors[index < 0 ? 0 : index % colors.Length];
+
+ public IReadOnlyList toList() => (Rgb[])colors.Clone();
+
+ public static Palette monochrome() => new(
+ new Rgb(0x00, 0x00, 0x00),
+ new Rgb(0xFF, 0xFF, 0xFF));
+
+ public static Palette grayscale() => new(
+ new Rgb(0x00, 0x00, 0x00),
+ new Rgb(0x55, 0x55, 0x55),
+ new Rgb(0xAA, 0xAA, 0xAA),
+ new Rgb(0xFF, 0xFF, 0xFF));
+
+ public static Palette octo() => new(
+ Rgb.fromHex("#996600"),
+ Rgb.fromHex("#FFCC00"),
+ Rgb.fromHex("#FF6600"),
+ Rgb.fromHex("#662200"));
+
+ public static Palette extended16() => new(
+ Rgb.fromHex("#000000"),
+ Rgb.fromHex("#1D2B53"),
+ Rgb.fromHex("#7E2553"),
+ Rgb.fromHex("#008751"),
+ Rgb.fromHex("#AB5236"),
+ Rgb.fromHex("#5F574F"),
+ Rgb.fromHex("#C2C3C7"),
+ Rgb.fromHex("#FFF1E8"),
+ Rgb.fromHex("#FF004D"),
+ Rgb.fromHex("#FFA300"),
+ Rgb.fromHex("#FFEC27"),
+ Rgb.fromHex("#00E436"),
+ Rgb.fromHex("#29ADFF"),
+ Rgb.fromHex("#83769C"),
+ Rgb.fromHex("#FF77A8"),
+ Rgb.fromHex("#FFCCAA"));
+
+ public static Palette byName(string name) => name.ToLowerInvariant() switch
+ {
+ "mono" or "monochrome" => monochrome(),
+ "gray" or "grey" or "grayscale" => grayscale(),
+ "octo" => octo(),
+ "extended" or "extended16" or "pico8" => extended16(),
+ _ => throw new ArgumentException("unknown palette '" + name + "'", nameof(name)),
+ };
+}
diff --git a/src/Chip8.Core/Platform.cs b/src/Chip8.Core/Platform.cs
new file mode 100644
index 0000000..ca88efc
--- /dev/null
+++ b/src/Chip8.Core/Platform.cs
@@ -0,0 +1,8 @@
+namespace Chip8.Core;
+
+public enum Platform
+{
+ Chip8,
+ SuperChip,
+ XoChip,
+}
diff --git a/src/Chip8.Core/Rgb.cs b/src/Chip8.Core/Rgb.cs
new file mode 100644
index 0000000..7737e66
--- /dev/null
+++ b/src/Chip8.Core/Rgb.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Globalization;
+
+namespace Chip8.Core;
+
+public readonly struct Rgb : IEquatable
+{
+ public Rgb(byte r, byte g, byte b)
+ {
+ this.r = r;
+ this.g = g;
+ this.b = b;
+ }
+
+ public byte r { get; }
+ public byte g { get; }
+ public byte b { get; }
+
+ public static Rgb fromHex(string hex)
+ {
+ if (hex is null)
+ {
+ throw new ArgumentNullException(nameof(hex));
+ }
+
+ string digits = hex.StartsWith("#", StringComparison.Ordinal) ? hex.Substring(1) : hex;
+ if (digits.Length != 6)
+ {
+ throw new FormatException("expected a 6 digit hex colour like #RRGGBB, got '" + hex + "'");
+ }
+
+ byte channel(int start) => byte.Parse(digits.Substring(start, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ return new Rgb(channel(0), channel(2), channel(4));
+ }
+
+ public string toHex() =>
+ "#" + r.ToString("X2", CultureInfo.InvariantCulture)
+ + g.ToString("X2", CultureInfo.InvariantCulture)
+ + b.ToString("X2", CultureInfo.InvariantCulture);
+
+ public bool Equals(Rgb other) => r == other.r && g == other.g && b == other.b;
+
+ public override bool Equals(object? obj) => obj is Rgb other && Equals(other);
+
+ public override int GetHashCode() => (r << 16) | (g << 8) | b;
+
+ public override string ToString() => toHex();
+}