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 */ }
}
}