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;
+ }
}
}