├── .gitignore ├── LICENSE ├── README.md ├── json2sav.py └── sav2json.py /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !*/ 3 | 4 | !/.gitignore 5 | !/LICENSE 6 | !/README.md 7 | !/json2sav.py 8 | !/sav2json.py 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 bitowl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # satisfactory-save-format 2 | 3 | 4 | --- 5 | **IMPORTANT** 6 | This repository is currently not maintained. It was mainly created to reverse engineer the save files and is not very performant. You can find a maintained version written in TypeScript at [satisfactory-json](https://github.com/ficsit-felix/satisfactory-json). 7 | 8 | --- 9 | 10 | Repository containing the scripts I used to dissect the save files of Satisfactory. 11 | 12 | Contains two scripts to convert to a readable json format and back to a sav file. 13 | 14 | ``` 15 | usage: sav2json.py [-h] [--output OUTPUT] [--pretty] FILE 16 | 17 | Converts Satisfactory save games into a more readable format 18 | ``` 19 | 20 | ``` 21 | usage: json2sav.py [-h] [--output OUTPUT] FILE 22 | 23 | Converts from the more readable format back to a Satisfactory save game 24 | ``` 25 | 26 | ## Other useful repositories 27 | 28 | https://github.com/Vilsol/satisfactory-tool 29 | Save file to json converter written in Go 30 | 31 | https://github.com/bitowl/ficsit-felix 32 | Web app to visualize save files 33 | 34 | https://github.com/Goz3rr/SatisfactorySaveEditor 35 | Save file editor for Windows written in C# 36 | -------------------------------------------------------------------------------- /json2sav.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Converts from the more readable format (.json) back to a Satisfactory save game (.sav) 4 | """ 5 | 6 | import struct 7 | import json 8 | import argparse 9 | import pathlib 10 | import sys 11 | 12 | parser = argparse.ArgumentParser( 13 | description='Converts from the more readable format back to a Satisfactory save game') 14 | parser.add_argument('file', metavar='FILE', type=str, 15 | help='json file to process') 16 | parser.add_argument('--output', '-o', type=str, help='output file (.sav)') 17 | args = parser.parse_args() 18 | 19 | extension = pathlib.Path(args.file).suffix 20 | if extension == '.sav': 21 | print('error: extension of save file should be .json', file=sys.stderr) 22 | exit(1) 23 | 24 | f = open(args.file, 'r') 25 | saveJson = json.loads(f.read()) 26 | 27 | if args.output == None: 28 | output_file = pathlib.Path(args.file).stem + '.sav' 29 | else: 30 | output_file = args.output 31 | 32 | output = open(output_file, 'wb') 33 | 34 | 35 | buffers = [] 36 | 37 | 38 | def write(bytes, count=True): 39 | if len(buffers) == 0: 40 | output.write(bytes) 41 | else: 42 | buffers[len(buffers)-1]['buffer'].append(bytes) 43 | if count: 44 | buffers[len(buffers)-1]['length'] += len(bytes) 45 | 46 | 47 | def addBuffer(): 48 | """ 49 | pushes a new buffer to the stack, so that the length of the following content can be written before the content 50 | """ 51 | buffers.append({'buffer': [], 'length': 0}) 52 | 53 | 54 | def endBufferAndWriteSize(): 55 | """ 56 | ends the top buffer and writes it's context prefixed by the length (possibly into another buffer) 57 | """ 58 | buffer = buffers[len(buffers)-1] 59 | buffers.remove(buffer) 60 | # writeInt(26214) # TODO length 61 | writeInt(buffer['length']) 62 | for b in buffer['buffer']: 63 | write(b) 64 | return buffer['length'] 65 | 66 | 67 | def assertFail(message): 68 | print('failed: ' + message) 69 | input() 70 | assert False 71 | 72 | 73 | def writeInt(value, count=True): 74 | write(struct.pack('i', value), count) 75 | 76 | 77 | def writeFloat(value): 78 | write(struct.pack('f', value)) 79 | 80 | 81 | def writeLong(value): 82 | write(struct.pack('q', value)) 83 | 84 | 85 | def writeByte(value, count=True): 86 | write(struct.pack('b', value), count) 87 | 88 | # https://stackoverflow.com/a/18403812 89 | def isASCII(s): 90 | return len(s) == len(s.encode()) 91 | 92 | def writeLengthPrefixedString(value, count=True): 93 | if len(value) == 0: 94 | writeInt(0, count) 95 | return 96 | 97 | if isASCII(value): 98 | writeInt(len(value)+1, count) 99 | for i in value: 100 | write(struct.pack('b', ord(i)), count) 101 | write(b'\x00', count) 102 | else: 103 | writeInt(-len(value)-1, count) 104 | write(value.encode('utf-16'), count) 105 | write(b'\x00\x00', count) 106 | 107 | 108 | def writeHex(value, count=True): 109 | write(bytearray.fromhex(value), count) 110 | 111 | 112 | # Header 113 | writeInt(saveJson['saveHeaderType']) 114 | writeInt(saveJson['saveVersion']) 115 | writeInt(saveJson['buildVersion']) 116 | writeLengthPrefixedString(saveJson['mapName']) 117 | writeLengthPrefixedString(saveJson['mapOptions']) 118 | writeLengthPrefixedString(saveJson['sessionName']) 119 | writeInt(saveJson['playDurationSeconds']) 120 | writeLong(saveJson['saveDateTime']) 121 | writeByte(saveJson['sessionVisibility']) 122 | 123 | writeInt(len(saveJson['objects'])) 124 | 125 | 126 | def writeActor(obj): 127 | writeLengthPrefixedString(obj['className']) 128 | writeLengthPrefixedString(obj['levelName']) 129 | writeLengthPrefixedString(obj['pathName']) 130 | writeInt(obj['needTransform']) 131 | writeFloat(obj['transform']['rotation'][0]) 132 | writeFloat(obj['transform']['rotation'][1]) 133 | writeFloat(obj['transform']['rotation'][2]) 134 | writeFloat(obj['transform']['rotation'][3]) 135 | writeFloat(obj['transform']['translation'][0]) 136 | writeFloat(obj['transform']['translation'][1]) 137 | writeFloat(obj['transform']['translation'][2]) 138 | writeFloat(obj['transform']['scale3d'][0]) 139 | writeFloat(obj['transform']['scale3d'][1]) 140 | writeFloat(obj['transform']['scale3d'][2]) 141 | writeInt(obj['wasPlacedInLevel']) 142 | 143 | 144 | def writeObject(obj): 145 | writeLengthPrefixedString(obj['className']) 146 | writeLengthPrefixedString(obj['levelName']) 147 | writeLengthPrefixedString(obj['pathName']) 148 | writeLengthPrefixedString(obj['outerPathName']) 149 | 150 | 151 | for obj in saveJson['objects']: 152 | writeInt(obj['type']) 153 | if obj['type'] == 1: 154 | writeActor(obj) 155 | elif obj['type'] == 0: 156 | writeObject(obj) 157 | else: 158 | assertFail('unknown type ' + str(type)) 159 | 160 | writeInt(len(saveJson['objects'])) 161 | 162 | 163 | def writeProperty(property): 164 | writeLengthPrefixedString(property['name']) 165 | type = property['type'] 166 | writeLengthPrefixedString(type) 167 | addBuffer() 168 | writeInt(property['index'], count=False) 169 | if type == 'IntProperty': 170 | writeByte(0, count=False) 171 | writeInt(property['value']) 172 | elif type == 'BoolProperty': 173 | writeByte(property['value'], count=False) 174 | writeByte(0, count=False) 175 | elif type == 'FloatProperty': 176 | writeByte(0, count=False) 177 | writeFloat(property['value']) 178 | elif type == 'StrProperty': 179 | writeByte(0, count=False) 180 | writeLengthPrefixedString(property['value']) 181 | elif type == 'NameProperty': 182 | writeByte(0, count=False) 183 | writeLengthPrefixedString(property['value']) 184 | elif type == 'TextProperty': 185 | writeByte(0, count=False) 186 | writeInt(property['unknown1']) 187 | writeByte(property['unknown2']) 188 | writeInt(property['unknown3']) 189 | writeLengthPrefixedString(property['unknown4']) 190 | writeLengthPrefixedString(property['value']) 191 | elif type == 'ByteProperty': # TODO 192 | 193 | writeLengthPrefixedString(property['value']['unk1'], count=False) 194 | if property['value']['unk1'] == 'EGamePhase': 195 | writeByte(0, count=False) 196 | writeLengthPrefixedString(property['value']['unk2']) 197 | else: 198 | writeByte(0, count=False) 199 | writeByte(property['value']['unk2']) 200 | elif type == 'EnumProperty': 201 | writeLengthPrefixedString(property['value']['enum'], count=False) 202 | writeByte(0, count=False) 203 | writeLengthPrefixedString(property['value']['value']) 204 | elif type == 'ObjectProperty': 205 | writeByte(0, count=False) 206 | writeLengthPrefixedString(property['value']['levelName']) 207 | writeLengthPrefixedString(property['value']['pathName']) 208 | 209 | elif type == 'StructProperty': 210 | writeLengthPrefixedString(property['value']['type'], count=False) 211 | writeHex(property['structUnknown'], count=False) 212 | 213 | type = property['value']['type'] 214 | if type == 'Vector' or type == 'Rotator': 215 | writeFloat(property['value']['x']) 216 | writeFloat(property['value']['y']) 217 | writeFloat(property['value']['z']) 218 | elif type == 'Box': 219 | writeFloat(property['value']['min'][0]) 220 | writeFloat(property['value']['min'][1]) 221 | writeFloat(property['value']['min'][2]) 222 | writeFloat(property['value']['max'][0]) 223 | writeFloat(property['value']['max'][1]) 224 | writeFloat(property['value']['max'][2]) 225 | writeByte(property['value']['isValid']) 226 | elif type == 'LinearColor': 227 | writeFloat(property['value']['b']) 228 | writeFloat(property['value']['g']) 229 | writeFloat(property['value']['r']) 230 | writeFloat(property['value']['a']) 231 | elif type == 'Transform': 232 | for prop in property['value']['properties']: 233 | writeProperty(prop) 234 | writeNone() 235 | elif type == 'Quat': 236 | writeFloat(property['value']['a']) 237 | writeFloat(property['value']['b']) 238 | writeFloat(property['value']['c']) 239 | writeFloat(property['value']['d']) 240 | elif type == 'RemovedInstanceArray' or type == 'InventoryStack': 241 | for prop in property['value']['properties']: 242 | writeProperty(prop) 243 | writeNone() 244 | elif type == 'InventoryItem': 245 | writeLengthPrefixedString(property['value']['unk1'], count=False) 246 | writeLengthPrefixedString(property['value']['itemName']) 247 | writeLengthPrefixedString(property['value']['levelName']) 248 | writeLengthPrefixedString(property['value']['pathName']) 249 | oldval = buffers[len(buffers)-1]['length'] 250 | writeProperty(property['value']['properties'][0]) 251 | # Dirty hack to make in this one case the inner property only take up 4 bytes 252 | buffers[len(buffers)-1]['length'] = oldval + 4 253 | elif type == 'Color': 254 | writeHex(property['value']['r']) 255 | writeHex(property['value']['g']) 256 | writeHex(property['value']['b']) 257 | writeHex(property['value']['a']) 258 | elif type == 'RailroadTrackPosition': 259 | writeLengthPrefixedString(property['value']['levelName']); 260 | writeLengthPrefixedString(property['value']['pathName']); 261 | writeFloat(property['value']['offset']); 262 | writeFloat(property['value']['forward']); 263 | elif type == 'TimerHandle': 264 | writeLengthPrefixedString(property['value']['handle']); 265 | 266 | elif type == 'ArrayProperty': 267 | itemType = property['value']['type'] 268 | writeLengthPrefixedString(itemType, count=False) 269 | writeByte(0, count=False) 270 | writeInt(len(property['value']['values'])) 271 | if itemType == 'IntProperty': 272 | for obj in property['value']['values']: 273 | writeInt(obj) 274 | elif itemType == 'ByteProperty': 275 | for obj in property['value']['values']: 276 | writeByte(obj) 277 | elif itemType == 'ObjectProperty': 278 | for obj in property['value']['values']: 279 | writeLengthPrefixedString(obj['levelName']) 280 | writeLengthPrefixedString(obj['pathName']) 281 | elif itemType == 'StructProperty': 282 | writeLengthPrefixedString(property['structName']) 283 | writeLengthPrefixedString(property['structType']) 284 | addBuffer() 285 | writeInt(0, count=False) 286 | writeLengthPrefixedString(property['structInnerType'], count=False) 287 | writeHex(property['structUnknown'], count=False) 288 | for obj in property['value']['values']: 289 | for prop in obj['properties']: 290 | writeProperty(prop) 291 | writeNone() 292 | structLength = endBufferAndWriteSize() 293 | if (structLength != property['_structLength']): 294 | print('struct: ' + str(structLength) + 295 | '/' + str(property['_structLength'])) 296 | print(json.dumps(property, indent=4)) 297 | elif type == 'MapProperty': 298 | writeLengthPrefixedString(property['value']['name'], count=False) 299 | writeLengthPrefixedString(property['value']['type'], count=False) 300 | writeByte(0, count=False) 301 | writeInt(0) # for some reason this counts towards the length 302 | 303 | writeInt(len(property['value']['values'])) 304 | for key, value in property['value']['values'].items(): 305 | writeInt(int(key)) 306 | for prop in value: 307 | writeProperty(prop) 308 | writeNone() 309 | length = endBufferAndWriteSize() 310 | if (length != property['_length']): 311 | print(str(length) + '/' + str(property['_length'])) 312 | print(json.dumps(property, indent=4)) 313 | 314 | 315 | def writeNone(): 316 | writeLengthPrefixedString('None') 317 | 318 | 319 | def writeEntity(withNames, obj): 320 | addBuffer() # size will be written at this place later 321 | if withNames: 322 | writeLengthPrefixedString(obj['levelName']) 323 | writeLengthPrefixedString(obj['pathName']) 324 | writeInt(len(obj['children'])) 325 | for child in obj['children']: 326 | writeLengthPrefixedString(child['levelName']) 327 | writeLengthPrefixedString(child['pathName']) 328 | 329 | for property in obj['properties']: 330 | writeProperty(property) 331 | writeNone() 332 | 333 | writeHex(obj['missing']) 334 | endBufferAndWriteSize() 335 | 336 | 337 | for obj in saveJson['objects']: 338 | if obj['type'] == 1: 339 | writeEntity(True, obj['entity']) 340 | elif obj['type'] == 0: 341 | writeEntity(False, obj['entity']) 342 | 343 | 344 | writeInt(len(saveJson['collected'])) 345 | 346 | for collected in saveJson['collected']: 347 | writeLengthPrefixedString(collected['levelName']) 348 | writeLengthPrefixedString(collected['pathName']) 349 | 350 | writeHex(saveJson['missing']) 351 | -------------------------------------------------------------------------------- /sav2json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Converts Satisfactory save games (.sav) into a more readable format (.json) 4 | """ 5 | import struct 6 | import functools 7 | import itertools 8 | import csv 9 | import binascii 10 | import sys 11 | import json 12 | import argparse 13 | import pathlib 14 | 15 | parser = argparse.ArgumentParser( 16 | description='Converts Satisfactory save games into a more readable format') 17 | parser.add_argument('file', metavar='FILE', type=str, 18 | help='save game to process (.sav file extension)') 19 | parser.add_argument('--output', '-o', type=str, help='output file (.json)') 20 | parser.add_argument('--pretty', '-p', help='pretty print json', action='store_true') 21 | 22 | args = parser.parse_args() 23 | 24 | extension = pathlib.Path(args.file).suffix 25 | if extension != '.sav': 26 | print('error: extension of save file should be .sav', file=sys.stderr) 27 | exit(1) 28 | 29 | f = open(args.file, 'rb') 30 | 31 | # determine the file size so that we can 32 | f.seek(0, 2) 33 | fileSize = f.tell() 34 | f.seek(0, 0) 35 | 36 | bytesRead = 0 37 | 38 | 39 | def assertFail(message): 40 | print('assertion failed: ' + message, file=sys.stderr) 41 | # show the next bytes to help debugging 42 | print(readHex(32)) 43 | input() 44 | assert False 45 | 46 | 47 | def readInt(): 48 | global bytesRead 49 | bytesRead += 4 50 | return struct.unpack('i', f.read(4))[0] 51 | 52 | 53 | def readFloat(): 54 | global bytesRead 55 | bytesRead += 4 56 | return struct.unpack('f', f.read(4))[0] 57 | 58 | 59 | def readLong(): 60 | global bytesRead 61 | bytesRead += 8 62 | return struct.unpack('q', f.read(8))[0] 63 | 64 | 65 | def readByte(): 66 | global bytesRead 67 | bytesRead += 1 68 | return struct.unpack('b', f.read(1))[0] 69 | 70 | 71 | def assertNullByte(): 72 | global bytesRead 73 | bytesRead += 1 74 | zero = f.read(1) 75 | if zero != b'\x00': 76 | assertFail('not null but ' + str(zero)) 77 | 78 | 79 | def readLengthPrefixedString(): 80 | """ 81 | Reads a string that is prefixed with its length 82 | """ 83 | global bytesRead 84 | length = readInt() 85 | if length == 0: 86 | return '' 87 | 88 | if length < 0: 89 | # Read UTF-16 90 | length = length * -2 91 | 92 | chars = f.read(length-2) 93 | 94 | zero = f.read(2) 95 | bytesRead += length 96 | 97 | if zero != b'\x00\x00': # We assume that the last byte of a string is alway \x00\x00 98 | if length > 100: 99 | assertFail('zero is ' + str(zero) + ' in ' + str(chars[0:100])) 100 | else: 101 | assertFail('zero is ' + str(zero) + ' in ' + str(chars)) 102 | return chars.decode('utf-16') 103 | 104 | # Read ASCII 105 | 106 | chars = f.read(length-1) 107 | 108 | zero = f.read(1) 109 | bytesRead += length 110 | 111 | if zero != b'\x00': # We assume that the last byte of a string is alway \x00 112 | if length > 100: 113 | assertFail('zero is ' + str(zero) + ' in ' + str(chars[0:100])) 114 | else: 115 | assertFail('zero is ' + str(zero) + ' in ' + str(chars)) 116 | return chars.decode('ascii') 117 | 118 | def readHex(count): 119 | """ 120 | Reads count bytes and returns their hex form 121 | """ 122 | global bytesRead 123 | bytesRead += count 124 | 125 | chars = f.read(count) 126 | c = 0 127 | result = '' 128 | for i in chars: 129 | result += format(i, '02x') + ' ' 130 | c += 1 131 | if (c % 4 == 0 and c < count - 1): 132 | result += ' ' 133 | 134 | return result 135 | 136 | 137 | # Read the file header 138 | saveHeaderType = readInt() 139 | saveVersion = readInt() # Save Version 140 | buildVersion = readInt() # BuildVersion 141 | 142 | mapName = readLengthPrefixedString() # MapName 143 | mapOptions = readLengthPrefixedString() # MapOptions 144 | sessionName = readLengthPrefixedString() # SessionName 145 | playDurationSeconds = readInt() # PlayDurationSeconds 146 | 147 | saveDateTime = readLong() # SaveDateTime 148 | ''' 149 | to convert this FDateTime to a unix timestamp use: 150 | saveDateSeconds = saveDateTime / 10000000 151 | # see https://stackoverflow.com/a/1628018 152 | print(saveDateSeconds-62135596800) 153 | ''' 154 | sessionVisibility = readByte() # SessionVisibility 155 | 156 | entryCount = readInt() # total entries 157 | saveJson = { 158 | 'saveHeaderType': saveHeaderType, 159 | 'saveVersion': saveVersion, 160 | 'buildVersion': buildVersion, 161 | 'mapName': mapName, 162 | 'mapOptions': mapOptions, 163 | 'sessionName': sessionName, 164 | 'playDurationSeconds': playDurationSeconds, 165 | 'saveDateTime': saveDateTime, 166 | 'sessionVisibility': sessionVisibility, 167 | 'objects': [], 168 | 'collected': [] 169 | } 170 | 171 | 172 | def readActor(): 173 | className = readLengthPrefixedString() 174 | levelName = readLengthPrefixedString() 175 | pathName = readLengthPrefixedString() 176 | needTransform = readInt() 177 | 178 | a = readFloat() 179 | b = readFloat() 180 | c = readFloat() 181 | d = readFloat() 182 | x = readFloat() 183 | y = readFloat() 184 | z = readFloat() 185 | sx = readFloat() 186 | sy = readFloat() 187 | sz = readFloat() 188 | 189 | wasPlacedInLevel = readInt() 190 | 191 | return { 192 | 'type': 1, 193 | 'className': className, 194 | 'levelName': levelName, 195 | 'pathName': pathName, 196 | 'needTransform': needTransform, 197 | 'transform': { 198 | 'rotation': [a, b, c, d], 199 | 'translation': [x, y, z], 200 | 'scale3d': [sx, sy, sz], 201 | 202 | }, 203 | 'wasPlacedInLevel': wasPlacedInLevel 204 | } 205 | 206 | 207 | def readObject(): 208 | className = readLengthPrefixedString() 209 | levelName = readLengthPrefixedString() 210 | pathName = readLengthPrefixedString() 211 | outerPathName = readLengthPrefixedString() 212 | 213 | return { 214 | 'type': 0, 215 | 'className': className, 216 | 'levelName': levelName, 217 | 'pathName': pathName, 218 | 'outerPathName': outerPathName 219 | } 220 | 221 | 222 | for i in range(0, entryCount): 223 | type = readInt() 224 | if type == 1: 225 | saveJson['objects'].append(readActor()) 226 | elif type == 0: 227 | saveJson['objects'].append(readObject()) 228 | else: 229 | assertFail('unknown type ' + str(type)) 230 | 231 | 232 | elementCount = readInt() 233 | 234 | # So far these counts have always been the same and the entities seem to belong 1 to 1 to the actors/objects read above 235 | if elementCount != entryCount: 236 | assertFail('elementCount ('+str(elementCount) + 237 | ') != entryCount('+str(entryCount)+')') 238 | 239 | 240 | def readProperty(properties): 241 | name = readLengthPrefixedString() 242 | if name == 'None': 243 | return 244 | 245 | prop = readLengthPrefixedString() 246 | length = readInt() 247 | index = readInt() 248 | 249 | property = { 250 | 'name': name, 251 | 'type': prop, 252 | '_length': length, 253 | 'index': index 254 | } 255 | 256 | if prop == 'IntProperty': 257 | assertNullByte() 258 | property['value'] = readInt() 259 | 260 | elif prop == 'StrProperty': 261 | assertNullByte() 262 | property['value'] = readLengthPrefixedString() 263 | 264 | elif prop == 'StructProperty': 265 | type = readLengthPrefixedString() 266 | 267 | property['structUnknown'] = readHex(17) # TODO 268 | 269 | if type == 'Vector' or type == 'Rotator': 270 | x = readFloat() 271 | y = readFloat() 272 | z = readFloat() 273 | property['value'] = { 274 | 'type': type, 275 | 'x': x, 276 | 'y': y, 277 | 'z': z 278 | } 279 | 280 | elif type == 'Box': 281 | minX = readFloat() 282 | minY = readFloat() 283 | minZ = readFloat() 284 | maxX = readFloat() 285 | maxY = readFloat() 286 | maxZ = readFloat() 287 | isValid = readByte() 288 | property['value'] = { 289 | 'type': type, 290 | 'min': [minX, minY, minZ], 291 | 'max': [maxX, maxY, maxZ], 292 | 'isValid': isValid 293 | } 294 | elif type == 'LinearColor': 295 | b = readFloat() 296 | g = readFloat() 297 | r = readFloat() 298 | a = readFloat() 299 | property['value'] = { 300 | 'type': type, 301 | 'b': b, 302 | 'g': g, 303 | 'r': r, 304 | 'a': a 305 | } 306 | elif type == 'Transform': 307 | props = [] 308 | while (readProperty(props)): 309 | pass 310 | property['value'] = { 311 | 'type': type, 312 | 'properties': props 313 | } 314 | 315 | elif type == 'Quat': 316 | a = readFloat() 317 | b = readFloat() 318 | c = readFloat() 319 | d = readFloat() 320 | property['value'] = { 321 | 'type': type, 322 | 'a': a, 323 | 'b': b, 324 | 'c': c, 325 | 'd': d 326 | } 327 | 328 | elif type == 'RemovedInstanceArray' or type == 'InventoryStack': 329 | props = [] 330 | while (readProperty(props)): 331 | pass 332 | property['value'] = { 333 | 'type': type, 334 | 'properties': props 335 | } 336 | elif type == 'InventoryItem': 337 | unk1 = readLengthPrefixedString() # TODO 338 | itemName = readLengthPrefixedString() 339 | levelName = readLengthPrefixedString() 340 | pathName = readLengthPrefixedString() 341 | 342 | props = [] 343 | readProperty(props) 344 | # can't consume null here because it is needed by the entaingling struct 345 | 346 | property['value'] = { 347 | 'type': type, 348 | 'unk1': unk1, 349 | 'itemName': itemName, 350 | 'levelName': levelName, 351 | 'pathName': pathName, 352 | 'properties': props 353 | } 354 | elif type == 'Color': 355 | a = readHex(1) 356 | b = readHex(1) 357 | c = readHex(1) 358 | d = readHex(1) 359 | property['value'] = { 360 | 'type': type, 361 | 'r': a, 362 | 'g': b, 363 | 'b': c, 364 | 'a': d 365 | } 366 | elif type == 'RailroadTrackPosition': 367 | levelName = readLengthPrefixedString() 368 | pathName = readLengthPrefixedString() 369 | offset = readFloat() 370 | forward = readFloat() 371 | 372 | property['value'] = { 373 | 'type': type, 374 | 'levelName': levelName, 375 | 'pathName': pathName, 376 | 'offset': offset, 377 | 'forward': forward 378 | } 379 | elif type == 'TimerHandle': 380 | property['value'] = { 381 | 'type': type, 382 | 'handle': readLengthPrefixedString() 383 | } 384 | else: 385 | print(property) 386 | assertFail('Unknown type: ' + type) 387 | 388 | elif prop == 'ArrayProperty': 389 | itemType = readLengthPrefixedString() 390 | assertNullByte() 391 | count = readInt() 392 | values = [] 393 | 394 | if itemType == 'ObjectProperty': 395 | for j in range(0, count): 396 | values.append({ 397 | 'levelName': readLengthPrefixedString(), 398 | 'pathName': readLengthPrefixedString() 399 | }) 400 | elif itemType == 'StructProperty': 401 | structName = readLengthPrefixedString() 402 | structType = readLengthPrefixedString() 403 | structSize = readInt() 404 | zero = readInt() 405 | if zero != 0: 406 | assertFail('not zero: ' + str(zero)) 407 | 408 | type = readLengthPrefixedString() 409 | 410 | property['structName'] = structName 411 | property['structType'] = structType 412 | property['structInnerType'] = type 413 | 414 | property['structUnknown'] = readHex(17) # TODO what are those? 415 | property['_structLength'] = structSize 416 | for i in range(0, count): 417 | props = [] 418 | while (readProperty(props)): 419 | pass 420 | values.append({ 421 | 'properties': props 422 | }) 423 | 424 | elif itemType == 'IntProperty': 425 | for i in range(0, count): 426 | values.append(readInt()) 427 | 428 | elif itemType == 'ByteProperty': 429 | for i in range(0, count): 430 | values.append(readByte()) 431 | 432 | else: 433 | assertFail('unknown itemType ' + itemType + ' in name ' + name) 434 | 435 | property['value'] = { 436 | 'type': itemType, 437 | 'values': values 438 | } 439 | elif prop == 'ObjectProperty': 440 | assertNullByte() 441 | property['value'] = { 442 | 'levelName': readLengthPrefixedString(), 443 | 'pathName': readLengthPrefixedString() 444 | } 445 | elif prop == 'BoolProperty': 446 | property['value'] = readByte() 447 | assertNullByte() 448 | elif prop == 'FloatProperty': # TimeStamps that are FloatProperties are negative to the current time in seconds? 449 | assertNullByte() 450 | property['value'] = readFloat() 451 | elif prop == 'EnumProperty': 452 | enumName = readLengthPrefixedString() 453 | assertNullByte() 454 | valueName = readLengthPrefixedString() 455 | property['value'] = { 456 | 'enum': enumName, 457 | 'value': valueName, 458 | } 459 | elif prop == 'NameProperty': 460 | assertNullByte() 461 | property['value'] = readLengthPrefixedString() 462 | elif prop == 'MapProperty': 463 | name = readLengthPrefixedString() 464 | valueType = readLengthPrefixedString() 465 | for i in range(0, 5): 466 | assertNullByte() 467 | count = readInt() 468 | values = { 469 | } 470 | for i in range(0, count): 471 | key = readInt() 472 | props = [] 473 | while readProperty(props): 474 | pass 475 | values[key] = props 476 | 477 | property['value'] = { 478 | 'name': name, 479 | 'type': valueType, 480 | 'values': values 481 | } 482 | elif prop == 'ByteProperty': # TODO 483 | 484 | unk1 = readLengthPrefixedString() # TODO 485 | if unk1 == 'None': 486 | assertNullByte() 487 | property['value'] = { 488 | 'unk1': unk1, 489 | 'unk2': readByte() 490 | } 491 | else: 492 | assertNullByte() 493 | unk2 = readLengthPrefixedString() # TODO 494 | property['value'] = { 495 | 'unk1': unk1, 496 | 'unk2': unk2 497 | } 498 | 499 | elif prop == 'TextProperty': 500 | assertNullByte() 501 | property['unknown1'] = readInt() 502 | property['unknown2'] = readByte() 503 | property['unknown3'] = readInt() 504 | property['unknown4'] = readLengthPrefixedString() 505 | property['value'] = readLengthPrefixedString() 506 | else: 507 | assertFail('Unknown property type: ' + prop) 508 | 509 | properties.append(property) 510 | return True 511 | 512 | 513 | def readEntity(withNames, length): 514 | global bytesRead 515 | bytesRead = 0 516 | 517 | entity = {} 518 | 519 | if withNames: 520 | entity['levelName'] = readLengthPrefixedString() 521 | entity['pathName'] = readLengthPrefixedString() 522 | entity['children'] = [] 523 | 524 | childCount = readInt() 525 | if childCount > 0: 526 | for i in range(0, childCount): 527 | levelName = readLengthPrefixedString() 528 | pathName = readLengthPrefixedString() 529 | entity['children'].append({ 530 | 'levelName': levelName, 531 | 'pathName': pathName 532 | }) 533 | entity['properties'] = [] 534 | while (readProperty(entity['properties'])): 535 | pass 536 | 537 | # read missing bytes at the end of this entity. 538 | # maybe we missed something while parsing the properties? 539 | missing = length - bytesRead 540 | if missing > 0: 541 | entity['missing'] = readHex(missing) 542 | elif missing < 0: 543 | assertFail('negative missing amount: ' + str(missing)) 544 | 545 | return entity 546 | 547 | 548 | for i in range(0, elementCount): 549 | length = readInt() # length of this entry 550 | if saveJson['objects'][i]['type'] == 1: 551 | saveJson['objects'][i]['entity'] = readEntity(True, length) 552 | else: 553 | saveJson['objects'][i]['entity'] = readEntity(False, length) 554 | 555 | 556 | collectedCount = readInt() 557 | 558 | for i in range(0, collectedCount): 559 | levelName = readLengthPrefixedString() 560 | pathName = readLengthPrefixedString() 561 | saveJson['collected'].append({'levelName': levelName, 'pathName': pathName}) 562 | 563 | # store the remaining bytes as well so that we can recreate the exact same save file 564 | saveJson['missing'] = readHex(fileSize - f.tell()) 565 | 566 | 567 | if args.output == None: 568 | output_file = pathlib.Path(args.file).stem + '.json' 569 | else: 570 | output_file = args.output 571 | output = open(output_file, 'w') 572 | if args.pretty == True: 573 | output.write(json.dumps(saveJson, indent=4)) 574 | else: 575 | output.write(json.dumps(saveJson)) 576 | output.close() 577 | print('converted savegame saved to ' + output_file) 578 | --------------------------------------------------------------------------------