400 lines
14 KiB
C#
400 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
|
|
namespace EonaCat.FirstLight.SaveTransfer;
|
|
|
|
/// <summary>
|
|
/// All encryption uses XOR with the little-endian SteamID64 (8-byte repeating key).
|
|
/// </summary>
|
|
public static class SaveCore
|
|
{
|
|
|
|
private static byte[] GetSteamIdKeyBytes(ulong steamId64)
|
|
{
|
|
// struct.pack("<Q", steam_id) → 8 bytes, little-endian
|
|
byte[] key = new byte[8];
|
|
ulong v = steamId64;
|
|
for (int i = 0; i < 8; i++) { key[i] = (byte)(v & 0xFF); v >>= 8; }
|
|
return key;
|
|
}
|
|
|
|
/// <summary>XOR every byte with the 8-byte repeating SteamID key.</summary>
|
|
public static byte[] XorWithSteamId(ReadOnlySpan<byte> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read bytes 24..27 (little-endian uint32) from index.save, convert to SteamID64.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static ulong? BruteforceDataSaveKey(string filePath, IProgress<string>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resign index.save: XOR out the old key and XOR in the new key in one pass.
|
|
/// Creates a Backup/ copy before modifying.
|
|
/// </summary>
|
|
public static void ResignIndexFile(string filePath, ulong fromSid, ulong toSid,
|
|
List<string> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resign data.save: decrypt (XOR + zlib decompress), recompress (zlib level 4), re-encrypt.
|
|
/// Returns false if decompression fails.
|
|
/// </summary>
|
|
public static bool ResignDataFile(string filePath, ulong fromSid, ulong toSid,
|
|
List<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decrypt index.save and write a .decrypted file alongside it.
|
|
/// </summary>
|
|
public static void DecryptIndexFile(string filePath, ulong steamSid, List<string> 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)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decrypt data.save (XOR + zlib decompress) and write a .decrypted file.
|
|
/// </summary>
|
|
public static bool DecryptDataFile(string filePath, ulong steamSid, List<string> 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<string> 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<byte> haystack, ReadOnlySpan<byte> 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);
|
|
|
|
/// <summary>
|
|
/// Walk a root directory recursively looking for folders that contain
|
|
/// index.save and/or data.save (ignoring Backup sub-dirs).
|
|
/// </summary>
|
|
public static List<SaveContainer> FindSaveContainers(string rootDir)
|
|
{
|
|
var results = new List<SaveContainer>();
|
|
ScanDir(rootDir, results);
|
|
return results;
|
|
}
|
|
|
|
private static void ScanDir(string dir, List<SaveContainer> 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 */ }
|
|
}
|
|
}
|