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