diff --git a/src/Blocktavius.sln b/src/Blocktavius.sln
index f24dc0b..c60b994 100644
--- a/src/Blocktavius.sln
+++ b/src/Blocktavius.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.14.36408.4
+# Visual Studio Version 18
+VisualStudioVersion = 18.2.11415.280
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blocktavius.Core", "Blocktavius.Core\Blocktavius.Core.csproj", "{F74E3B1F-4A90-7129-974A-17B7AF2F75DB}"
EndProject
@@ -18,6 +18,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Antipasta", "Antipasta\Antipasta.csproj", "{098197E3-A33F-4D06-B6ED-FA61032DE307}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibDQB", "LibDQB\LibDQB.csproj", "{AF2E2CB4-6B78-4790-BE62-1922CAA1416C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibDQBTests", "LibDQBTests\LibDQBTests.csproj", "{D47C7D9D-16AB-4768-BBB1-004AB209AB5B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -44,6 +48,14 @@ Global
{098197E3-A33F-4D06-B6ED-FA61032DE307}.Debug|Any CPU.Build.0 = Debug|Any CPU
{098197E3-A33F-4D06-B6ED-FA61032DE307}.Release|Any CPU.ActiveCfg = Release|Any CPU
{098197E3-A33F-4D06-B6ED-FA61032DE307}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AF2E2CB4-6B78-4790-BE62-1922CAA1416C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AF2E2CB4-6B78-4790-BE62-1922CAA1416C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AF2E2CB4-6B78-4790-BE62-1922CAA1416C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AF2E2CB4-6B78-4790-BE62-1922CAA1416C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D47C7D9D-16AB-4768-BBB1-004AB209AB5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D47C7D9D-16AB-4768-BBB1-004AB209AB5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D47C7D9D-16AB-4768-BBB1-004AB209AB5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D47C7D9D-16AB-4768-BBB1-004AB209AB5B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/LibDQB/B2/CmndatReadOptions.cs b/src/LibDQB/B2/CmndatReadOptions.cs
new file mode 100644
index 0000000..aed12d1
--- /dev/null
+++ b/src/LibDQB/B2/CmndatReadOptions.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibDQB.B2;
+
+public sealed record CmndatReadOptions
+{
+ public FileShare FileShare { get; init; } = FileShare.None;
+}
diff --git a/src/LibDQB/B2/IslandId.cs b/src/LibDQB/B2/IslandId.cs
new file mode 100644
index 0000000..d831b98
--- /dev/null
+++ b/src/LibDQB/B2/IslandId.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibDQB.B2;
+
+public readonly record struct IslandId
+{
+ public byte Value { get; }
+
+ public IslandId(byte value)
+ {
+ this.Value = value;
+ }
+
+ // TODO - add constants for known values
+}
diff --git a/src/LibDQB/B2/RawCmndat.cs b/src/LibDQB/B2/RawCmndat.cs
new file mode 100644
index 0000000..1b32ca7
--- /dev/null
+++ b/src/LibDQB/B2/RawCmndat.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibDQB.B2;
+
+///
+/// Provides direct, low-level access to a CMNDAT file.
+///
+public sealed class RawCmndat
+{
+ const int headerLength = 0x2A444;
+ const int decompressedBodyLength = 5627194; // The decompressed body will always have this length
+
+ private readonly Memory _header;
+ private readonly Memory _body;
+ private Span Header => _header.Span;
+ private Span Body => _body.Span;
+
+ private RawCmndat(Memory header, Memory body)
+ {
+ this._header = header;
+ this._body = body;
+ }
+
+ ///
+ /// See
+ ///
+ public SaveFileKey SaveFileKey
+ {
+ get => new(BinaryPrimitives.ReadUInt32LittleEndian(Header.Slice(0x80)));
+ set => BinaryPrimitives.WriteUInt32LittleEndian(Header.Slice(0x80), value.Value);
+ }
+
+ ///
+ /// When sailing, indicates the arrival island.
+ /// When not sailing, indicates the island the builder is on.
+ ///
+ public IslandId ToIslandId
+ {
+ get => new IslandId(Header[0xC8]);
+ set => Header[0xC8] = value.Value;
+ }
+
+ ///
+ /// When sailing, indicates the departure island.
+ /// When not sailing, indicates the island the builder is on.
+ ///
+ public IslandId FromIslandId
+ {
+ get => new IslandId(Header[0xC9]);
+ set => Header[0xC9] = value.Value;
+ }
+
+ ///
+ /// The timestamp shown by the game when you load the file.
+ ///
+ ///
+ /// The save file wants UTC.
+ /// The game adjusts to the user's time zone when displaying the value.
+ ///
+ public DateTime LastSaveTime
+ {
+ // Signed because that's what DateTime likes to use.
+ // Unconfirmed if the game cares, but it won't matter for any reasonable value.
+ get => DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(Header.Slice(0x2A40D)));
+ set => BinaryPrimitives.WriteInt64LittleEndian(Header.Slice(0x2A40D), value.ToFileTimeUtc());
+ }
+
+ public static Task LoadAsync(FileInfo file) => LoadAsync(file, new CmndatReadOptions());
+
+ public static async Task LoadAsync(FileInfo file, CmndatReadOptions options)
+ {
+ using var readStream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, options.FileShare);
+ return await LoadAsync(readStream, options);
+ }
+
+ public static async Task LoadAsync(Stream readStream, CmndatReadOptions options)
+ {
+ var header = new byte[headerLength];
+ await readStream.ReadExactlyAsync(header, 0, headerLength);
+
+ if (!IsHeaderValid(header, 0x61, 0x65, 0x72, 0x43))
+ {
+ throw new ArgumentException($"Not a valid CMNDAT file (magic number check failed)");
+ }
+
+ var body = await DecompressAndValidateLength(readStream);
+ return new RawCmndat(header, body);
+ }
+
+ private static bool IsHeaderValid(ReadOnlySpan header, params byte[] check)
+ {
+ for (int i = 0; i < check.Length; i++)
+ {
+ if (header[i] != check[i])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static async Task DecompressAndValidateLength(Stream readStream)
+ {
+ using var zlib = new ZLibStream(readStream, CompressionMode.Decompress, leaveOpen: true);
+ var body = new byte[decompressedBodyLength];
+ using var bodyStream = new MemoryStream(body);
+ await zlib.CopyToAsync(bodyStream);
+
+ if (bodyStream.Position != decompressedBodyLength)
+ {
+ throw new ArgumentException("Not a valid CMNDAT file (decompressed length check failed)");
+ }
+
+ return body;
+ }
+}
diff --git a/src/LibDQB/B2/SaveFileKey.cs b/src/LibDQB/B2/SaveFileKey.cs
new file mode 100644
index 0000000..6b74c32
--- /dev/null
+++ b/src/LibDQB/B2/SaveFileKey.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibDQB.B2;
+
+///
+/// Each STGDAT file has a key that must match the key in its CMNDAT file.
+/// If the keys don't match the game will not load it.
+///
+///
+/// The game generates a new, apparently-random key every time you save.
+///
+public readonly record struct SaveFileKey
+{
+ public uint Value { get; }
+
+ public SaveFileKey(uint value)
+ {
+ this.Value = value;
+ }
+}
diff --git a/src/LibDQB/LibDQB.csproj b/src/LibDQB/LibDQB.csproj
new file mode 100644
index 0000000..87889db
--- /dev/null
+++ b/src/LibDQB/LibDQB.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net9.0;net10.0
+ enable
+ enable
+ true
+
+
+
diff --git a/src/LibDQBTests/LibDQBTests.csproj b/src/LibDQBTests/LibDQBTests.csproj
new file mode 100644
index 0000000..5d1b3b1
--- /dev/null
+++ b/src/LibDQBTests/LibDQBTests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LibDQBTests/MSTestSettings.cs b/src/LibDQBTests/MSTestSettings.cs
new file mode 100644
index 0000000..aaf278c
--- /dev/null
+++ b/src/LibDQBTests/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/src/LibDQBTests/RawCmndatTests.cs b/src/LibDQBTests/RawCmndatTests.cs
new file mode 100644
index 0000000..08f9faf
--- /dev/null
+++ b/src/LibDQBTests/RawCmndatTests.cs
@@ -0,0 +1,59 @@
+using LibDQB.B2;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibDQBTests;
+
+[TestClass]
+public class RawCmndatTests
+{
+ private static DirectoryInfo FindTestdataDir()
+ {
+ const string projectRoot = "LibDQBTests";
+ const string subdir = "testdata";
+
+ var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ while (dir.Name != projectRoot && dir.Parent != null)
+ {
+ dir = dir.Parent;
+ }
+ if (dir.Name != projectRoot)
+ {
+ throw new Exception($"Could not find: {projectRoot}");
+ }
+
+ return dir.GetDirectories(subdir)
+ .Where(d => subdir.Equals(d.Name, StringComparison.OrdinalIgnoreCase))
+ .FirstOrDefault()
+ ?? throw new Exception($"Could not find: {subdir}");
+ }
+
+ private static FileInfo FindTestFile(params string[] path)
+ {
+ var testdata = FindTestdataDir();
+ return new FileInfo(Path.Combine([testdata.FullName, .. path]));
+ }
+
+ [TestMethod]
+ public async Task TestCmndat01()
+ {
+ var file = FindTestFile("game-saves", "01", "CMNDAT.BIN");
+ var cmndat = await RawCmndat.LoadAsync(file);
+
+ // Buildertopia Gamma (16), not sailing
+ Assert.AreEqual(16, cmndat.ToIslandId.Value);
+ Assert.AreEqual(16, cmndat.FromIslandId.Value);
+
+ Assert.AreEqual((uint)0x75182684, cmndat.SaveFileKey.Value);
+
+ // Game showed 2026-02-02 20:54 with my local timzone set to UTC-6 (Chicago)
+ var lastSaveTime = cmndat.LastSaveTime;
+ var expectedTime = new DateTime(2026, 2, 2, 20, 54, 0, DateTimeKind.Utc).AddHours(6);
+ Assert.AreEqual(expectedTime.Date, lastSaveTime.Date);
+ Assert.AreEqual(expectedTime.Hour, lastSaveTime.Hour);
+ Assert.AreEqual(expectedTime.Minute, lastSaveTime.Minute);
+ }
+}
diff --git a/src/LibDQBTests/testdata/game-saves/01/CMNDAT.BIN b/src/LibDQBTests/testdata/game-saves/01/CMNDAT.BIN
new file mode 100644
index 0000000..2ee17ed
Binary files /dev/null and b/src/LibDQBTests/testdata/game-saves/01/CMNDAT.BIN differ
diff --git a/src/LibDQBTests/testdata/game-saves/01/STGDAT16.BIN b/src/LibDQBTests/testdata/game-saves/01/STGDAT16.BIN
new file mode 100644
index 0000000..a5f49d8
Binary files /dev/null and b/src/LibDQBTests/testdata/game-saves/01/STGDAT16.BIN differ
diff --git a/src/LibDQBTests/testdata/game-saves/01/notes.txt b/src/LibDQBTests/testdata/game-saves/01/notes.txt
new file mode 100644
index 0000000..2378140
--- /dev/null
+++ b/src/LibDQBTests/testdata/game-saves/01/notes.txt
@@ -0,0 +1 @@
+Nothing special. Just the save I had readily available.
\ No newline at end of file