Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/Blocktavius.sln
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/LibDQB/B2/CmndatReadOptions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 19 additions & 0 deletions src/LibDQB/B2/IslandId.cs
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 123 additions & 0 deletions src/LibDQB/B2/RawCmndat.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides direct, low-level access to a CMNDAT file.
/// </summary>
public sealed class RawCmndat
{
const int headerLength = 0x2A444;
const int decompressedBodyLength = 5627194; // The decompressed body will always have this length

private readonly Memory<byte> _header;
private readonly Memory<byte> _body;
private Span<byte> Header => _header.Span;
private Span<byte> Body => _body.Span;

private RawCmndat(Memory<byte> header, Memory<byte> body)
{
this._header = header;
this._body = body;
}

/// <summary>
/// See <see cref="LibDQB.B2.SaveFileKey"/>
/// </summary>
public SaveFileKey SaveFileKey
{
get => new(BinaryPrimitives.ReadUInt32LittleEndian(Header.Slice(0x80)));
set => BinaryPrimitives.WriteUInt32LittleEndian(Header.Slice(0x80), value.Value);
}

/// <summary>
/// When sailing, indicates the arrival island.
/// When not sailing, indicates the island the builder is on.
/// </summary>
public IslandId ToIslandId
{
get => new IslandId(Header[0xC8]);
set => Header[0xC8] = value.Value;
}

/// <summary>
/// When sailing, indicates the departure island.
/// When not sailing, indicates the island the builder is on.
/// </summary>
public IslandId FromIslandId
{
get => new IslandId(Header[0xC9]);
set => Header[0xC9] = value.Value;
}

/// <summary>
/// The timestamp shown by the game when you load the file.
/// </summary>
/// <remarks>
/// The save file wants UTC.
/// The game adjusts to the user's time zone when displaying the value.
/// </remarks>
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<RawCmndat> LoadAsync(FileInfo file) => LoadAsync(file, new CmndatReadOptions());

public static async Task<RawCmndat> 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<RawCmndat> 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<byte> 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<byte[]> 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;
}
}
24 changes: 24 additions & 0 deletions src/LibDQB/B2/SaveFileKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LibDQB.B2;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The game generates a new, apparently-random key every time you save.
/// </remarks>
public readonly record struct SaveFileKey
{
public uint Value { get; }

public SaveFileKey(uint value)
{
this.Value = value;
}
}
10 changes: 10 additions & 0 deletions src/LibDQB/LibDQB.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

</Project>
22 changes: 22 additions & 0 deletions src/LibDQBTests/LibDQBTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="MSTest" Version="4.0.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\LibDQB\LibDQB.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions src/LibDQBTests/MSTestSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
59 changes: 59 additions & 0 deletions src/LibDQBTests/RawCmndatTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions src/LibDQBTests/testdata/game-saves/01/notes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Nothing special. Just the save I had readily available.