429 lines
15 KiB
C#
429 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
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;
|
|
}
|
|
|
|
PanelResign.Visibility = Visibility.Collapsed;
|
|
PanelDecrypt.Visibility = Visibility.Collapsed;
|
|
PanelLog.Visibility = Visibility.Collapsed;
|
|
|
|
if (tab == TabResign)
|
|
{
|
|
PanelResign.Visibility = Visibility.Visible;
|
|
}
|
|
|
|
if (tab == TabDecrypt)
|
|
{
|
|
PanelDecrypt.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 static string? BrowseForFolder()
|
|
{
|
|
var dlg = new Microsoft.Win32.OpenFolderDialog
|
|
{
|
|
Title = "Select your save folder (e.g. …\\userdata\\<AccountId>\\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<string>();
|
|
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<string> 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<string>();
|
|
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<string> 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 void AppendLog(IEnumerable<string> 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;
|
|
}
|