diff --git a/007SaveTool/App.xaml b/007SaveTool/App.xaml
new file mode 100644
index 0000000..82422a5
--- /dev/null
+++ b/007SaveTool/App.xaml
@@ -0,0 +1,198 @@
+
+
+
+ #1A1A2E
+ #16213E
+ #0F3460
+ #D4AF37
+ #F0C840
+ #E8E8E8
+ #A0A8B8
+ #6A7280
+ #22C55E
+ #EF4444
+ #F59E0B
+ #3B82F6
+ #1E3A5F
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/007SaveTool/App.xaml.cs b/007SaveTool/App.xaml.cs
new file mode 100644
index 0000000..516162a
--- /dev/null
+++ b/007SaveTool/App.xaml.cs
@@ -0,0 +1,5 @@
+using System.Windows;
+
+namespace EonaCat.FirstLight.SaveTransfer;
+
+public partial class App : Application { }
diff --git a/007SaveTool/EonaCat.FirstLight.SaveTransfer.csproj b/007SaveTool/EonaCat.FirstLight.SaveTransfer.csproj
new file mode 100644
index 0000000..088a27b
--- /dev/null
+++ b/007SaveTool/EonaCat.FirstLight.SaveTransfer.csproj
@@ -0,0 +1,17 @@
+
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ EonaCat.FirstLight.SaveTransfer
+ EonaCat.FirstLight.SaveTransfer
+ icon.ico
+ AnyCPU
+ false
+ true
+
+
+
diff --git a/007SaveTool/MainWindow.xaml b/007SaveTool/MainWindow.xaml
new file mode 100644
index 0000000..8422672
--- /dev/null
+++ b/007SaveTool/MainWindow.xaml
@@ -0,0 +1,502 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1. Select your folder using the 'Browse' button with your NEW savegame (folder which contains index.save and data.save)
+
+
+ 2. Insert your steam Id which you want to set.
+
+
+ 3. Click on 'Resign Save Files'
+
+
+ 4. Place the savegame files in your original save directory.
+
+
+
+
+
+
+
+
+
+ (x is your save slot number)
+
+
+
+
+
+
+
+
+
+ Please note if using the steam version, you also need to do these extra steps:
+
+
+
+
+ 1. Click on the VDF Generator tab
+
+
+ 2. Browse to your remote folder
+
+
+ 3. Click on 'Generate VDF'
+
+
+
+
+
+ Now startup your game, the saves should show
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1. Select your folder using the 'Browse' button with your NEW savegame (folder which contains index.save and data.save)
+
+
+ 2. Insert your steam Id which you want to set.
+
+
+ 3. Click on 'Resign Save Files'
+
+
+ 4. Place the savegame files in your original save directory.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Please note that if using the cracked version you would possibly also need the userdata.zip
+
+
+
+
+ Extract the contents of the zip in [YOUR 007 FIRST LIGHT INSTALLATION FOLDER]\Retail
+
+
+ Overwrite the existing steam_settings and userdata.
+
+
+
+
+
+
+ ⚠ IMPORTANT WARNING
+
+
+ CREATE A BACKUP FIRST OF THE FOLDERS YOU'RE OVERWRITING AS YOU COULD GET INVALID TOKEN ERRORS IF DONE INCORRECTLY!
+
+
+
+
+
+ Please note that the steam_settings and the userdata folders are from the v1.0.1 version of the game.
+
+
+
+ See
+
+ nexusmods.com/007firstlight/mods/44
+
+ for the userdata.zip
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Find your SteamID64 at
+
+ steamid.io
+
+ - it looks like 76561197960272671
+
+
+
+
+
+
+ When left blank the tool will auto-detect from index.save
+ and bruteforce the data.save key.
+
+
+
+
+
+
+
+ Auto-confirm SteamID mismatches between
+ index.save and
+ data.save
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Leave blank to auto-detect from index.save.
+ Output files will be saved next to the originals with a .decrypted extension.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select the remote folder from your Steam save directory
+ (e.g., …\userdata\<AccountId>\3768760\remote)
+ to generate the remotecache.vdf file.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/007SaveTool/MainWindow.xaml.cs b/007SaveTool/MainWindow.xaml.cs
new file mode 100644
index 0000000..7b78a9a
--- /dev/null
+++ b/007SaveTool/MainWindow.xaml.cs
@@ -0,0 +1,541 @@
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.Models;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Navigation;
+
+namespace EonaCat.FirstLight.SaveTransfer;
+
+public partial class MainWindow : Window
+{
+ private readonly StringBuilder _logBuffer = new();
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ MainTabs.SelectionChanged += MainTabs_SelectionChanged;
+ }
+
+ private void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (MainTabs.SelectedItem is not TabItem tab)
+ {
+ return;
+ }
+
+ PanelInstructionsSteam.Visibility = Visibility.Collapsed;
+ PanelInstructionsCracked.Visibility = Visibility.Collapsed;
+ PanelResign.Visibility = Visibility.Collapsed;
+ PanelDecrypt.Visibility = Visibility.Collapsed;
+ PanelVdf.Visibility = Visibility.Collapsed;
+ PanelLog.Visibility = Visibility.Collapsed;
+
+ if (tab == TabInstructionsSteam)
+ {
+ PanelInstructionsSteam.Visibility = Visibility.Visible;
+ }
+
+ if (tab == TabInstructionsCracked)
+ {
+ PanelInstructionsCracked.Visibility = Visibility.Visible;
+ }
+
+ if (tab == TabResign)
+ {
+ PanelResign.Visibility = Visibility.Visible;
+ }
+
+ if (tab == TabDecrypt)
+ {
+ PanelDecrypt.Visibility = Visibility.Visible;
+ }
+
+ if (tab == TabVdf)
+ {
+ PanelVdf.Visibility = Visibility.Visible;
+ }
+
+ if (tab == TabLog)
+ {
+ PanelLog.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void BrowseResignFolder_Click(object sender, RoutedEventArgs e)
+ {
+ string? path = BrowseForFolder();
+ if (path is null)
+ {
+ return;
+ }
+
+ TxtResignFolder.Text = path;
+ TxtResignFolder.Foreground = (Brush)FindResource("TextPrimaryBrush");
+ }
+
+ private void BrowseDecryptFolder_Click(object sender, RoutedEventArgs e)
+ {
+ string? path = BrowseForFolder();
+ if (path is null)
+ {
+ return;
+ }
+
+ TxtDecryptFolder.Text = path;
+ TxtDecryptFolder.Foreground = (Brush)FindResource("TextPrimaryBrush");
+ }
+
+ private void BrowseVdfRemoteFolder_Click(object sender, RoutedEventArgs e)
+ {
+ string? path = BrowseForFolder();
+ if (path is null)
+ {
+ return;
+ }
+
+ TxtVdfRemoteFolder.Text = path;
+ TxtVdfRemoteFolder.Foreground = (Brush)FindResource("TextPrimaryBrush");
+ }
+
+ private static string? BrowseForFolder()
+ {
+ var dlg = new Microsoft.Win32.OpenFolderDialog
+ {
+ Title = "Select your save folder (e.g. …\\userdata\\\\3768760\\remote)",
+ };
+ return dlg.ShowDialog() == true ? dlg.FolderName : null;
+ }
+
+ private void NumericOnly_PreviewInput(object sender, TextCompositionEventArgs e)
+ {
+ foreach (char c in e.Text)
+ {
+ if (!char.IsDigit(c)) { e.Handled = true; return; }
+ }
+ }
+
+ private async void BtnResign_Click(object sender, RoutedEventArgs e)
+ {
+ string folder = TxtResignFolder.Text.Trim();
+ if (string.IsNullOrEmpty(folder) || folder.StartsWith("Click"))
+ { ShowBanner(ResignResultBanner, ResignResultText, "Please select a save folder first.", BannerKind.Error); return; }
+
+ string targetText = TxtTargetSID.Text.Trim();
+ if (!ulong.TryParse(targetText, out ulong targetSid))
+ { ShowBanner(ResignResultBanner, ResignResultText, "Please enter a valid Target SteamID64.", BannerKind.Error); return; }
+
+ ulong? userSourceSid = null;
+ string sourceText = TxtSourceSID.Text.Trim();
+ if (!string.IsNullOrEmpty(sourceText))
+ {
+ if (!ulong.TryParse(sourceText, out ulong s))
+ { ShowBanner(ResignResultBanner, ResignResultText, "Source SteamID64 is invalid - leave blank to auto-detect.", BannerKind.Error); return; }
+ userSourceSid = s;
+ }
+
+ bool autoConfirm = ChkAutoConfirm.IsChecked == true;
+
+ SetBusy(true, BtnResign, ResignProgress);
+ HideBanner(ResignResultBanner);
+
+ var log = new List();
+ int resignedDirs = 0;
+ string summary;
+
+ try
+ {
+ (resignedDirs, summary) = await Task.Run(() =>
+ RunResign(folder, targetSid, userSourceSid, autoConfirm, log));
+ }
+ finally
+ {
+ SetBusy(false, BtnResign, ResignProgress);
+ }
+
+ // Flush to log tab
+ AppendLog(log);
+ UpdateStatusBar($"Resigned {resignedDirs} save container(s).");
+
+ bool success = resignedDirs > 0;
+ ShowBanner(ResignResultBanner, ResignResultText, summary,
+ success ? BannerKind.Success : BannerKind.Warning);
+ }
+
+ private static (int resigned, string summary) RunResign(
+ string rootDir, ulong targetSid, ulong? userSourceSid,
+ bool autoConfirm, List log)
+ {
+ var containers = SaveCore.FindSaveContainers(rootDir);
+ if (containers.Count == 0)
+ {
+ log.Add("[INFO] No save containers found (no index.save / data.save).");
+ return (0, "No save containers found in the selected folder.");
+ }
+
+ int resignedDirs = 0;
+
+ foreach (var container in containers)
+ {
+ log.Add($"\nFound save container: {container.Directory}");
+
+ string indexPath = System.IO.Path.Combine(container.Directory, "index.save");
+ string dataPath = System.IO.Path.Combine(container.Directory, "data.save");
+
+ if (container.HasIndex && container.HasData)
+ {
+ ulong? indexSid = SaveCore.DetectSourceSteamIdFromIndex(indexPath);
+ log.Add(" [AUTO] Bruteforcing data.save encryption key…");
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ ulong? dataSid = SaveCore.BruteforceDataSaveKey(dataPath);
+ sw.Stop();
+
+ if (dataSid.HasValue)
+ {
+ log.Add($" [AUTO] data.save key cracked in {sw.Elapsed.TotalSeconds:F3}s: {dataSid}");
+ }
+ else
+ {
+ log.Add(" [ERROR] Bruteforce failed. Skipping. Use manual Source SteamID.");
+ continue;
+ }
+
+ if (!indexSid.HasValue)
+ {
+ log.Add(" [ERROR] index.save auto-detect failed. Skipping.");
+ continue;
+ }
+
+ if (indexSid == dataSid)
+ {
+ log.Add($" [OK] Both files bound to SteamID64: {indexSid}");
+ ulong src = userSourceSid ?? indexSid.Value;
+ SaveCore.ResignIndexFile(indexPath, src, targetSid, log);
+ SaveCore.ResignDataFile(dataPath, src, targetSid, log);
+ }
+ else
+ {
+ log.Add($" [WARNING] STEAMID MISMATCH!");
+ log.Add($" index.save → {indexSid}");
+ log.Add($" data.save → {dataSid}");
+
+ if (userSourceSid.HasValue)
+ {
+ log.Add($" Using manual override: {userSourceSid}");
+ SaveCore.ResignIndexFile(indexPath, userSourceSid.Value, targetSid, log);
+ SaveCore.ResignDataFile(dataPath, userSourceSid.Value, targetSid, log);
+ }
+ else if (autoConfirm)
+ {
+ log.Add(" Proceeding with split re-signing (auto-confirm).");
+ SaveCore.ResignIndexFile(indexPath, indexSid.Value, targetSid, log);
+ SaveCore.ResignDataFile(dataPath, dataSid.Value, targetSid, log);
+ }
+ else
+ {
+ // In GUI mode without auto-confirm, default to split signing
+ log.Add(" Proceeding with dynamic split re-signing.");
+ SaveCore.ResignIndexFile(indexPath, indexSid.Value, targetSid, log);
+ SaveCore.ResignDataFile(dataPath, dataSid.Value, targetSid, log);
+ }
+ }
+ resignedDirs++;
+ }
+ else if (container.HasIndex)
+ {
+ ulong? indexSid = SaveCore.DetectSourceSteamIdFromIndex(indexPath);
+ if (!indexSid.HasValue)
+ { log.Add(" [ERROR] index.save auto-detect failed. Skipping."); continue; }
+
+ ulong src = userSourceSid ?? indexSid.Value;
+ SaveCore.ResignIndexFile(indexPath, src, targetSid, log);
+ log.Add(" [INFO] No data.save present.");
+ resignedDirs++;
+ }
+ else if (container.HasData)
+ {
+ log.Add(" [AUTO] Bruteforcing data.save…");
+ ulong? dataSid = SaveCore.BruteforceDataSaveKey(dataPath);
+ if (!dataSid.HasValue)
+ { log.Add(" [ERROR] Bruteforce failed. Skipping."); continue; }
+
+ ulong src = userSourceSid ?? dataSid.Value;
+ SaveCore.ResignDataFile(dataPath, src, targetSid, log);
+ log.Add(" [INFO] No index.save present.");
+ resignedDirs++;
+ }
+ }
+
+ string summary = resignedDirs > 0
+ ? $"✓ Resigned {resignedDirs} save container(s) successfully. Backups created."
+ : "No containers were resigned. Check the Log tab for details.";
+ return (resignedDirs, summary);
+ }
+
+ private async void BtnDecrypt_Click(object sender, RoutedEventArgs e)
+ {
+ string folder = TxtDecryptFolder.Text.Trim();
+ if (string.IsNullOrEmpty(folder) || folder.StartsWith("Click"))
+ { ShowBanner(DecryptResultBanner, DecryptResultText, "Please select a save folder first.", BannerKind.Error); return; }
+
+ ulong? manualSid = null;
+ string sidText = TxtDecryptSID.Text.Trim();
+ if (!string.IsNullOrEmpty(sidText))
+ {
+ if (!ulong.TryParse(sidText, out ulong s))
+ { ShowBanner(DecryptResultBanner, DecryptResultText, "SteamID64 is invalid.", BannerKind.Error); return; }
+ manualSid = s;
+ }
+
+ SetBusy(true, BtnDecrypt, DecryptProgress);
+ HideBanner(DecryptResultBanner);
+
+ var log = new List();
+ int processed;
+ string summary;
+
+ try
+ {
+ (processed, summary) = await Task.Run(() =>
+ RunDecrypt(folder, manualSid, log));
+ }
+ finally
+ {
+ SetBusy(false, BtnDecrypt, DecryptProgress);
+ }
+
+ AppendLog(log);
+ UpdateStatusBar($"Decrypted {processed} container(s).");
+
+ ShowBanner(DecryptResultBanner, DecryptResultText, summary,
+ processed > 0 ? BannerKind.Success : BannerKind.Warning);
+ }
+
+ private static (int processed, string summary) RunDecrypt(
+ string rootDir, ulong? manualSid, List log)
+ {
+ var containers = SaveCore.FindSaveContainers(rootDir);
+ if (containers.Count == 0)
+ {
+ log.Add("[INFO] No save containers found.");
+ return (0, "No save containers found in the selected folder.");
+ }
+
+ int processedCount = 0;
+
+ foreach (var container in containers)
+ {
+ log.Add($"\nSave container: {container.Directory}");
+ string indexPath = System.IO.Path.Combine(container.Directory, "index.save");
+ string dataPath = System.IO.Path.Combine(container.Directory, "data.save");
+
+ ulong? decSid = manualSid;
+
+ if (!decSid.HasValue)
+ {
+ if (container.HasIndex)
+ {
+ decSid = SaveCore.DetectSourceSteamIdFromIndex(indexPath);
+ if (decSid.HasValue)
+ {
+ log.Add($" [AUTO] Detected SteamID64: {decSid}");
+ }
+ else
+ { log.Add(" [ERROR] Auto-detect failed. Skipping."); continue; }
+ }
+ else
+ { log.Add(" [ERROR] No index.save for auto-detection. Skipping."); continue; }
+ }
+
+ if (container.HasIndex)
+ {
+ SaveCore.DecryptIndexFile(indexPath, decSid.Value, log);
+ }
+
+ if (container.HasData)
+ {
+ SaveCore.DecryptDataFile(dataPath, decSid.Value, log);
+ }
+
+ processedCount++;
+ }
+
+ string summary = processedCount > 0
+ ? $"✓ Decrypted {processedCount} container(s). .decrypted files written."
+ : "Nothing was decrypted. Check the Log tab for details.";
+ return (processedCount, summary);
+ }
+
+ private async void BtnGenerateVdf_Click(object sender, RoutedEventArgs e)
+ {
+ string folder = TxtVdfRemoteFolder.Text.Trim();
+ if (string.IsNullOrEmpty(folder) || folder.StartsWith("Click"))
+ { ShowBanner(VdfResultBanner, VdfResultText, "Please select a remote folder first.", BannerKind.Error); return; }
+
+ if (!Directory.Exists(folder))
+ { ShowBanner(VdfResultBanner, VdfResultText, "The selected folder does not exist.", BannerKind.Error); return; }
+
+ SetBusy(true, BtnGenerateVdf, VdfProgress);
+ HideBanner(VdfResultBanner);
+
+ var log = new List();
+ string summary;
+
+ try
+ {
+ summary = await Task.Run(() => RunGenerateVdf(folder, log));
+ }
+ catch (Exception ex)
+ {
+ summary = $"Error generating VDF: {ex.Message}";
+ log.Add($"[ERROR] {ex.Message}");
+ }
+ finally
+ {
+ SetBusy(false, BtnGenerateVdf, VdfProgress);
+ }
+
+ AppendLog(log);
+ UpdateStatusBar("VDF generation completed.");
+
+ bool success = !summary.StartsWith("Error");
+ ShowBanner(VdfResultBanner, VdfResultText, summary,
+ success ? BannerKind.Success : BannerKind.Error);
+ }
+
+ private static string RunGenerateVdf(string remoteFolderPath, List log)
+ {
+ try
+ {
+ log.Add("[INFO] Generating VDF from remote folder...");
+ var remoteCacheVdfFile = new RemoteCacheVdfFile(remoteFolderPath);
+ log.Add($"[INFO] Found {remoteCacheVdfFile.CachedFiles.Count} files in remote folder.");
+
+ var outputDirectory = remoteFolderPath;
+ if (string.IsNullOrEmpty(outputDirectory))
+ {
+ outputDirectory = AppDomain.CurrentDomain.BaseDirectory;
+ }
+
+ // Check if we already have a remotecache.vdf in the target location
+ string outputPath = Path.Combine(outputDirectory, $"{RemoteCacheVdfFile.FileName}{RemoteCacheVdfFile.FileExtension}");
+ if (File.Exists(outputPath))
+ {
+ log.Add($"[WARNING] A file named {RemoteCacheVdfFile.FileName}{RemoteCacheVdfFile.FileExtension} already exists in the target location.");
+
+ // Create a backup of the existing file
+ string backupPath = Path.Combine(outputDirectory, $"{RemoteCacheVdfFile.FileName}_backup_{DateTime.Now:yyyyMMddHHmmss}{RemoteCacheVdfFile.FileExtension}");
+ if (File.Exists(backupPath))
+ {
+ log.Add($"[WARNING] Backup file {backupPath} already exists. Overwriting backup.");
+ File.Delete(backupPath);
+ }
+ File.Move(outputPath, backupPath);
+ }
+
+ remoteCacheVdfFile.ExportAsFile(outputDirectory);
+ log.Add($"[SUCCESS] VDF file generated successfully.");
+ log.Add($"[INFO] Output: {Path.Combine(outputDirectory, "remotecache.vdf")}");
+ return $"✓ VDF generated successfully with {remoteCacheVdfFile.CachedFiles.Count} files.";
+ }
+ catch (Exception ex)
+ {
+ log.Add($"[ERROR] {ex.Message}");
+ return $"Error: {ex.Message}";
+ }
+
+ }
+
+ private void AppendLog(IEnumerable lines)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ foreach (string line in lines)
+ {
+ _logBuffer.AppendLine(line);
+ // Colour-code lines
+ var run = new Run(line + "\n");
+ if (line.Contains("[SUCCESS]") || line.Contains("[OK]") || line.StartsWith(" ✓"))
+ {
+ run.Foreground = (Brush)FindResource("SuccessBrush");
+ }
+ else if (line.Contains("[ERROR]"))
+ {
+ run.Foreground = (Brush)FindResource("ErrorBrush");
+ }
+ else if (line.Contains("[WARNING]") || line.Contains("[WARN]"))
+ {
+ run.Foreground = (Brush)FindResource("WarningBrush");
+ }
+ else if (line.Contains("[AUTO]") || line.Contains("[INFO]"))
+ {
+ run.Foreground = (Brush)FindResource("InfoBrush");
+ }
+ else
+ {
+ run.Foreground = (Brush)FindResource("TextSecondaryBrush");
+ }
+
+ TxtLog.Inlines.Add(run);
+ }
+ LogScroll.ScrollToBottom();
+ });
+ }
+
+ private void ClearLog_Click(object sender, RoutedEventArgs e)
+ {
+ TxtLog.Inlines.Clear();
+ _logBuffer.Clear();
+ }
+
+ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
+ {
+ Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
+ e.Handled = true;
+ }
+
+ private void SetBusy(bool busy, Button btn, System.Windows.Controls.ProgressBar progress)
+ {
+ btn.IsEnabled = !busy;
+ progress.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ TxtStatus.Text = busy ? "Working…" : "Ready";
+ }
+
+ private void UpdateStatusBar(string msg) =>
+ TxtStatusBar.Text = msg;
+
+ private enum BannerKind { Success, Warning, Error, Info }
+
+ private void ShowBanner(Border banner, TextBlock text, string message, BannerKind kind)
+ {
+ banner.Visibility = Visibility.Visible;
+ text.Text = message;
+ banner.Background = kind switch
+ {
+ BannerKind.Success => new SolidColorBrush(Color.FromArgb(40, 34, 197, 94)),
+ BannerKind.Warning => new SolidColorBrush(Color.FromArgb(40, 245, 158, 11)),
+ BannerKind.Error => new SolidColorBrush(Color.FromArgb(40, 239, 68, 68)),
+ _ => new SolidColorBrush(Color.FromArgb(40, 59, 130, 246)),
+ };
+ banner.BorderBrush = kind switch
+ {
+ BannerKind.Success => (Brush)FindResource("SuccessBrush"),
+ BannerKind.Warning => (Brush)FindResource("WarningBrush"),
+ BannerKind.Error => (Brush)FindResource("ErrorBrush"),
+ _ => (Brush)FindResource("InfoBrush"),
+ };
+ banner.BorderThickness = new Thickness(1);
+ text.Foreground = banner.BorderBrush;
+ }
+
+ private static void HideBanner(Border banner) =>
+ banner.Visibility = Visibility.Collapsed;
+}
diff --git a/007SaveTool/SaveCore.cs b/007SaveTool/SaveCore.cs
new file mode 100644
index 0000000..3a8f23b
--- /dev/null
+++ b/007SaveTool/SaveCore.cs
@@ -0,0 +1,399 @@
+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 */ }
+ }
+}
diff --git a/007SaveTool/VdfGenerator/KeyValue/Interfaces/IKeyValueNode.cs b/007SaveTool/VdfGenerator/KeyValue/Interfaces/IKeyValueNode.cs
new file mode 100644
index 0000000..ba0ada1
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/Interfaces/IKeyValueNode.cs
@@ -0,0 +1,6 @@
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
+
+public interface IKeyValueNode
+{
+ string Key { get; }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/KeyValue/KeyValueDeserializer.cs b/007SaveTool/VdfGenerator/KeyValue/KeyValueDeserializer.cs
new file mode 100644
index 0000000..4245335
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/KeyValueDeserializer.cs
@@ -0,0 +1,55 @@
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
+
+///
+/// Provides static methods for deserializing text representations of key-value groups into KvGroup objects.
+///
+public static class KeyValueDeserializer
+{
+ ///
+ /// Deserializes the specified text into a KvGroup object.
+ ///
+ /// The input string containing the serialized representation of a KvGroup. Cannot be null.
+ /// A KvGroup object that represents the data contained in the input text.
+ public static KeyValueGroup Deserialize(string text)
+ {
+ var tokenizer = new KeyValueTokenizer(text);
+ return ParseGroup(tokenizer);
+ }
+
+ ///
+ /// Parses a group from the provided tokenizer, including any nested groups or key-value pairs.
+ ///
+ /// The tokenizer used to read group names, symbols, and key-value pairs from the input stream. Must not be null and must be positioned at the start of a group.
+ /// The optional name of the group to parse. If null, the group name is read from the tokenizer.
+ /// A KvGroup representing the parsed group, including all nested groups and key-value pairs.
+ private static KeyValueGroup ParseGroup(KeyValueTokenizer t, string? k = null)
+ {
+ // Expect group name
+ var groupName = k ?? t.ReadString();
+ var group = new KeyValueGroup(groupName);
+
+ t.ReadSymbol('{');
+
+ while (!t.PeekSymbol('}'))
+ {
+ var key = t.ReadString();
+
+ if (t.PeekSymbol('{'))
+ {
+ // Nested group
+ group.Nodes.Add(ParseGroup(t, key));
+ }
+ else
+ {
+ // Key-value pair
+ var value = t.ReadString();
+ group.Nodes.Add(new Models.KeyValuePair(key, value));
+ }
+ }
+
+ t.ReadSymbol('}');
+ return group;
+ }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/KeyValue/KeyValueSerializer.cs b/007SaveTool/VdfGenerator/KeyValue/KeyValueSerializer.cs
new file mode 100644
index 0000000..3056f6f
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/KeyValueSerializer.cs
@@ -0,0 +1,53 @@
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+using System.Text;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
+
+///
+/// Provides functionality to serialize key–value groups into a text-based format.
+///
+public class KeyValueSerializer
+{
+ ///
+ /// Serializes the given key–value group into a text format.
+ ///
+ /// The key–value group to serialize. Cannot be null.
+ /// A string representing the serialized form of the key–value group.
+ public static string Serialize(KeyValueGroup group)
+ {
+ var sb = new StringBuilder();
+ SerializeGroup(group, sb, 0);
+ return sb.ToString();
+ }
+
+ ///
+ /// Serializes the specified key-value group and its child elements into a formatted string representation, appending the result to the provided StringBuilder.
+ ///
+ /// The key-value group to serialize. Cannot be null.
+ /// The StringBuilder to which the serialized output is appended. Cannot be null.
+ /// The indentation level to apply to the serialized output.
+ private static void SerializeGroup(KeyValueGroup group, StringBuilder sb, int indent)
+ {
+ const char padChar = '\t';
+ var pad = new string(padChar, indent);
+
+ sb.Append($"{pad}\"{group.Key}\"\n");
+ sb.Append($"{pad}{{\n");
+
+ foreach (var node in group.Nodes)
+ {
+ switch (node)
+ {
+ case Models.KeyValuePair pair:
+ sb.Append($"{pad}{padChar}\"{pair.Key}\"{padChar}{padChar}\"{pair.Value}\"\n");
+ break;
+
+ case KeyValueGroup childGroup:
+ SerializeGroup(childGroup, sb, indent + 1);
+ break;
+ }
+ }
+
+ sb.Append($"{pad}}}\n");
+ }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/KeyValue/KeyValueTokenizer.cs b/007SaveTool/VdfGenerator/KeyValue/KeyValueTokenizer.cs
new file mode 100644
index 0000000..a345432
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/KeyValueTokenizer.cs
@@ -0,0 +1,81 @@
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
+
+///
+/// Provides functionality for tokenizing a key-value formatted string input, allowing sequential reading and validation of symbols and quoted strings.
+///
+/// The input string to tokenize. Must not be null.
+public class KeyValueTokenizer(string text)
+{
+ private int _pos;
+
+ ///
+ /// Determines whether the next non-whitespace character in the input matches the specified character without advancing the current position.
+ ///
+ /// The character to compare with the next non-whitespace character in the input.
+ /// if the next non-whitespace character matches the specified character; otherwise, .
+ public bool PeekSymbol(char c)
+ {
+ SkipWhitespace();
+ return _pos < text.Length && text[_pos] == c;
+ }
+
+ ///
+ /// Reads the next non-whitespace character from the input and verifies that it matches the specified symbol.
+ ///
+ /// The character to match at the current position in the input.
+ /// Thrown if the next non-whitespace character does not match the specified symbol.
+ public void ReadSymbol(char c)
+ {
+ SkipWhitespace();
+ if (_pos >= text.Length || text[_pos] != c)
+ throw new Exception($"Expected '{c}' at position {_pos}");
+
+ _pos++;
+ }
+
+ ///
+ /// Reads a string enclosed in quotation marks from the current position in the input text.
+ ///
+ /// The string located between the opening and closing quotation marks or an empty string if there are no characters between the quotes.
+ /// Thrown when the current position does not contain an opening quotation mark or when a closing quotation mark cannot be found.
+ public string ReadString()
+ {
+ SkipWhitespace();
+
+ if (_pos >= text.Length || text[_pos] != '"')
+ throw new Exception($"Expected '\"' at position {_pos}, but found '{text[_pos]}'!");
+
+ _pos++; // skip opening quote
+ var start = _pos;
+
+ while (_pos < text.Length && text[_pos] != '"')
+ _pos++;
+
+ if (_pos >= text.Length)
+ throw new Exception("Unterminated string literal!");
+
+ var result = text.Substring(start, _pos - start);
+ _pos++; // skip closing quote
+
+ return result;
+ }
+
+ ///
+ /// Advances the current position past any consecutive whitespace characters in the input text.
+ ///
+ private void SkipWhitespace()
+ {
+ while (_pos < text.Length)
+ {
+ var c = text[_pos];
+
+ if (c is ' ' or '\t' or '\n' or '\r')
+ {
+ _pos++;
+ continue;
+ }
+
+ break;
+ }
+ }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/KeyValue/Models/KeyValueGroup.cs b/007SaveTool/VdfGenerator/KeyValue/Models/KeyValueGroup.cs
new file mode 100644
index 0000000..ce75ea4
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/Models/KeyValueGroup.cs
@@ -0,0 +1,36 @@
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+
+///
+/// Represents a hierarchical group of key-value nodes, allowing organization of nested key-value pairs and groups.
+///
+/// The key that identifies this group. Cannot be null.
+public class KeyValueGroup(string key) : IKeyValueNode
+{
+ public string Key { get; } = key;
+ public List Nodes { get; } = [];
+
+ ///
+ /// Adds a new key-value pair to the group.
+ ///
+ /// The key to associate with the value. Cannot be null.
+ /// The value to associate with the key. Cannot be null.
+ /// The current instance with the new key-value pair added.
+ public KeyValueGroup Add(string key, string value)
+ {
+ Nodes.Add(new KeyValuePair(key, value));
+ return this;
+ }
+
+ ///
+ /// Adds the specified group to the collection of nodes.
+ ///
+ /// The group to add to the collection. Cannot be null.
+ /// The current instance with the added group, enabling method chaining.
+ public KeyValueGroup Add(KeyValueGroup group)
+ {
+ Nodes.Add(group);
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/KeyValue/Models/KeyValuePair.cs b/007SaveTool/VdfGenerator/KeyValue/Models/KeyValuePair.cs
new file mode 100644
index 0000000..a9338ac
--- /dev/null
+++ b/007SaveTool/VdfGenerator/KeyValue/Models/KeyValuePair.cs
@@ -0,0 +1,14 @@
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+
+///
+/// Represents a key-value pair node with immutable key and value properties.
+///
+/// The key associated with the pair. If not specified, an empty string is used.
+/// The value associated with the pair. If not specified, an empty string is used.
+public class KeyValuePair(string key = "", string value = "") : IKeyValueNode
+{
+ public string Key { get; } = key;
+ public string Value { get; } = value;
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/Models/CachedFile.cs b/007SaveTool/VdfGenerator/Models/CachedFile.cs
new file mode 100644
index 0000000..fa02c17
--- /dev/null
+++ b/007SaveTool/VdfGenerator/Models/CachedFile.cs
@@ -0,0 +1,193 @@
+using System.Security.Cryptography;
+using System.IO;
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.Models;
+
+///
+/// Represents a file metadata cached for synchronization or tracking purposes. Provides properties for file path, size, timestamps, hash, and synchronization state.
+///
+/// The relative path of the file within the root directory. Used to identify and locate the file in the cache.
+public class CachedFileMetadata(string relativePath)
+{
+ private const string DefaultSha = "0000000000000000000000000000000000000000";
+
+ public string RelativePath { get; set; } = relativePath;
+ public int Root { get; set; }
+ public int Size { get; set; }
+ public long LocalTime { get; set; }
+ public long Time { get; set; }
+ public long RemoteTime { get; set; }
+ public string Sha { get; set; } = DefaultSha;
+ public int SyncState { get; set; }
+ public int PersistState { get; set; }
+ public int PlatformsToSync2 { get; set; } = -1;
+
+ ///
+ /// Gets the current time as the number of seconds that have elapsed since the Unix epoch (January 1, 1970, 00:00:00 UTC).
+ ///
+ private static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ ///
+ /// Computes the SHA-1 hash of the specified byte span and returns its hexadecimal string representation.
+ ///
+ /// The input data to hash as a read-only span of bytes.
+ /// A lowercase hexadecimal string representing the SHA-1 hash of the input data.
+ private static string Sha1FromSpan(ReadOnlySpan data)
+ {
+ Span hash = stackalloc byte[20]; // SHA‑1 = 20 bytes
+ SHA1.HashData(data, hash);
+
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ ///
+ /// Computes the relative path from the specified root directory to the given file path, using forward slashes as directory separators.
+ ///
+ /// The absolute path to the target file. Cannot be null.
+ /// The absolute path to the root directory from which to calculate the relative path. Cannot be null.
+ /// A relative path from the root directory to the file, using forward slashes ('/') as directory separators.
+ private static string GetRelativePath(string filePath, string rootPath)
+ => Path.GetRelativePath(rootPath, filePath).Replace(Path.DirectorySeparatorChar, '/');
+
+ ///
+ /// Initializes a new instance of the class using the specified file path and root directory. Loads the file's data and metadata into the cache.
+ ///
+ /// The full path to the file to be cached. Must refer to an existing file.
+ /// The root directory path used to compute the relative path for the cached file.
+ /// Thrown if the file specified by filePath does not exist.
+ public CachedFileMetadata(string filePath, string rootPath) : this(GetRelativePath(filePath, rootPath))
+ {
+ if (!File.Exists(filePath))
+ throw new FileNotFoundException("File not found", filePath);
+
+ var data = File.ReadAllBytes(filePath);
+ Size = data.Length;
+
+ SetLocalTimeAndTimeToNow();
+ Sha = Sha1FromSpan(data);
+ }
+
+ ///
+ /// Initializes a new instance of the class using data from the provided key–value group.
+ ///
+ /// A key–value group containing the file data used to initialize the object's properties.
+ public CachedFileMetadata(KeyValueGroup group) : this(group.Key)
+ {
+ foreach (var node in group.Nodes.Cast())
+ {
+ switch (node?.Key)
+ {
+ case "root":
+ Root = NumberParser.ParseInt(node.Value);
+ break;
+ case "size":
+ Size = NumberParser.ParseInt(node.Value);
+ break;
+ case "localtime":
+ LocalTime = NumberParser.ParseLong(node.Value);
+ break;
+ case "time":
+ Time = NumberParser.ParseLong(node.Value);
+ break;
+ case "remotetime":
+ RemoteTime = NumberParser.ParseLong(node.Value);
+ break;
+ case "sha":
+ Sha = node.Value;
+ break;
+ case "syncstate":
+ SyncState = NumberParser.ParseInt(node.Value);
+ break;
+ case "persiststate":
+ PersistState = NumberParser.ParseInt(node.Value);
+ break;
+ case "platformstosync2":
+ PlatformsToSync2 = NumberParser.ParseInt(node.Value);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Sets the local time property to the current system time.
+ ///
+ public void SetLocalTimeToNow() => LocalTime = Now;
+
+ ///
+ /// Sets the current time to the system's current date and time.
+ ///
+ public void SetTimeToNow() => Time = Now;
+
+ ///
+ /// Sets both the local time and the time properties to the current value of the system clock.
+ ///
+ public void SetLocalTimeAndTimeToNow()
+ {
+ var epoch = Now;
+ LocalTime = epoch;
+ Time = epoch;
+ }
+
+ ///
+ /// Sets the local time using the specified timestamp.
+ ///
+ /// The point in time, expressed as a DateTimeOffset, to set as the local time. The value is converted to Unix time in seconds.
+ public void SetLocalTime(DateTimeOffset timestamp)
+ => LocalTime = timestamp.ToUnixTimeSeconds();
+
+ ///
+ /// Sets the current time value using the specified timestamp.
+ ///
+ /// The point in time to set, represented as a DateTimeOffset. The value is converted to Unix time in seconds.
+ public void SetTime(DateTimeOffset timestamp)
+ => Time = timestamp.ToUnixTimeSeconds();
+
+ ///
+ /// Sets the local time and time properties using the specified timestamp.
+ ///
+ /// The date and time value to use, including the offset from Coordinated Universal Time (UTC).
+ public void SetLocalTimeAndTime(DateTimeOffset timestamp)
+ {
+ LocalTime = timestamp.ToUnixTimeSeconds();
+ Time = timestamp.ToUnixTimeSeconds();
+ }
+
+ ///
+ /// Gets the local date and time represented by the current Unix timestamp value.
+ ///
+ /// A that represents the local date and time corresponding to the stored Unix time in seconds.
+ public DateTimeOffset GetLocalDateTime()
+ => DateTimeOffset.FromUnixTimeSeconds(LocalTime);
+
+ ///
+ /// Gets the date and time represented by the current Unix timestamp value.
+ ///
+ /// A DateTimeOffset value corresponding to the Unix timestamp stored in the current instance.
+ public DateTimeOffset GetDateTime()
+ => DateTimeOffset.FromUnixTimeSeconds(Time);
+
+ ///
+ /// Gets the current remote date and time as a DateTimeOffset value.
+ ///
+ /// A DateTimeOffset representing the remote time, converted from the stored Unix timestamp.
+ public DateTimeOffset GetRemoteDateTime()
+ => DateTimeOffset.FromUnixTimeSeconds(RemoteTime);
+
+ ///
+ /// Exports the current object as a key–value group containing essential information about its state and properties.
+ ///
+ /// A instance populated with core data such as the relative path, size, timestamps, SHA checksum, and the current synchronization and persistence states.
+ public KeyValueGroup ExportAsKvGroup()
+ => new KeyValueGroup(RelativePath)
+ .Add("root", Root.ToString())
+ .Add("size", Size.ToString())
+ .Add("localtime", LocalTime.ToString())
+ .Add("time", Time.ToString())
+ .Add("remotetime", RemoteTime.ToString())
+ .Add("sha", Sha)
+ .Add("syncstate", SyncState.ToString())
+ .Add("persiststate", PersistState.ToString())
+ .Add("platformstosync2", PlatformsToSync2.ToString());
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/Models/RemoteCacheVdfFile.cs b/007SaveTool/VdfGenerator/Models/RemoteCacheVdfFile.cs
new file mode 100644
index 0000000..3512887
--- /dev/null
+++ b/007SaveTool/VdfGenerator/Models/RemoteCacheVdfFile.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using System.Text;
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator;
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
+using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
+
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.Models;
+
+public class RemoteCacheVdfFile(int appId)
+{
+ public const string FileName = "remotecache";
+ public const string FileExtension = ".vdf";
+
+ public int AppId { get; set; } = appId;
+ public int ChangeNumber { get; set; }
+ public int OsType { get; set; }
+ public List CachedFiles { get; set; } = [];
+
+ ///
+ /// Retrieves the application identifier from the specified file path.
+ ///
+ /// The file system path from which to extract the application identifier. Must contain a parent directory whose name is a valid integer.
+ /// The application identifier parsed from the parent directory name of the specified path.
+ /// Thrown if the parent directory name of the specified path is not a valid integer.
+ private static int GetAppIdFromPath(string path)
+ {
+ var parent = Path.GetFileName(Path.GetDirectoryName(path));
+ return int.TryParse(parent, out var result)
+ ? result
+ : throw new InvalidOperationException("Invalid AppId in path");
+ }
+
+ ///
+ /// Initializes a new instance of the class using the specified remote folder path and loads metadata for all files within the folder and its subdirectories.
+ ///
+ /// The full path to the remote folder containing the files to be cached. Must not be null or empty.
+ public RemoteCacheVdfFile(string remoteFolderPath) : this(GetAppIdFromPath(remoteFolderPath))
+ {
+ var files = Directory.GetFiles(remoteFolderPath, "*", SearchOption.AllDirectories);
+ foreach (var file in files)
+ CachedFiles.Add(new CachedFileMetadata(file, remoteFolderPath));
+ }
+
+ ///
+ /// Initializes a new instance of the class based on the provided key–value (KV) group, copying the relevant metadata.
+ ///
+ /// The KV group from which metadata and the list of cached files are read. Must not be null.
+ public RemoteCacheVdfFile(KeyValueGroup group) : this(group.Key)
+ {
+ foreach (var node in group.Nodes)
+ {
+ // If the node is a KvGroup, we create a new CachedFileMetadata object using the group and add it to the CachedFiles list.
+ if (node is KeyValueGroup fileGroup)
+ {
+ CachedFiles.Add(new CachedFileMetadata(fileGroup));
+ continue;
+ }
+ // If the node is not a KvGroup, we attempt to cast it to a KvPair to extract the key and value for the properties of RemoteCacheVdfFile.
+ var kvPair = node as KeyValue.Models.KeyValuePair;
+ switch (kvPair?.Key)
+ {
+ case "ChangeNumber":
+ ChangeNumber = NumberParser.ParseInt(kvPair.Value);
+ break;
+ case "OSType":
+ OsType = NumberParser.ParseInt(kvPair.Value);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Exports the current object and its cached files as a key-value group representation.
+ ///
+ /// A KvGroup containing the key-value pairs for the current object and its cached files.
+ public KeyValueGroup ExportAsKvGroup()
+ {
+ var kvGroup = new KeyValueGroup(AppId.ToString())
+ .Add("ChangeNumber", ChangeNumber.ToString())
+ .Add("OSType", OsType.ToString());
+
+ foreach (var cachedFile in CachedFiles)
+ kvGroup.Add(cachedFile.ExportAsKvGroup());
+
+ return kvGroup;
+ }
+
+ ///
+ /// Exports the current data to a file in the specified destination folder.
+ ///
+ /// The path to the folder where the exported file will be created. Must be a valid, writable directory.
+ public void ExportAsFile(string destinationFolder)
+ {
+ var kv = ExportAsKvGroup();
+ var serialized = KeyValueSerializer.Serialize(kv);
+ var outputFilePath = Path.Combine(destinationFolder, $"{FileName}{FileExtension}");
+ File.WriteAllText(outputFilePath, serialized, new UTF8Encoding());
+ }
+}
\ No newline at end of file
diff --git a/007SaveTool/VdfGenerator/NumberParser.cs b/007SaveTool/VdfGenerator/NumberParser.cs
new file mode 100644
index 0000000..f5b1f62
--- /dev/null
+++ b/007SaveTool/VdfGenerator/NumberParser.cs
@@ -0,0 +1,20 @@
+namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator;
+
+public static class NumberParser
+{
+ ///
+ /// Parses the specified string as a 32-bit signed integer value.
+ ///
+ /// The string representation of the number to parse.
+ /// The parsed 32-bit signed integer value if the parse operation succeeds; otherwise, 0.
+ public static int ParseInt(string s)
+ => int.TryParse(s, out var v) ? v : 0;
+
+ ///
+ /// Parses the specified string representation of a number to a 64-bit signed integer.
+ ///
+ /// The string containing the number to parse.
+ /// The parsed 64-bit signed integer value if parsing succeeds; otherwise, 0.
+ public static long ParseLong(string s)
+ => long.TryParse(s, out var v) ? v : 0;
+}
\ No newline at end of file
diff --git a/007SaveTool/icon.ico b/007SaveTool/icon.ico
new file mode 100644
index 0000000..406f265
Binary files /dev/null and b/007SaveTool/icon.ico differ
diff --git a/EonaCat.FirstLight.SaveTransfer.sln b/EonaCat.FirstLight.SaveTransfer.sln
new file mode 100644
index 0000000..2db9ef8
--- /dev/null
+++ b/EonaCat.FirstLight.SaveTransfer.sln
@@ -0,0 +1,21 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.5.11801.241
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.FirstLight.SaveTransfer", "007SaveTool\EonaCat.FirstLight.SaveTransfer.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|AnyCPU = Debug|AnyCPU
+ Release|AnyCPU = Release|AnyCPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|AnyCPU.ActiveCfg = Debug|AnyCPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|AnyCPU.Build.0 = Debug|AnyCPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|AnyCPU.ActiveCfg = Release|AnyCPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|AnyCPU.Build.0 = Release|AnyCPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 02231a6..50ec343 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
-# EonaCat.FirstLight.SaveTransfer
+# EonaCat.FirstLight.SaveTranfer
-EonaCat.FirstLight.SaveTransfer
\ No newline at end of file
+EonaCat.FirstLight.SaveTranfer
\ No newline at end of file