Skip to content

Commit 3e9caa5

Browse files
committed
Initial commit
0 parents  commit 3e9caa5

File tree

10 files changed

+306
-0
lines changed

10 files changed

+306
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__pycache__/
2+
*.pyc
3+
build/
4+
dist/
5+
*.egg-info/
6+
.vscode/

.pylintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[FORMAT]
2+
max-line-length=120

LICENSE

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
BSD 2-Clause License
2+
3+
Copyright (c) 2022, NeryK
4+
All rights reserved.
5+
6+
Redistribution and use in source and binary forms, with or without
7+
modification, are permitted provided that the following conditions are met:
8+
9+
1. Redistributions of source code must retain the above copyright notice, this
10+
list of conditions and the following disclaimer.
11+
12+
2. Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
16+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# snowrunner-uwp2steam
2+
3+
Tool to migrate the save data of the game Snowrunner from Microsoft Store / Xbox Game pass (UWP) to Steam.
4+
The migration is basically just renaming a number of files which have obfuscated names in UWP storage, and then making Steam aware of them.
5+
6+
## References
7+
8+
- https://steamcommunity.com/sharedfiles/filedetails/?id=2530914231
9+
- https://blog.s505.su/2021/08/how-to-transfer-snowrunner-game-saves.html
10+
- https://steamcommunity.com/app/1465360/discussions/0/4811511685077534336/#c3111403360718313694
11+
- (which is based on https://github.com/zarroboogs/p4g-saveconv/blob/master/remotecache.py)
12+
- https://github.com/goatfungus/NMSSaveEditor/issues/306#issue-679701707
13+
14+
## Usage
15+
16+
1. Have both Snowrunner UWP and Steam versions installed (ideally with the same DLC).
17+
2. Find Snowrunner UWP save location, somewhere like: `%LOCALAPPDATA%\Packages\FocusHomeInteractiveSA.SnowRunnerWindows10_4hny5m903y3g0\SystemAppData\wgs\<an identifier>\<another identifier>`.
18+
3. Make a copy of it to be safe, like `C:\Temp\<another identifier>`
19+
4. Disable cloud saves for Snowrunner in Steam.
20+
5. Restart Steam in offline mode.
21+
6. Run Snowrunner and start a new game. Ensure the game is saved.
22+
7. Run the tool on the copy of the UWP data and write Steam data to a temporary directory: `py -m sr_u2s -i "C:\Temp\<another identifier>" -o "C:\Temp\steam_snowrunner_save"`
23+
8. Find Steam saves location, somewhere like:`%ProgramFiles(x86)%\Steam\userdata\<your Steam id\`
24+
9. Copy the directory `1465360` from the Steam data output directory to the Steam saves location.
25+
10. Run Snowrunner. If all went according to plan, you have recovered your save.
26+
11. Re-enable cloud saves for the game and return online.

setup.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import setuptools
2+
import sr_u2s
3+
4+
def get_long_description():
5+
with open("README.md", "r") as readme_file:
6+
readme = readme_file.read()
7+
return readme[readme.find("# snowrunner-uwp2steam"):]
8+
9+
if __name__ == '__main__':
10+
setuptools.setup(
11+
name=sr_u2s.__name__,
12+
version=sr_u2s.__version__,
13+
author="NeryK",
14+
author_email="96932938+NeryK@users.noreply.github.com",
15+
description=sr_u2s.__doc__,
16+
long_description=get_long_description(),
17+
long_description_content_type="text/markdown",
18+
url="https://github.com/NeryK/snowrunner-uwp2steam",
19+
python_requires=">=3",
20+
packages=[sr_u2s.__package__],
21+
license="License :: OSI Approved :: BSD License",
22+
classifiers=[
23+
"Development Status :: 4 - Beta",
24+
"Programming Language :: Python :: 3",
25+
"License :: OSI Approved :: BSD License",
26+
"Operating System :: OS Independent",
27+
"Topic :: Games/Entertainment"
28+
]
29+
)

sr_u2s/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Migrate Snowrunner save from Windows Store (UWP) storage to Steam storage"""
2+
3+
__version__ = "0.1.0"
4+
__author__ = "NeryK"

sr_u2s/__main__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Module entry point"""
2+
import os
3+
import argparse
4+
import pathlib
5+
from sr_u2s import __doc__, __version__
6+
from sr_u2s import container, remotecache, transfer_save
7+
8+
9+
def locate_container(input_dir):
10+
save_path = pathlib.Path(input_dir)
11+
files = [f for f in save_path.glob("container.*") if f.is_file()]
12+
if len(files) != 1:
13+
raise FileNotFoundError(f"No single 'container.XYZ' file found in {input_dir}")
14+
return str(files[0])
15+
16+
def make_remotecache(output_subdir):
17+
save_path = pathlib.Path(output_subdir)
18+
remcache_path = pathlib.Path(os.path.join(save_path.parent, "remotecache.vdf"))
19+
remotecache.write_remcache(remcache_path, save_path)
20+
return str(remcache_path)
21+
22+
if __name__ == "__main__":
23+
parser = argparse.ArgumentParser(description=__doc__)
24+
parser.add_argument("-i", "--input-uwp-save-directory", help="Snowrunner Windows Store savegame directory", required=True)
25+
parser.add_argument("-o", "--output-steam-save-directory", help="Snowrunner Steam savegame directory", required=True)
26+
parser.add_argument("-v", "--version", action="version", version=__version__)
27+
args = parser.parse_args()
28+
29+
print("Snowrunner save UWP -> Steam start.")
30+
input_dir = args.input_uwp_save_directory
31+
if not os.path.exists(input_dir) or not os.path.isdir(input_dir):
32+
parser.error(f"Error accessing {input_dir}")
33+
container_path = locate_container(input_dir)
34+
save_list = container.load_container(locate_container(input_dir))
35+
print(f"Container file {container_path} loaded.")
36+
output_subdir = os.path.join(args.output_steam_save_directory, "1465360", "remote")
37+
os.makedirs(output_subdir, exist_ok=True)
38+
transfer_save.copy_rename_save(save_list, input_dir, output_subdir, False)
39+
print(f"{len(save_list)} files copied.")
40+
remcache_file = make_remotecache(output_subdir)
41+
print(f"Steam remote cache generated ({remcache_file}).")
42+
print("Snowrunner save UWP -> Steam end.")

sr_u2s/container.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Parse UWP container file"""
2+
import argparse
3+
import struct
4+
5+
def decode_hash(hash_bytes):
6+
new_hash_bytes = bytearray()
7+
new_hash_bytes.append(hash_bytes[3])
8+
new_hash_bytes.append(hash_bytes[2])
9+
new_hash_bytes.append(hash_bytes[1])
10+
new_hash_bytes.append(hash_bytes[0])
11+
new_hash_bytes.append(hash_bytes[5])
12+
new_hash_bytes.append(hash_bytes[4])
13+
new_hash_bytes.append(hash_bytes[7])
14+
new_hash_bytes.append(hash_bytes[6])
15+
return new_hash_bytes.hex().upper() + hash_bytes[8:].hex().upper()
16+
17+
def parse_file_bytes(bytes):
18+
filepath = bytes[:-32].decode("utf-16").strip("\x00")
19+
hash1 = decode_hash(bytes[-32:-16])
20+
hash2 = decode_hash(bytes[-16:])
21+
if hash1 != hash2:
22+
raise ValueError(f"Inconsistent hash for {filepath}")
23+
return filepath, hash1
24+
25+
def load_container(container_path):
26+
save_files = []
27+
with open(container_path, "rb") as containerfile:
28+
_header = struct.unpack("I", containerfile.read(4))[0]
29+
numfiles = struct.unpack("I", containerfile.read(4))[0]
30+
for i in range(numfiles):
31+
save_files.append(parse_file_bytes(containerfile.read(160)))
32+
return save_files
33+
34+
if __name__=="__main__":
35+
parser = argparse.ArgumentParser()
36+
parser.add_argument("--input-container", help="UWP container.xyz file", required=True)
37+
args = parser.parse_args()
38+
39+
save_list = load_container(args.input_container)
40+
print(save_list)

sr_u2s/remotecache.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Source: https://steamcommunity.com/app/1465360/discussions/0/4811511685077534336/#c3111403360718313694
2+
# Original : https://github.com/zarroboogs/p4g-saveconv/blob/master/remotecache.py
3+
4+
import os
5+
import math
6+
import hashlib
7+
import argparse
8+
from pathlib import Path
9+
10+
11+
def sha1sum( stream, start=0 ):
12+
sha1 = hashlib.sha1()
13+
stream.seek( start, 0 )
14+
15+
while True:
16+
data = stream.read( 1024 )
17+
if not data:
18+
break
19+
sha1.update( data )
20+
21+
stream.seek( 0, 0 )
22+
return sha1
23+
24+
25+
def vdf_write( vdf, level, key="", val=None ):
26+
pad = '\t' * level
27+
if key == None or key == "":
28+
vdf.write( f'{pad}' + "}\n" )
29+
elif val == None:
30+
vdf.write( f'{pad}"{key}"\n{pad}' + "{\n" )
31+
else:
32+
vdf.write( f'{pad}"{key}"\t\t"{val}"\n' )
33+
34+
35+
def write_remcache_file( vdf, filepath ):
36+
fstat = os.stat( filepath )
37+
fsize = fstat.st_size
38+
ftime = math.floor( fstat.st_mtime )
39+
40+
with open( filepath, "rb" ) as fs:
41+
fsha = sha1sum( fs ).hexdigest()
42+
43+
vdf_write( vdf, 1, filepath.name )
44+
vdf_write( vdf, 2, "root", 0 )
45+
vdf_write( vdf, 2, "size", fsize )
46+
vdf_write( vdf, 2, "localtime", ftime )
47+
vdf_write( vdf, 2, "time", ftime )
48+
vdf_write( vdf, 2, "remotetime", ftime )
49+
vdf_write( vdf, 2, "sha", fsha )
50+
vdf_write( vdf, 2, "syncstate", 4 )
51+
vdf_write( vdf, 2, "persiststate", 0 )
52+
vdf_write( vdf, 2, "platformstosync2", -1 )
53+
vdf_write( vdf, 1 )
54+
55+
56+
def write_remcache( remcache_path, data_path ):
57+
with open( remcache_path, "w", newline='\n' ) as vdf:
58+
vdf_write( vdf, 0, "1465360" )
59+
60+
for f in data_path.glob( "*" ):
61+
write_remcache_file( vdf, f )
62+
63+
# for f in data_path.glob( "system.bin" ):
64+
# write_remcache_file( vdf, f )
65+
# write_remcache_file( vdf, Path( f"{f}slot" ) )
66+
67+
# for f in data_path.glob( "data*.bin" ):
68+
# write_remcache_file( vdf, f )
69+
# write_remcache_file( vdf, Path( f"{f}slot" ) )
70+
71+
vdf_write( vdf, 0 )
72+
73+
74+
def main():
75+
parser = argparse.ArgumentParser()
76+
parser.add_argument( "save_dir", nargs=1, help="pc save dir" )
77+
args = parser.parse_args()
78+
79+
save_path = Path( args.save_dir[ 0 ] )
80+
81+
if not save_path.is_dir():
82+
raise Exception( "missing save dir or save dir doesn't exist" )
83+
84+
# files = [ f for f in save_path.glob( "data*.binslot" ) if f.is_file() ]
85+
# if len( files ) == 0:
86+
# raise Exception( "input dir doesn't contain pc saves" )
87+
files = [ f for f in save_path.glob( "*.cfg" ) if f.is_file() ]
88+
if len( files ) == 0:
89+
raise Exception( "input dir doesn't contain pc saves" )
90+
91+
print( "generating remotecache.vdf" )
92+
remcache_path = Path ( save_path.parent / "remotecache.vdf" )
93+
write_remcache( remcache_path, save_path )
94+
95+
print( "done!" )
96+
97+
if __name__ == "__main__":
98+
main()

sr_u2s/transfer_save.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Automate Snowrunner save file renaming described in https://blog.s505.su/2021/08/how-to-transfer-snowrunner-game-saves.html"""
2+
import argparse
3+
import struct
4+
import os
5+
import shutil
6+
from sr_u2s import container
7+
8+
9+
def copy_file(input_dir, input_name, output_dir, output_name, dry_run):
10+
input = os.path.join(input_dir, input_name)
11+
output = os.path.join(output_dir, f"{output_name}.cfg")
12+
print(f"Copy {input} to {output}")
13+
if not dry_run:
14+
shutil.copy2(input, output)
15+
16+
def copy_rename_save(save_list, input_dir, output_dir, dry_run):
17+
for decoded_filename, hashed_filename in save_list:
18+
copy_file(input_dir, hashed_filename, output_dir, decoded_filename, dry_run)
19+
20+
if __name__ == "__main__":
21+
parser = argparse.ArgumentParser(description="Rename Snowrunner save files from Windows Store to Steam convention")
22+
parser.add_argument("--input-container", help="UWP container.xyz file", required=True)
23+
parser.add_argument("--input-save-directory", help="Snowrunner Windows Store savegame directory", required=True)
24+
parser.add_argument("--output-save-directory", help="Snowrunner Steam savegame directory", required=True)
25+
parser.add_argument("--dry-run", action="store_true", default=False, help="Enable dry run, do not copy files")
26+
args = parser.parse_args()
27+
28+
save_list = container.load_container(args.input_container)
29+
#print(save_list)
30+
if not os.path.exists(args.input_save_directory) or not os.path.isdir(args.input_save_directory):
31+
parser.error(f"Error accessing {args.input_save_directory}")
32+
if not args.dry_run:
33+
os.makedirs(args.output_save_directory, exist_ok=True)
34+
copy_rename_save(save_list, args.input_save_directory, args.output_save_directory, args.dry_run)

0 commit comments

Comments
 (0)