diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9657f83..4735cd4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,7 @@ jobs: dotnet-version: | 2.2.x 6.0.x + 8.0.x - name: Restore dependencies run: dotnet restore ./GeometryDashAPI.sln - name: Build @@ -29,7 +30,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Set Version run: echo ${{ github.ref_name }} | sed -r "s/^v/GDAPI_VERSION=/" >> $GITHUB_ENV - name: Restore dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09ccb66..3969fd2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,7 @@ jobs: dotnet-version: | 2.2.x 6.0.x + 8.0.x - name: Restore dependencies run: dotnet restore ./GeometryDashAPI.sln - name: Build diff --git a/GeometryDashAPI.sln.DotSettings b/GeometryDashAPI.sln.DotSettings index d019b29..af5c97d 100644 --- a/GeometryDashAPI.sln.DotSettings +++ b/GeometryDashAPI.sln.DotSettings @@ -1,2 +1,3 @@  - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/GeometryDashAPI/Crypt.cs b/GeometryDashAPI/Crypt.cs index ef3781b..66731bd 100644 --- a/GeometryDashAPI/Crypt.cs +++ b/GeometryDashAPI/Crypt.cs @@ -1,5 +1,6 @@ using System.IO; using System.IO.Compression; +using System.Security.Cryptography; using System.Text; using ICSharpCode.SharpZipLib.Zip.Compression.Streams; @@ -7,6 +8,16 @@ namespace GeometryDashAPI { public class Crypt { + // The MacOS save file is not encoded like the Windows one - instead, it uses AES ECB encryption. + // Huge thanks to: https://github.com/qimiko/gd-save-tools/blob/b5176eb2c805ca65da3e51701409b72b90bdd497/assets/js/savefile.mjs#L43 + private static byte[] MAC_SAVE_KEY = + [ + 0x69, 0x70, 0x75, 0x39, 0x54, 0x55, 0x76, 0x35, + 0x34, 0x79, 0x76, 0x5D, 0x69, 0x73, 0x46, 0x4D, + 0x68, 0x35, 0x40, 0x3B, 0x74, 0x2E, 0x35, 0x77, + 0x33, 0x34, 0x45, 0x32, 0x52, 0x79, 0x40, 0x7B + ]; + public static byte[] XOR(byte[] data, int key) { var result = new byte[data.Length]; @@ -53,5 +64,50 @@ public static byte[] GZipCompress(byte[] data) } return memory.ToArray(); } + + public static byte[] SavingSaveAsMacOS(byte[] data) + { + using (Aes aesAlg = Aes.Create()) + { + aesAlg.Key = MAC_SAVE_KEY; + aesAlg.Mode = CipherMode.ECB; + + ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV); + + using (MemoryStream msEncrypt = new MemoryStream()) + { + using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write)) + { + csEncrypt.Write(data, 0, data.Length); + } + return msEncrypt.ToArray(); + } + } + } + + public static string LoadSaveAsMacOS(byte[] data) + { + using (Aes aesAlg = Aes.Create()) + { + aesAlg.Key = MAC_SAVE_KEY; + aesAlg.Mode = CipherMode.ECB; + + // Create a decryptor to perform the stream transform. + ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV); + + // Create the streams used for decryption. + using (MemoryStream msDecrypt = new MemoryStream(data)) + { + using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read)) + { + using (StreamReader srDecrypt = new StreamReader(csDecrypt)) + { + // Read the decrypted bytes from the decrypting stream and place them in a string. + return srDecrypt.ReadToEnd(); + } + } + } + } + } } } diff --git a/GeometryDashAPI/Data/DatFileFormat.cs b/GeometryDashAPI/Data/DatFileFormat.cs new file mode 100644 index 0000000..2899e16 --- /dev/null +++ b/GeometryDashAPI/Data/DatFileFormat.cs @@ -0,0 +1,7 @@ +namespace GeometryDashAPI.Data; + +public enum DatFileFormat +{ + Windows, + Mac +} diff --git a/GeometryDashAPI/Data/GameData.cs b/GeometryDashAPI/Data/GameData.cs index 6ee6c37..3035647 100644 --- a/GeometryDashAPI/Data/GameData.cs +++ b/GeometryDashAPI/Data/GameData.cs @@ -10,6 +10,10 @@ namespace GeometryDashAPI.Data { public class GameData { + // This is xored gzip magick bytes: 'C?' + // see more https://en.wikipedia.org/wiki/Gzip + private static readonly byte[] XorDatFileMagickBytes = [ 0x43, 0x3f ]; + public Plist DataPlist { get; set; } private readonly GameDataType? type; @@ -34,15 +38,25 @@ public virtual async Task LoadAsync(string fileName) using var file = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); #endif var data = new byte[file.Length]; - await file.ReadAsync(data, 0, data.Length); + _ = await file.ReadAsync(data, 0, data.Length); + + if (data.AsSpan().Slice(0, XorDatFileMagickBytes.Length).IndexOf(XorDatFileMagickBytes) != 0) + { + // mac files + var decryptedData = Crypt.LoadSaveAsMacOS(data); + DataPlist = new Plist(Encoding.ASCII.GetBytes(decryptedData)); + return; + } + // windows files var xor = Crypt.XOR(data, 0xB); var index = xor.AsSpan().IndexOf((byte)0); - var gZipDecompress = Crypt.GZipDecompress(GameConvert.FromBase64(Encoding.ASCII.GetString(xor, 0, index >= 0 ? index : xor.Length))); - + var gZipDecompress = + Crypt.GZipDecompress( + GameConvert.FromBase64(Encoding.ASCII.GetString(xor, 0, index >= 0 ? index : xor.Length))); DataPlist = new Plist(Encoding.ASCII.GetBytes(gZipDecompress)); } - + /// /// Saves class data to a file as a game save

/// Before saving, make sure that you have closed the game. Otherwise, after closing, the game will overwrite the file
@@ -50,11 +64,15 @@ public virtual async Task LoadAsync(string fileName) /// File to write the data.
/// use null value for default resolving /// - public void Save(string? fullName = null) + /// + /// Specify if you want to save the file in a format specific to another operating system.
+ /// Leave null to save the file for the current operating system + /// + public void Save(string? fullName = null, DatFileFormat? format = null) { using var memory = new MemoryStream(); DataPlist.SaveToStream(memory); - File.WriteAllBytes(fullName ?? ResolveFileName(type), GetFileContent(memory)); + File.WriteAllBytes(fullName ?? ResolveFileName(type), GetFileContent(memory, format ?? ResolveFileFormat())); } /// @@ -64,15 +82,19 @@ public void Save(string? fullName = null) /// File to write the data.
/// use null value for default resolving /// - public async Task SaveAsync(string? fileName = null) + /// + /// Specify if you want to save the file in a format specific to another operating system.
+ /// Leave null to save the file for the current operating system + /// + public async Task SaveAsync(string? fileName = null, DatFileFormat? format = null) { using var memory = new MemoryStream(); await DataPlist.SaveToStreamAsync(memory); #if NETSTANDARD2_1 - await File.WriteAllBytesAsync(fileName ?? ResolveFileName(type), GetFileContent(memory)); + await File.WriteAllBytesAsync(fileName ?? ResolveFileName(type), GetFileContent(memory, format ?? ResolveFileFormat())); #else using var file = new FileStream(fileName ?? ResolveFileName(type), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 4096, useAsync: true); - var data = GetFileContent(memory); + var data = GetFileContent(memory, format ?? ResolveFileFormat()); await file.WriteAsync(data, 0, data.Length); #endif } @@ -83,13 +105,40 @@ public static string ResolveFileName(GameDataType? type) throw new InvalidOperationException("can't resolve the directory with the saves for undefined file type. Use certain file name"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return $@"{Environment.GetEnvironmentVariable("LocalAppData")}\GeometryDash\CC{type}.dat"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return $"/Users/{Environment.GetEnvironmentVariable("USER")}/Library/Application Support/GeometryDash/CC{type}.dat"; throw new InvalidOperationException($"can't resolve the directory with the saves on your operating system: '{RuntimeInformation.OSDescription}'. Use certain file name"); } - private static byte[] GetFileContent(MemoryStream memory) + public static DatFileFormat ResolveFileFormat() { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return DatFileFormat.Mac; + return DatFileFormat.Windows; + } + + private static byte[] GetFileContent(MemoryStream memory, DatFileFormat format) + { + if (format == DatFileFormat.Mac) + return Crypt.SavingSaveAsMacOS(memory.ToArray()); + var base64 = GameConvert.ToBase64(Crypt.GZipCompress(memory.ToArray())); return Crypt.XOR(Encoding.ASCII.GetBytes(base64), 0xB); } + + private static bool StartsWith(Stream stream, ReadOnlySpan prefix) + { + if (!stream.CanSeek) + throw new ArgumentException($"{nameof(stream)} is not seekable. This can lead to bugs."); + if (stream.Length < prefix.Length) + return false; + var position = stream.Position; + var buffer = new byte[prefix.Length]; + var read = 0; + while (read != buffer.Length) + read += stream.Read(buffer, read, buffer.Length - read); + stream.Seek(position, SeekOrigin.Begin); + return buffer.AsSpan().IndexOf(prefix) == 0; + } } }