using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Runtime.InteropServices; using System.Text; namespace EonaCat.FirstLight.SaveTransfer; /// /// All encryption uses XOR with the little-endian SteamID64 (8-byte repeating key). /// public static class SaveCore { private static byte[] GetSteamIdKeyBytes(ulong steamId64) { // struct.pack(">= 8; } return key; } /// XOR every byte with the 8-byte repeating SteamID key. public static byte[] XorWithSteamId(ReadOnlySpan data, ulong steamId64) { byte[] key = GetSteamIdKeyBytes(steamId64); byte[] result = new byte[data.Length]; for (int i = 0; i < data.Length; i++) { result[i] = (byte)(data[i] ^ key[i % 8]); } return result; } /// /// Read bytes 24..27 (little-endian uint32) from index.save, convert to SteamID64. /// public static ulong? DetectSourceSteamIdFromIndex(string indexPath) { // Use the Backup copy if available string backupPath = Path.Combine(Path.GetDirectoryName(indexPath)!, "Backup", Path.GetFileName(indexPath)); string targetPath = File.Exists(backupPath) ? backupPath : indexPath; if (!File.Exists(targetPath)) { return null; } try { byte[] data = File.ReadAllBytes(targetPath); if (data.Length < 28) { return null; } uint accountId = BitConverter.ToUInt32(data, 24); // little-endian uint32 return 76561197960265728UL + accountId; } catch { return null; } } /// /// Recover the SteamID64 used to encrypt data.save by brute-forcing bytes 2 and 3 /// of the key while exploiting known zlib header magic (CMF=0x78). /// public static ulong? BruteforceDataSaveKey(string filePath, IProgress? progress = null) { if (!File.Exists(filePath)) { return null; } string backupPath = Path.Combine(Path.GetDirectoryName(filePath)!, "Backup", Path.GetFileName(filePath)); string targetPath = File.Exists(backupPath) ? backupPath : filePath; byte[] ciphertext; try { ciphertext = File.ReadAllBytes(targetPath); } catch { return null; } if (ciphertext.Length < 32) { return null; } // b0 comes from knowing first plaintext byte is 0x78 (zlib CMF) byte b0 = (byte)(ciphertext[0] ^ 0x78); // b1 candidates from valid zlib FLG values that make (cmf*256+flg) % 31 == 0 // where cmf = 0x78: 0x78*256 + flg must be divisible by 31 // valid_flgs = [0x01, 0x5E, 0x9C, 0xDA] byte[] validFlgs = [0x01, 0x5E, 0x9C, 0xDA]; byte[] b1Candidates = new byte[validFlgs.Length]; for (int i = 0; i < validFlgs.Length; i++) { b1Candidates[i] = (byte)(ciphertext[1] ^ validFlgs[i]); } byte[] headerChunk = ciphertext[..16]; foreach (byte b1 in b1Candidates) { for (int b2 = 0; b2 < 256; b2++) { for (int b3 = 0; b3 < 256; b3++) { // Key bytes 0..7 - tail bytes 4..7 fixed as [0x01,0x00,0x10,0x01] // (matches the game's SteamID encoding pattern) byte[] key = [b0, b1, (byte)b2, (byte)b3, 0x01, 0x00, 0x10, 0x01]; // Decrypt first 2 bytes only to check zlib header byte cmf = (byte)(headerChunk[0] ^ key[0]); byte flg = (byte)(headerChunk[1] ^ key[1]); if (cmf != 0x78) { continue; } if ((cmf * 256 + flg) % 31 != 0) { continue; } // Verify with first 128 bytes try { byte[] verifyChunk = new byte[Math.Min(128, ciphertext.Length)]; for (int i = 0; i < verifyChunk.Length; i++) { verifyChunk[i] = (byte)(ciphertext[i] ^ key[i % 8]); } using var ms = new MemoryStream(verifyChunk); using var zs = new ZLibStream(ms, CompressionMode.Decompress); byte[] dummy = new byte[256]; zs.Read(dummy, 0, dummy.Length); } catch { continue; } // Full decompression to confirm try { byte[] fullDecrypted = new byte[ciphertext.Length]; for (int i = 0; i < ciphertext.Length; i++) { fullDecrypted[i] = (byte)(ciphertext[i] ^ key[i % 8]); } using var ms2 = new MemoryStream(fullDecrypted); using var zs2 = new ZLibStream(ms2, CompressionMode.Decompress); using var output = new MemoryStream(); zs2.CopyTo(output); // success - reconstruct ulong SteamID64 from key bytes ulong steamId = BitConverter.ToUInt64(key, 0); return steamId; } catch { continue; } } } } return null; } /// /// Resign index.save: XOR out the old key and XOR in the new key in one pass. /// Creates a Backup/ copy before modifying. /// public static void ResignIndexFile(string filePath, ulong fromSid, ulong toSid, List log) { log.Add($" Resigning index.save: {filePath}"); byte[] originalCiphertext = File.ReadAllBytes(filePath); byte[] fromKey = GetSteamIdKeyBytes(fromSid); byte[] toKey = GetSteamIdKeyBytes(toSid); byte[] newCiphertext = new byte[originalCiphertext.Length]; for (int i = 0; i < originalCiphertext.Length; i++) { newCiphertext[i] = (byte)(originalCiphertext[i] ^ fromKey[i % 8] ^ toKey[i % 8]); } EnsureBackup(filePath, log); File.WriteAllBytes(filePath, newCiphertext); log.Add(" [SUCCESS] index.save resigned successfully."); } /// /// Resign data.save: decrypt (XOR + zlib decompress), recompress (zlib level 4), re-encrypt. /// Returns false if decompression fails. /// public static bool ResignDataFile(string filePath, ulong fromSid, ulong toSid, List log) { log.Add($" Resigning data.save: {filePath}"); byte[] originalCiphertext = File.ReadAllBytes(filePath); byte[] fromKey = GetSteamIdKeyBytes(fromSid); byte[] toKey = GetSteamIdKeyBytes(toSid); // Step 1: XOR-decrypt with source key byte[] decryptedXor = new byte[originalCiphertext.Length]; for (int i = 0; i < originalCiphertext.Length; i++) { decryptedXor[i] = (byte)(originalCiphertext[i] ^ fromKey[i % 8]); } // Step 2: Zlib decompress byte[] decompressedPayload; try { using var ms = new MemoryStream(decryptedXor); using var zs = new ZLibStream(ms, CompressionMode.Decompress); using var output = new MemoryStream(); zs.CopyTo(output); decompressedPayload = output.ToArray(); log.Add($" [OK] Decompressed payload: {decompressedPayload.Length:N0} bytes."); } catch (Exception ex) { log.Add($" [ERROR] Decompression failed - source SteamID may be wrong. {ex.Message}"); return false; } // Step 3: Recompress at level 4 byte[] compressedPayload; using (var msOut = new MemoryStream()) { using var zOut = new ZLibStream(msOut, CompressionLevel.Optimal); zOut.Write(decompressedPayload, 0, decompressedPayload.Length); zOut.Flush(); compressedPayload = msOut.ToArray(); } // Step 4: XOR-encrypt with target key byte[] newCiphertext = new byte[compressedPayload.Length]; for (int i = 0; i < compressedPayload.Length; i++) { newCiphertext[i] = (byte)(compressedPayload[i] ^ toKey[i % 8]); } EnsureBackup(filePath, log); File.WriteAllBytes(filePath, newCiphertext); log.Add($" [SUCCESS] data.save resigned. ({originalCiphertext.Length:N0} → {newCiphertext.Length:N0} bytes)"); return true; } /// /// Decrypt index.save and write a .decrypted file alongside it. /// public static void DecryptIndexFile(string filePath, ulong steamSid, List log) { log.Add($" Processing index.save: {filePath}"); byte[] data = File.ReadAllBytes(filePath); if (data.Length < 8) { log.Add(" [ERROR] File too short."); return; } byte[] decrypted = XorWithSteamId(data, steamSid); // Check for header structure marker int idx = IndexOf(decrypted, "SSaveGameHeader"u8); if (idx >= 0) { log.Add(" [OK] Header verified: found 'SSaveGameHeader'"); } else { log.Add(" [WARN] 'SSaveGameHeader' not found - SteamID may be incorrect."); } string outPath = filePath + ".decrypted"; File.WriteAllBytes(outPath, decrypted); log.Add($" [SUCCESS] → {Path.GetFileName(outPath)}"); } /// /// Decrypt data.save (XOR + zlib decompress) and write a .decrypted file. /// public static bool DecryptDataFile(string filePath, ulong steamSid, List log) { log.Add($" Processing data.save: {filePath}"); byte[] ciphertext = File.ReadAllBytes(filePath); byte[] decryptedXor = XorWithSteamId(ciphertext, steamSid); try { byte[] decompressed; using (var ms = new MemoryStream(decryptedXor)) using (var zs = new ZLibStream(ms, CompressionMode.Decompress)) using (var output = new MemoryStream()) { zs.CopyTo(output); decompressed = output.ToArray(); } log.Add($" [OK] Decompressed: {decompressed.Length:N0} bytes."); // Extract save class type name (bytes 8..8+head_len), head_len at offset 4 if (decompressed.Length >= 32) { uint rawLen = BitConverter.ToUInt32(decompressed, 4); int headLen = (int)(rawLen & 0x3FFFFFFF); if (headLen < 100 && headLen > 0 && 8 + headLen <= decompressed.Length) { string typeName = Encoding.Latin1.GetString(decompressed, 8, headLen); log.Add($" Save class type: '{typeName}'"); } } string outPath = filePath + ".decrypted"; File.WriteAllBytes(outPath, decompressed); log.Add($" [SUCCESS] → {Path.GetFileName(outPath)}"); return true; } catch (Exception ex) { log.Add($" [ERROR] Decompression failed - SteamID {steamSid} may be incorrect. {ex.Message}"); return false; } } private static void EnsureBackup(string filePath, List log) { string backupDir = Path.Combine(Path.GetDirectoryName(filePath)!, "Backup"); string backupPath = Path.Combine(backupDir, Path.GetFileName(filePath)); if (!Directory.Exists(backupDir)) { Directory.CreateDirectory(backupDir); } if (!File.Exists(backupPath)) { File.Copy(filePath, backupPath); log.Add($" [OK] Backup → {backupPath}"); } } private static int IndexOf(ReadOnlySpan haystack, ReadOnlySpan needle) { for (int i = 0; i <= haystack.Length - needle.Length; i++) { if (haystack.Slice(i, needle.Length).SequenceEqual(needle)) { return i; } } return -1; } public record SaveContainer(string Directory, bool HasIndex, bool HasData); /// /// Walk a root directory recursively looking for folders that contain /// index.save and/or data.save (ignoring Backup sub-dirs). /// public static List FindSaveContainers(string rootDir) { var results = new List(); ScanDir(rootDir, results); return results; } private static void ScanDir(string dir, List results) { try { string dirName = Path.GetFileName(dir); if (string.Equals(dirName, "Backup", StringComparison.OrdinalIgnoreCase)) { return; } string[] files = Directory.GetFiles(dir); bool hasIndex = Array.Exists(files, f => Path.GetFileName(f).Equals("index.save", StringComparison.OrdinalIgnoreCase)); bool hasData = Array.Exists(files, f => Path.GetFileName(f).Equals("data.save", StringComparison.OrdinalIgnoreCase)); if (hasIndex || hasData) { results.Add(new SaveContainer(dir, hasIndex, hasData)); } foreach (string sub in Directory.GetDirectories(dir)) { ScanDir(sub, results); } } catch { /* access denied, skip */ } } }