Initial version
This commit is contained in:
198
007SaveTool/App.xaml
Normal file
198
007SaveTool/App.xaml
Normal file
@@ -0,0 +1,198 @@
|
||||
<Application x:Class="EonaCat.FirstLight.SaveTransfer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<!-- ── Colour Palette ─────────────────────────────────────────── -->
|
||||
<Color x:Key="BgDark">#1A1A2E</Color>
|
||||
<Color x:Key="BgMid">#16213E</Color>
|
||||
<Color x:Key="BgCard">#0F3460</Color>
|
||||
<Color x:Key="AccentGold">#D4AF37</Color>
|
||||
<Color x:Key="AccentGoldHover">#F0C840</Color>
|
||||
<Color x:Key="TextPrimary">#E8E8E8</Color>
|
||||
<Color x:Key="TextSecondary">#A0A8B8</Color>
|
||||
<Color x:Key="TextMuted">#6A7280</Color>
|
||||
<Color x:Key="Success">#22C55E</Color>
|
||||
<Color x:Key="Error">#EF4444</Color>
|
||||
<Color x:Key="Warning">#F59E0B</Color>
|
||||
<Color x:Key="Info">#3B82F6</Color>
|
||||
<Color x:Key="BorderColor">#1E3A5F</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BgDarkBrush" Color="{StaticResource BgDark}"/>
|
||||
<SolidColorBrush x:Key="BgMidBrush" Color="{StaticResource BgMid}"/>
|
||||
<SolidColorBrush x:Key="BgCardBrush" Color="{StaticResource BgCard}"/>
|
||||
<SolidColorBrush x:Key="AccentGoldBrush" Color="{StaticResource AccentGold}"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimary}"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondary}"/>
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="{StaticResource TextMuted}"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource Success}"/>
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource Error}"/>
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource Warning}"/>
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="{StaticResource Info}"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
|
||||
|
||||
<!-- ── TextBox Style ─────────────────────────────────────────── -->
|
||||
<Style x:Key="DarkTextBox" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="#0D1B2A"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontFamily" Value="Consolas"/>
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="6">
|
||||
<ScrollViewer x:Name="PART_ContentHost"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsFocused" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentGoldBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ── Primary Gold Button ───────────────────────────────────── -->
|
||||
<Style x:Key="GoldButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="Foreground" Value="#1A1A2E"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Padding" Value="24,12"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background"
|
||||
Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.85"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.7"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.4"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ── Secondary/Outline Button ─────────────────────────────── -->
|
||||
<Style x:Key="OutlineButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="16,10"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#1A3050"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.4"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ── Tab Control Styles ────────────────────────────────────── -->
|
||||
<Style x:Key="DarkTabControl" TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DarkTabItem" TargetType="TabItem">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Padding" Value="20,10"/>
|
||||
<Setter Property="BorderThickness" Value="0,0,0,2"/>
|
||||
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter x:Name="ContentSite"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
ContentSource="Header"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush"
|
||||
Value="{StaticResource AccentGoldBrush}"/>
|
||||
<Setter Property="Foreground"
|
||||
Value="{StaticResource AccentGoldBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground"
|
||||
Value="{StaticResource TextPrimaryBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ── ScrollBar Style ───────────────────────────────────────── -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Width" Value="6"/>
|
||||
</Style>
|
||||
|
||||
<!-- ── Label Style ───────────────────────────────────────────── -->
|
||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
</Style>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
5
007SaveTool/App.xaml.cs
Normal file
5
007SaveTool/App.xaml.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace EonaCat.FirstLight.SaveTransfer;
|
||||
|
||||
public partial class App : Application { }
|
||||
17
007SaveTool/EonaCat.FirstLight.SaveTransfer.csproj
Normal file
17
007SaveTool/EonaCat.FirstLight.SaveTransfer.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>EonaCat.FirstLight.SaveTransfer</AssemblyName>
|
||||
<RootNamespace>EonaCat.FirstLight.SaveTransfer</RootNamespace>
|
||||
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||
<Optimize>true</Optimize>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
502
007SaveTool/MainWindow.xaml
Normal file
502
007SaveTool/MainWindow.xaml
Normal file
@@ -0,0 +1,502 @@
|
||||
<Window x:Class="EonaCat.FirstLight.SaveTransfer.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:EonaCat.FirstLight.SaveTransfer"
|
||||
Title="EonaCat FirstLight SaveTransfer"
|
||||
Height="760" Width="920"
|
||||
MinHeight="680" MinWidth="820"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgDarkBrush}"
|
||||
FontFamily="Segoe UI">
|
||||
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolVis"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- header -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- tabs bar -->
|
||||
<RowDefinition Height="*"/>
|
||||
<!-- content -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- status bar -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
|
||||
<Border Grid.Row="0" Background="{StaticResource BgMidBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,0,0,1">
|
||||
<Grid Margin="28,18,28,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- Bond logo / bullet icon -->
|
||||
<Border Width="36" Height="36" CornerRadius="18"
|
||||
Background="{StaticResource AccentGoldBrush}"
|
||||
Margin="0,0,14,0">
|
||||
<TextBlock Text="007" FontWeight="Bold" FontSize="11"
|
||||
Foreground="#1A1A2E"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="EonaCat FirstLight SaveTransfer"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock Text="Resign · Decrypt · Inspect"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
FontSize="11" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Background="#0D2035" CornerRadius="6" Padding="10,5" Margin="0,0,10,0">
|
||||
<TextBlock x:Name="TxtStatus" Text="Ready"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="12" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1" Background="{StaticResource BgMidBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,0,0,1">
|
||||
<TabControl x:Name="MainTabs" Style="{StaticResource DarkTabControl}"
|
||||
Background="Transparent" Margin="14,0,14,0">
|
||||
<TabControl.ContentTemplate>
|
||||
<DataTemplate><!-- tabs only used for header switching --></DataTemplate>
|
||||
</TabControl.ContentTemplate>
|
||||
|
||||
<TabItem Header="📖 Instructions - Steam" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabInstructionsSteam"/>
|
||||
<TabItem Header="📖 Instructions - Cracked" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabInstructionsCracked"/>
|
||||
<TabItem Header="⚙ Resign Save" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabResign"/>
|
||||
<TabItem Header="🔓 Decrypt / Inspect" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabDecrypt"/>
|
||||
<TabItem Header="📦 VDF Generator" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabVdf"/>
|
||||
<TabItem Header="📋 Log" Style="{StaticResource DarkTabItem}"
|
||||
x:Name="TabLog"/>
|
||||
</TabControl>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
|
||||
<!-- INSTRUCTIONS - STEAM PANEL -->
|
||||
<ScrollViewer x:Name="PanelInstructionsSteam" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="32,28,32,28">
|
||||
<TextBlock Text="007: First Light - Save Game Transfer Instructions (Steam)"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontSize="16" FontWeight="Bold" Margin="0,0,0,20"/>
|
||||
|
||||
<TextBlock Text="STEPS TO RESIGN YOUR SAVE FILES"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
|
||||
<StackPanel Margin="0,0,0,20">
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">1.</Run> Select your folder using the 'Browse' button with your NEW savegame (folder which contains index.save and data.save)
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">2.</Run> Insert your steam Id which you want to set.
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">3.</Run> Click on 'Resign Save Files'
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,20">
|
||||
<Run FontWeight="Bold">4.</Run> Place the savegame files in your original save directory.
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="STEAM SAVEGAME FILE LOCATION"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<Border Background="#0D1E30" CornerRadius="8" Padding="12" Margin="0,0,0,20"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"
|
||||
Text="C:\Program Files (x86)\Steam\userdata\<userID>\3768760\remote\KntSlotSaveFile-x"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11" Margin="0,0,0,20">
|
||||
(x is your save slot number)
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="YOUR STEAM ID"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<Border Background="#0D1E30" CornerRadius="8" Padding="12" Margin="0,0,0,20"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
|
||||
FontFamily="Consolas"
|
||||
Text="76561197960272671"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="EXTRA STEPS NEEDED FOR STEAM VERSION"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,12">
|
||||
Please note if using the steam version, you also need to do these extra steps:
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Margin="0,0,0,20">
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">1.</Run> Click on the VDF Generator tab
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">2.</Run> Browse to your remote folder
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,20">
|
||||
<Run FontWeight="Bold">3.</Run> Click on 'Generate VDF'
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#1A3A52" CornerRadius="8" Padding="16,12"
|
||||
BorderBrush="{StaticResource AccentGoldBrush}" BorderThickness="1">
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" TextWrapping="Wrap">
|
||||
<Run FontWeight="Bold">Now startup your game, the saves should show</Run>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- INSTRUCTIONS - CRACKED PANEL -->
|
||||
<ScrollViewer x:Name="PanelInstructionsCracked" Visibility="Collapsed"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="32,28,32,28">
|
||||
<TextBlock Text="007: First Light - Save Game Transfer Instructions (Cracked)"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontSize="16" FontWeight="Bold" Margin="0,0,0,20"/>
|
||||
|
||||
<TextBlock Text="STEPS TO RESIGN YOUR SAVE FILES"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
|
||||
<StackPanel Margin="0,0,0,20">
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">1.</Run> Select your folder using the 'Browse' button with your NEW savegame (folder which contains index.save and data.save)
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">2.</Run> Insert your steam Id which you want to set.
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
<Run FontWeight="Bold">3.</Run> Click on 'Resign Save Files'
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,20">
|
||||
<Run FontWeight="Bold">4.</Run> Place the savegame files in your original save directory.
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="CRACKED VERSION SAVE FILE LOCATION"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<Border Background="#0D1E30" CornerRadius="8" Padding="12" Margin="0,0,0,20"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"
|
||||
Text="C:\Users\[username]\AppData\Roaming\GSE Saves\3768760\remote\KntSlotSaveFile-0"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="YOUR STEAM ID"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<Border Background="#0D1E30" CornerRadius="8" Padding="12" Margin="0,0,0,20"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock Foreground="{StaticResource TextSecondaryBrush}" FontSize="12"
|
||||
FontFamily="Consolas"
|
||||
Text="76561197960272671"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="EXTRA STEPS NEEDED FOR CRACKED VERSION"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,12">
|
||||
Please note that if using the cracked version you would possibly also need the userdata.zip
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Margin="0,0,0,20">
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8" TextWrapping="Wrap">
|
||||
Extract the contents of the zip in <Run FontFamily="Consolas">[YOUR 007 FIRST LIGHT INSTALLATION FOLDER]\Retail</Run>
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
Overwrite the existing steam_settings and userdata.
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#3A2A1A" CornerRadius="8" Padding="16,12"
|
||||
BorderBrush="#CC6600" BorderThickness="1" Margin="0,0,0,20">
|
||||
<StackPanel>
|
||||
<TextBlock Foreground="#FF9933" FontSize="13" FontWeight="Bold" Margin="0,0,0,6">
|
||||
⚠ IMPORTANT WARNING
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="12" TextWrapping="Wrap">
|
||||
CREATE A BACKUP FIRST OF THE FOLDERS YOU'RE OVERWRITING AS YOU COULD GET INVALID TOKEN ERRORS IF DONE INCORRECTLY!
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11" Margin="0,0,0,20">
|
||||
Please note that the steam_settings and the userdata folders are from the v1.0.1 version of the game.
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Foreground="{StaticResource TextPrimaryBrush}" FontSize="13" Margin="0,0,0,8">
|
||||
See
|
||||
<Hyperlink NavigateUri="https://www.nexusmods.com/007firstlight/mods/44"
|
||||
RequestNavigate="Hyperlink_RequestNavigate"
|
||||
Foreground="{StaticResource AccentGoldBrush}">
|
||||
nexusmods.com/007firstlight/mods/44
|
||||
</Hyperlink>
|
||||
for the userdata.zip
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<ScrollViewer x:Name="PanelResign" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="32,28,32,28">
|
||||
|
||||
<!-- Save Folder -->
|
||||
<TextBlock Text="SAVE FOLDER" Style="{StaticResource FieldLabel}"/>
|
||||
<Grid Margin="0,0,0,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="TxtResignFolder" Style="{StaticResource DarkTextBox}"
|
||||
IsReadOnly="True" Grid.Column="0"
|
||||
Text="Click Browse to select your save folder…"
|
||||
Foreground="{StaticResource TextMutedBrush}"/>
|
||||
<Button Grid.Column="1" Content="Browse…"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
Margin="10,0,0,0" Click="BrowseResignFolder_Click"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Target SteamID64 -->
|
||||
<TextBlock Text="TARGET STEAMID64 (the account you want to resign TO)"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<TextBox x:Name="TxtTargetSID" Style="{StaticResource DarkTextBox}"
|
||||
Margin="0,0,0,6"
|
||||
ToolTip="Your SteamID64 - find it at steamid.io"
|
||||
PreviewTextInput="NumericOnly_PreviewInput"/>
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
Margin="0,0,0,18">
|
||||
Find your SteamID64 at
|
||||
<Hyperlink NavigateUri="https://steamid.io"
|
||||
RequestNavigate="Hyperlink_RequestNavigate"
|
||||
Foreground="{StaticResource AccentGoldBrush}">
|
||||
steamid.io
|
||||
</Hyperlink>
|
||||
- it looks like 76561197960272671
|
||||
</TextBlock>
|
||||
|
||||
<!-- Source SteamID64 (optional) -->
|
||||
<TextBlock Text="SOURCE STEAMID64 (optional - leave blank to auto-detect)"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<TextBox x:Name="TxtSourceSID" Style="{StaticResource DarkTextBox}"
|
||||
Margin="0,0,0,6"
|
||||
ToolTip="Only needed if auto-detect fails. The original account's SteamID64."
|
||||
PreviewTextInput="NumericOnly_PreviewInput"/>
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
Margin="0,0,0,24">
|
||||
When left blank the tool will auto-detect from <Run FontFamily="Consolas">index.save</Run>
|
||||
and bruteforce the <Run FontFamily="Consolas">data.save</Run> key.
|
||||
</TextBlock>
|
||||
|
||||
<!-- Mismatch option -->
|
||||
<Border Background="#0D1E30" CornerRadius="8" Padding="16,12"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
|
||||
Margin="0,0,0,24">
|
||||
<StackPanel>
|
||||
<CheckBox x:Name="ChkAutoConfirm"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
FontSize="13" IsChecked="False">
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
Auto-confirm SteamID mismatches between
|
||||
<Run FontFamily="Consolas">index.save</Run> and
|
||||
<Run FontFamily="Consolas">data.save</Run>
|
||||
</TextBlock>
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Action Button -->
|
||||
<Button x:Name="BtnResign" Content="🔑 Resign Save Files"
|
||||
Style="{StaticResource GoldButton}"
|
||||
HorizontalAlignment="Stretch" Height="48"
|
||||
Click="BtnResign_Click"/>
|
||||
|
||||
<!-- Progress -->
|
||||
<ProgressBar x:Name="ResignProgress" Height="4" Margin="0,14,0,0"
|
||||
Background="#0D1E30" Foreground="{StaticResource AccentGoldBrush}"
|
||||
BorderThickness="0" Visibility="Collapsed" IsIndeterminate="True"/>
|
||||
|
||||
<!-- Inline result banner -->
|
||||
<Border x:Name="ResignResultBanner" Visibility="Collapsed"
|
||||
CornerRadius="8" Padding="16,12" Margin="0,14,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="ResignResultText"
|
||||
Foreground="White" FontSize="13"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- DECRYPT PANEL -->
|
||||
<ScrollViewer x:Name="PanelDecrypt" Visibility="Collapsed"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="32,28,32,28">
|
||||
|
||||
<!-- Save Folder -->
|
||||
<TextBlock Text="SAVE FOLDER" Style="{StaticResource FieldLabel}"/>
|
||||
<Grid Margin="0,0,0,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="TxtDecryptFolder" Style="{StaticResource DarkTextBox}"
|
||||
IsReadOnly="True" Grid.Column="0"
|
||||
Text="Click Browse to select your save folder…"
|
||||
Foreground="{StaticResource TextMutedBrush}"/>
|
||||
<Button Grid.Column="1" Content="Browse…"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
Margin="10,0,0,0" Click="BrowseDecryptFolder_Click"/>
|
||||
</Grid>
|
||||
|
||||
<!-- SteamID64 -->
|
||||
<TextBlock Text="STEAMID64 (optional - leave blank to auto-detect)"
|
||||
Style="{StaticResource FieldLabel}"/>
|
||||
<TextBox x:Name="TxtDecryptSID" Style="{StaticResource DarkTextBox}"
|
||||
Margin="0,0,0,6"
|
||||
PreviewTextInput="NumericOnly_PreviewInput"/>
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
Margin="0,0,0,24">
|
||||
Leave blank to auto-detect from <Run FontFamily="Consolas">index.save</Run>.
|
||||
Output files will be saved next to the originals with a <Run FontFamily="Consolas">.decrypted</Run> extension.
|
||||
</TextBlock>
|
||||
|
||||
<!-- Action Button -->
|
||||
<Button x:Name="BtnDecrypt" Content="🔓 Decrypt Save Files"
|
||||
Style="{StaticResource GoldButton}"
|
||||
HorizontalAlignment="Stretch" Height="48"
|
||||
Click="BtnDecrypt_Click"/>
|
||||
|
||||
<!-- Progress -->
|
||||
<ProgressBar x:Name="DecryptProgress" Height="4" Margin="0,14,0,0"
|
||||
Background="#0D1E30" Foreground="{StaticResource AccentGoldBrush}"
|
||||
BorderThickness="0" Visibility="Collapsed" IsIndeterminate="True"/>
|
||||
|
||||
<!-- Inline result -->
|
||||
<Border x:Name="DecryptResultBanner" Visibility="Collapsed"
|
||||
CornerRadius="8" Padding="16,12" Margin="0,14,0,0">
|
||||
<TextBlock x:Name="DecryptResultText" Foreground="White"
|
||||
FontSize="13" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- LOG PANEL -->
|
||||
<Grid x:Name="PanelLog" Visibility="Collapsed" Margin="32,28,32,28">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="OPERATION LOG"
|
||||
Style="{StaticResource FieldLabel}" Margin="0,0,0,10"/>
|
||||
|
||||
<Border Grid.Row="1" Background="#080F18" CornerRadius="8"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
Padding="4">
|
||||
<TextBlock x:Name="TxtLog"
|
||||
FontFamily="Consolas" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Padding="12" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Button Grid.Row="2" Content="🗑 Clear Log"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
HorizontalAlignment="Right" Margin="0,10,0,0"
|
||||
Click="ClearLog_Click"/>
|
||||
</Grid>
|
||||
|
||||
<!-- VDF GENERATOR PANEL -->
|
||||
<ScrollViewer x:Name="PanelVdf" Visibility="Collapsed"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="32,28,32,28">
|
||||
|
||||
<!-- Remote Folder -->
|
||||
<TextBlock Text="REMOTE FOLDER" Style="{StaticResource FieldLabel}"/>
|
||||
<Grid Margin="0,0,0,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="TxtVdfRemoteFolder" Style="{StaticResource DarkTextBox}"
|
||||
IsReadOnly="True" Grid.Column="0"
|
||||
Text="Click Browse to select the 'remote' folder…"
|
||||
Foreground="{StaticResource TextMutedBrush}"/>
|
||||
<Button Grid.Column="1" Content="Browse…"
|
||||
Style="{StaticResource OutlineButton}"
|
||||
Margin="10,0,0,0" Click="BrowseVdfRemoteFolder_Click"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Description -->
|
||||
<TextBlock Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
Margin="0,0,0,24">
|
||||
Select the <Run FontFamily="Consolas">remote</Run> folder from your Steam save directory
|
||||
(e.g., <Run FontFamily="Consolas">…\userdata\<AccountId>\3768760\remote</Run>)
|
||||
to generate the <Run FontFamily="Consolas">remotecache.vdf</Run> file.
|
||||
</TextBlock>
|
||||
|
||||
<!-- Action Button -->
|
||||
<Button x:Name="BtnGenerateVdf" Content="📦 Generate VDF"
|
||||
Style="{StaticResource GoldButton}"
|
||||
HorizontalAlignment="Stretch" Height="48"
|
||||
Click="BtnGenerateVdf_Click"/>
|
||||
|
||||
<!-- Progress -->
|
||||
<ProgressBar x:Name="VdfProgress" Height="4" Margin="0,14,0,0"
|
||||
Background="#0D1E30" Foreground="{StaticResource AccentGoldBrush}"
|
||||
BorderThickness="0" Visibility="Collapsed" IsIndeterminate="True"/>
|
||||
|
||||
<!-- Inline result banner -->
|
||||
<Border x:Name="VdfResultBanner" Visibility="Collapsed"
|
||||
CornerRadius="8" Padding="16,12" Margin="0,14,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="VdfResultText"
|
||||
Foreground="White" FontSize="13"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
<Border Grid.Row="3" Background="{StaticResource BgMidBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,1,0,0"
|
||||
Padding="28,8">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="TxtStatusBar"
|
||||
Text="Click the 'Resign Save' tab and select a folder and enter your Target SteamID64 to get started."
|
||||
Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="EonaCat.FirstLight.SaveTransfer"
|
||||
Foreground="{StaticResource TextMutedBrush}" FontSize="11"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
541
007SaveTool/MainWindow.xaml.cs
Normal file
541
007SaveTool/MainWindow.xaml.cs
Normal file
@@ -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\\<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 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>();
|
||||
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<string> 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<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;
|
||||
}
|
||||
399
007SaveTool/SaveCore.cs
Normal file
399
007SaveTool/SaveCore.cs
Normal file
@@ -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;
|
||||
|
||||
/// <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 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
|
||||
|
||||
public interface IKeyValueNode
|
||||
{
|
||||
string Key { get; }
|
||||
}
|
||||
55
007SaveTool/VdfGenerator/KeyValue/KeyValueDeserializer.cs
Normal file
55
007SaveTool/VdfGenerator/KeyValue/KeyValueDeserializer.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
|
||||
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
|
||||
|
||||
/// <summary>
|
||||
/// Provides static methods for deserializing text representations of key-value groups into KvGroup objects.
|
||||
/// </summary>
|
||||
public static class KeyValueDeserializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the specified text into a KvGroup object.
|
||||
/// </summary>
|
||||
/// <param name="text">The input string containing the serialized representation of a KvGroup. Cannot be null.</param>
|
||||
/// <returns>A KvGroup object that represents the data contained in the input text.</returns>
|
||||
public static KeyValueGroup Deserialize(string text)
|
||||
{
|
||||
var tokenizer = new KeyValueTokenizer(text);
|
||||
return ParseGroup(tokenizer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a group from the provided tokenizer, including any nested groups or key-value pairs.
|
||||
/// </summary>
|
||||
/// <param name="t">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.</param>
|
||||
/// <param name="k">The optional name of the group to parse. If null, the group name is read from the tokenizer.</param>
|
||||
/// <returns>A KvGroup representing the parsed group, including all nested groups and key-value pairs.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
53
007SaveTool/VdfGenerator/KeyValue/KeyValueSerializer.cs
Normal file
53
007SaveTool/VdfGenerator/KeyValue/KeyValueSerializer.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
|
||||
|
||||
/// <summary>
|
||||
/// Provides functionality to serialize key–value groups into a text-based format.
|
||||
/// </summary>
|
||||
public class KeyValueSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes the given key–value group into a text format.
|
||||
/// </summary>
|
||||
/// <param name="group">The key–value group to serialize. Cannot be null.</param>
|
||||
/// <returns>A string representing the serialized form of the key–value group.</returns>
|
||||
public static string Serialize(KeyValueGroup group)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
SerializeGroup(group, sb, 0);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the specified key-value group and its child elements into a formatted string representation, appending the result to the provided StringBuilder.
|
||||
/// </summary>
|
||||
/// <param name="group">The key-value group to serialize. Cannot be null.</param>
|
||||
/// <param name="sb">The StringBuilder to which the serialized output is appended. Cannot be null.</param>
|
||||
/// <param name="indent">The indentation level to apply to the serialized output.</param>
|
||||
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");
|
||||
}
|
||||
}
|
||||
81
007SaveTool/VdfGenerator/KeyValue/KeyValueTokenizer.cs
Normal file
81
007SaveTool/VdfGenerator/KeyValue/KeyValueTokenizer.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue;
|
||||
|
||||
/// <summary>
|
||||
/// Provides functionality for tokenizing a key-value formatted string input, allowing sequential reading and validation of symbols and quoted strings.
|
||||
/// </summary>
|
||||
/// <param name="text">The input string to tokenize. Must not be null.</param>
|
||||
public class KeyValueTokenizer(string text)
|
||||
{
|
||||
private int _pos;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the next non-whitespace character in the input matches the specified character without advancing the current position.
|
||||
/// </summary>
|
||||
/// <param name="c">The character to compare with the next non-whitespace character in the input.</param>
|
||||
/// <returns><see langword="true"/> if the next non-whitespace character matches the specified character; otherwise, <see langword="false"/>.</returns>
|
||||
public bool PeekSymbol(char c)
|
||||
{
|
||||
SkipWhitespace();
|
||||
return _pos < text.Length && text[_pos] == c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next non-whitespace character from the input and verifies that it matches the specified symbol.
|
||||
/// </summary>
|
||||
/// <param name="c">The character to match at the current position in the input.</param>
|
||||
/// <exception cref="Exception">Thrown if the next non-whitespace character does not match the specified symbol.</exception>
|
||||
public void ReadSymbol(char c)
|
||||
{
|
||||
SkipWhitespace();
|
||||
if (_pos >= text.Length || text[_pos] != c)
|
||||
throw new Exception($"Expected '{c}' at position {_pos}");
|
||||
|
||||
_pos++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a string enclosed in quotation marks from the current position in the input text.
|
||||
/// </summary>
|
||||
/// <returns> The string located between the opening and closing quotation marks or an empty string if there are no characters between the quotes. </returns>
|
||||
/// <exception cref="Exception"> Thrown when the current position does not contain an opening quotation mark or when a closing quotation mark cannot be found. </exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the current position past any consecutive whitespace characters in the input text.
|
||||
/// </summary>
|
||||
private void SkipWhitespace()
|
||||
{
|
||||
while (_pos < text.Length)
|
||||
{
|
||||
var c = text[_pos];
|
||||
|
||||
if (c is ' ' or '\t' or '\n' or '\r')
|
||||
{
|
||||
_pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
007SaveTool/VdfGenerator/KeyValue/Models/KeyValueGroup.cs
Normal file
36
007SaveTool/VdfGenerator/KeyValue/Models/KeyValueGroup.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
|
||||
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a hierarchical group of key-value nodes, allowing organization of nested key-value pairs and groups.
|
||||
/// </summary>
|
||||
/// <param name="key">The key that identifies this group. Cannot be null.</param>
|
||||
public class KeyValueGroup(string key) : IKeyValueNode
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public List<IKeyValueNode> Nodes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new key-value pair to the group.
|
||||
/// </summary>
|
||||
/// <param name="key">The key to associate with the value. Cannot be null.</param>
|
||||
/// <param name="value">The value to associate with the key. Cannot be null.</param>
|
||||
/// <returns>The current instance with the new key-value pair added.</returns>
|
||||
public KeyValueGroup Add(string key, string value)
|
||||
{
|
||||
Nodes.Add(new KeyValuePair(key, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified group to the collection of nodes.
|
||||
/// </summary>
|
||||
/// <param name="group">The group to add to the collection. Cannot be null.</param>
|
||||
/// <returns>The current instance with the added group, enabling method chaining.</returns>
|
||||
public KeyValueGroup Add(KeyValueGroup group)
|
||||
{
|
||||
Nodes.Add(group);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
14
007SaveTool/VdfGenerator/KeyValue/Models/KeyValuePair.cs
Normal file
14
007SaveTool/VdfGenerator/KeyValue/Models/KeyValuePair.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Interfaces;
|
||||
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator.KeyValue.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a key-value pair node with immutable key and value properties.
|
||||
/// </summary>
|
||||
/// <param name="key">The key associated with the pair. If not specified, an empty string is used.</param>
|
||||
/// <param name="value">The value associated with the pair. If not specified, an empty string is used.</param>
|
||||
public class KeyValuePair(string key = "", string value = "") : IKeyValueNode
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public string Value { get; } = value;
|
||||
}
|
||||
193
007SaveTool/VdfGenerator/Models/CachedFile.cs
Normal file
193
007SaveTool/VdfGenerator/Models/CachedFile.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file metadata cached for synchronization or tracking purposes. Provides properties for file path, size, timestamps, hash, and synchronization state.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">The relative path of the file within the root directory. Used to identify and locate the file in the cache.</param>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current time as the number of seconds that have elapsed since the Unix epoch (January 1, 1970, 00:00:00 UTC).
|
||||
/// </summary>
|
||||
private static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-1 hash of the specified byte span and returns its hexadecimal string representation.
|
||||
/// </summary>
|
||||
/// <param name="data">The input data to hash as a read-only span of bytes.</param>
|
||||
/// <returns>A lowercase hexadecimal string representing the SHA-1 hash of the input data.</returns>
|
||||
private static string Sha1FromSpan(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[20]; // SHA‑1 = 20 bytes
|
||||
SHA1.HashData(data, hash);
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the relative path from the specified root directory to the given file path, using forward slashes as directory separators.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The absolute path to the target file. Cannot be null.</param>
|
||||
/// <param name="rootPath">The absolute path to the root directory from which to calculate the relative path. Cannot be null.</param>
|
||||
/// <returns>A relative path from the root directory to the file, using forward slashes ('/') as directory separators.</returns>
|
||||
private static string GetRelativePath(string filePath, string rootPath)
|
||||
=> Path.GetRelativePath(rootPath, filePath).Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CachedFileMetadata"/> class using the specified file path and root directory. Loads the file's data and metadata into the cache.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The full path to the file to be cached. Must refer to an existing file.</param>
|
||||
/// <param name="rootPath">The root directory path used to compute the relative path for the cached file.</param>
|
||||
/// <exception cref="FileNotFoundException">Thrown if the file specified by filePath does not exist.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CachedFileMetadata"/> class using data from the provided key–value group.
|
||||
/// </summary>
|
||||
/// <param name="group">A key–value group containing the file data used to initialize the object's properties.</param>
|
||||
public CachedFileMetadata(KeyValueGroup group) : this(group.Key)
|
||||
{
|
||||
foreach (var node in group.Nodes.Cast<KeyValue.Models.KeyValuePair?>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the local time property to the current system time.
|
||||
/// </summary>
|
||||
public void SetLocalTimeToNow() => LocalTime = Now;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current time to the system's current date and time.
|
||||
/// </summary>
|
||||
public void SetTimeToNow() => Time = Now;
|
||||
|
||||
/// <summary>
|
||||
/// Sets both the local time and the time properties to the current value of the system clock.
|
||||
/// </summary>
|
||||
public void SetLocalTimeAndTimeToNow()
|
||||
{
|
||||
var epoch = Now;
|
||||
LocalTime = epoch;
|
||||
Time = epoch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the local time using the specified timestamp.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The point in time, expressed as a DateTimeOffset, to set as the local time. The value is converted to Unix time in seconds.</param>
|
||||
public void SetLocalTime(DateTimeOffset timestamp)
|
||||
=> LocalTime = timestamp.ToUnixTimeSeconds();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current time value using the specified timestamp.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The point in time to set, represented as a DateTimeOffset. The value is converted to Unix time in seconds.</param>
|
||||
public void SetTime(DateTimeOffset timestamp)
|
||||
=> Time = timestamp.ToUnixTimeSeconds();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the local time and time properties using the specified timestamp.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The date and time value to use, including the offset from Coordinated Universal Time (UTC).</param>
|
||||
public void SetLocalTimeAndTime(DateTimeOffset timestamp)
|
||||
{
|
||||
LocalTime = timestamp.ToUnixTimeSeconds();
|
||||
Time = timestamp.ToUnixTimeSeconds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local date and time represented by the current Unix timestamp value.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="DateTimeOffset"/> that represents the local date and time corresponding to the stored Unix time in seconds.</returns>
|
||||
public DateTimeOffset GetLocalDateTime()
|
||||
=> DateTimeOffset.FromUnixTimeSeconds(LocalTime);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the date and time represented by the current Unix timestamp value.
|
||||
/// </summary>
|
||||
/// <returns>A DateTimeOffset value corresponding to the Unix timestamp stored in the current instance.</returns>
|
||||
public DateTimeOffset GetDateTime()
|
||||
=> DateTimeOffset.FromUnixTimeSeconds(Time);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current remote date and time as a DateTimeOffset value.
|
||||
/// </summary>
|
||||
/// <returns>A DateTimeOffset representing the remote time, converted from the stored Unix timestamp.</returns>
|
||||
public DateTimeOffset GetRemoteDateTime()
|
||||
=> DateTimeOffset.FromUnixTimeSeconds(RemoteTime);
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current object as a key–value group containing essential information about its state and properties.
|
||||
/// </summary>
|
||||
/// <returns> A <see cref="KeyValueGroup"/> instance populated with core data such as the relative path, size, timestamps, SHA checksum, and the current synchronization and persistence states.</returns>
|
||||
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());
|
||||
}
|
||||
99
007SaveTool/VdfGenerator/Models/RemoteCacheVdfFile.cs
Normal file
99
007SaveTool/VdfGenerator/Models/RemoteCacheVdfFile.cs
Normal file
@@ -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<CachedFileMetadata> CachedFiles { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the application identifier from the specified file path.
|
||||
/// </summary>
|
||||
/// <param name="path">The file system path from which to extract the application identifier. Must contain a parent directory whose name is a valid integer.</param>
|
||||
/// <returns>The application identifier parsed from the parent directory name of the specified path.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the parent directory name of the specified path is not a valid integer.</exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RemoteCacheVdfFile"/> class using the specified remote folder path and loads metadata for all files within the folder and its subdirectories.
|
||||
/// </summary>
|
||||
/// <param name="remoteFolderPath">The full path to the remote folder containing the files to be cached. Must not be null or empty.</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RemoteCacheVdfFile"/> class based on the provided key–value (KV) group, copying the relevant metadata.
|
||||
/// </summary>
|
||||
/// <param name="group">The KV group from which metadata and the list of cached files are read. Must not be null.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current object and its cached files as a key-value group representation.
|
||||
/// </summary>
|
||||
/// <returns>A KvGroup containing the key-value pairs for the current object and its cached files.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current data to a file in the specified destination folder.
|
||||
/// </summary>
|
||||
/// <param name="destinationFolder">The path to the folder where the exported file will be created. Must be a valid, writable directory.</param>
|
||||
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());
|
||||
}
|
||||
}
|
||||
20
007SaveTool/VdfGenerator/NumberParser.cs
Normal file
20
007SaveTool/VdfGenerator/NumberParser.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace EonaCat.FirstLight.SaveTransfer.VdfGenerator;
|
||||
|
||||
public static class NumberParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the specified string as a 32-bit signed integer value.
|
||||
/// </summary>
|
||||
/// <param name="s">The string representation of the number to parse.</param>
|
||||
/// <returns>The parsed 32-bit signed integer value if the parse operation succeeds; otherwise, 0.</returns>
|
||||
public static int ParseInt(string s)
|
||||
=> int.TryParse(s, out var v) ? v : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the specified string representation of a number to a 64-bit signed integer.
|
||||
/// </summary>
|
||||
/// <param name="s">The string containing the number to parse.</param>
|
||||
/// <returns>The parsed 64-bit signed integer value if parsing succeeds; otherwise, 0.</returns>
|
||||
public static long ParseLong(string s)
|
||||
=> long.TryParse(s, out var v) ? v : 0;
|
||||
}
|
||||
BIN
007SaveTool/icon.ico
Normal file
BIN
007SaveTool/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
21
EonaCat.FirstLight.SaveTransfer.sln
Normal file
21
EonaCat.FirstLight.SaveTransfer.sln
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user