├── WINDOWS-HOWTO.txt ├── README.md └── savefile.py /WINDOWS-HOWTO.txt: -------------------------------------------------------------------------------- 1 | Firstly, if you want to modify a save from your Xbox you must use a program 2 | like Horizon or Modio to extract the SaveGame.sav file from within the 3 | container files you see on the USB drive. If you're looking at a file with a 4 | name like "Save0001.sav" then you haven't done this yet (renaming will not 5 | help), and it won't be possible to use this program until you do so. See the 6 | documentation for those programs for assistance with this step. 7 | 8 | Secondly, it's not possible to read or write PS3 saves with this program. 9 | Borderlands 2 uses an optional extra layer of security provided by the console 10 | to further encrypt the save files, and at the time of writing there doesn't 11 | appear to be a known way around this. I can't estimate when, or if, this might 12 | change. 13 | 14 | Instructions for using under Windows follow. They were written for Windows XP 15 | but the process should be similar for later versions. 16 | 17 | Go to http://www.python.org/download/ and download either "Python 2.7.3 Windows 18 | Installer" or, if you're running 64-bit Windows, "Python 2.7.3 Windows X86-64 19 | Installer". Version 3 or later of Python will not work here. 20 | 21 | Run the .msi file and accept the defaults so that it installs Python under 22 | C:\Python27\ 23 | 24 | Create a new C:\bl2 directory, ie a folder called "bl2" sitting next to the 25 | "Python27" directory that was just created by the installer. 26 | 27 | Put savefile.py inside your new C:\bl2 directory along with the SaveGame.sav 28 | file you want to modify/convert/view/whatever. 29 | 30 | Click the Windows Start button down the bottom left and click "Run...". In the 31 | window that appears enter "cmd" in the "Open:" textbox and click OK. 32 | 33 | In the black window that appears enter "c:", press return, then enter "cd \bl2" 34 | and press return. 35 | 36 | Read the instructions in the README.md file to find out what you can change, 37 | and how. Then take the command or commands you want to use and in place of 38 | "python" type "c:\python27\python", eg: 39 | 40 | c:\python27\python savefile.py -m "" SaveGameFromAPC.sav SaveGameForAnXbox.sav 41 | 42 | Note that the two .sav filenames given to the command above are the filenames 43 | to read the save file from and to write the results to respectively. If you 44 | copied a file called SaveGame.sav into the C:\bl2 directory and want the 45 | results in a file called NewSaveGame.sav you should change the last two parts 46 | of the command: 47 | 48 | c:\python27\python savefile.py -m "" SaveFile.sav NewSaveFile.sav 49 | 50 | For an Xbox you'll then need to insert this new .sav file back into the save 51 | file container, using the same program (likely Horizon or Modio) that you used 52 | to extract the original. Again, see the documentation for those programs for 53 | assistance with this step. 54 | 55 | And, as mentioned in the README.md file, if you want the modified save to work 56 | on a PC rather than an Xbox you need to add --little-endian to the command, eg: 57 | 58 | c:\python27\python savefile.py --little-endian -m "" SaveGameFromAnXbox.sav SaveGameForAPC.sav 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Read and write Borderlands 2 save files 2 | 3 | A simple command line utility to extract player information from a Borderlands 4 | 2 save file, or to create a new save file from player information. 5 | 6 | Note the following before trying to use it: 7 | 8 | * It has no graphical interface and is not easy to use 9 | * It does not provide any mechanisms for creating items or weapons 10 | * It is a proof of concept and will corrupt your save files if used improperly 11 | * It requires a working Python 2 interpreter (2.6 or later, not 3) 12 | 13 | ## How do I modify values in a save file? 14 | 15 | Modify save file data by changing one or more of "level", "skillpoints", 16 | "money", "eridium", "seraph", "tokens", "gunslots", "backpack", "bank", 17 | "unlocks", or "itemlevels": 18 | 19 | python savefile.py -m eridium=99 old.sav new.sav 20 | 21 | Set the levels of all your items and weapons (except those at level 1, which 22 | are left alone) to match your character's level: 23 | 24 | python savefile.py -m itemlevels old.sav new.sav 25 | 26 | Or to a specific level: 27 | 28 | python savefile.py -m itemlevels=20 old.sav new.sav 29 | 30 | Set the number of guns your character can have equipped to 2, 3, or 4: 31 | 32 | python savefile.py -m gunslots=4 old.sav new.sav 33 | 34 | Set the size of your character's backpack, and the corresponding number of 35 | purchased backpack SDUs: 36 | 37 | python savefile.py -m backpack=27 old.sav new.sav 38 | 39 | Set the size of your character's bank, and the corresponding number of 40 | purchased bank SDUs: 41 | 42 | python savefile.py -m bank=16 old.sav new.sav 43 | 44 | Unlock the Creature Slaughter Dome (Natural Selection Annex): 45 | 46 | python savefile.py -m unlocks=slaughterdome old.sav new.sav 47 | 48 | Unlock the True Vault Hunter mode: 49 | 50 | python savefile.py -m unlocks=truevaulthunter old.sav new.sav 51 | 52 | Unlock both at once: 53 | 54 | python savefile.py -m unlocks=slaughterdome:truevaulthunter old.sav new.sav 55 | 56 | Or many changes at once, separated by commas: 57 | 58 | python savefile.py -m level=7,skillpoints=42,money=1234,eridium=12,seraph=120,itemlevels old.sav new.sav 59 | 60 | Add --little-endian to write the save file in a format that should be readable 61 | by the PC version (the default is to write the data in big-endian format, for 62 | the console versions): 63 | 64 | python savefile.py -m eridium=99 --little-endian old.sav new.sav 65 | 66 | ## How do I convert a PC save to work on a console? 67 | 68 | A PC save file is automatically detected and read, and the default is to write 69 | in the correct format for a console. If you don't want to make any changes 70 | except to the format: 71 | 72 | python savefile.py -m "" pc.sav console.sav 73 | 74 | ## How do I convert a console save to work on a PC? 75 | 76 | As before, add --little-endian to the command to write the data in a format 77 | suitable for the PC. If you don't want to make any changes except to the 78 | format: 79 | 80 | python savefile.py -m "" --little-endian console.sav pc.sav 81 | 82 | ## How do I take a copy of all my character's items? 83 | 84 | All items stored and held in the character's bank or inventory can be exported 85 | to a text file as a list of codes, in a format compatible with Gibbed's save 86 | editor: 87 | 88 | python savefile.py -e items.txt your-save-game.sav 89 | 90 | ## How do I import those items back into a character? 91 | 92 | A text file of codes generated as above, or assembled by hand, can be imported 93 | into a character like so: 94 | 95 | python savefile.py -i items.txt old.sav new.sav 96 | 97 | (Don't forget to add --little-endian if you're creating a save file for the PC 98 | version.) 99 | 100 | By default all items will be inserted into the inventory, but this can be 101 | changed with a line containing "; Bank" to indicate that all following items 102 | should go into the bank, or one of either "; Weapons" or "; Items" to indicate 103 | that all following items should go into the inventory. For example, importing 104 | a file containing the following will put a Vault Hunter's Relic into the 105 | inventory and a Righteous Infinity pistol into the bank: 106 | 107 | ; Bank 108 | BL2(h0Hd1Z+jY/s2Qy++Zu8Ba9qXoOmjwJ6NhrlsOmhNMX+oJo5CfQns) 109 | ; Items 110 | BL2(B2vuv4tz1zSQCf2pqLJCS5XD/tKN4FXpjRJLnn1v85U=) 111 | 112 | ## How do I just extract the player data? 113 | 114 | Extract the raw protocol buffer data from a save file: 115 | 116 | python savefile.py -d your-save-game.sav player.p 117 | 118 | Extract the data in JSON format (encoded purely to preserve all the raw 119 | information -- not very readable): 120 | 121 | python savefile.py -d -j your-save-game.sav player.json 122 | 123 | Extract the data in JSON format, applying further parsing to make the data as 124 | readable as possible: 125 | 126 | python savefile.py -d -j -p your-save-game.sav player.json 127 | 128 | It may help to copy and paste the contents of the .json file into a site like 129 | http://www.jsoneditoronline.org/ in order to view or modify the contents, to 130 | ensure that the necessary JSON formatting is preserved. 131 | 132 | Note that if you modify any of the data you extract in this way there is a very 133 | high probability that you will corrupt your save file. Please make sure you 134 | have a backup first. 135 | 136 | ## How do I write the player data back to a new save file? 137 | 138 | Create a new save file from protocol buffer data: 139 | 140 | python savefile.py player.p your-new-save-game.sav 141 | 142 | Create a new save file from the JSON data: 143 | 144 | python savefile.py -j player.json your-new-save-game.sav 145 | 146 | As before, to write a save file that can be read by the PC version add the 147 | --little-endian flag to one of the above, eg: 148 | 149 | python savefile.py -j --little-endian player.json your-new-save-game.sav 150 | -------------------------------------------------------------------------------- /savefile.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import binascii 4 | from bisect import insort 5 | from cStringIO import StringIO 6 | import hashlib 7 | import json 8 | import math 9 | import optparse 10 | import random 11 | import struct 12 | import sys 13 | 14 | 15 | class BL2Error(Exception): pass 16 | 17 | 18 | class ReadBitstream(object): 19 | 20 | def __init__(self, s): 21 | self.s = s 22 | self.i = 0 23 | 24 | def read_bit(self): 25 | i = self.i 26 | self.i = i + 1 27 | byte = ord(self.s[i >> 3]) 28 | bit = byte >> (7 - (i & 7)) 29 | return bit & 1 30 | 31 | def read_bits(self, n): 32 | s = self.s 33 | i = self.i 34 | end = i + n 35 | chunk = s[i >> 3: (end + 7) >> 3] 36 | value = ord(chunk[0]) &~ (0xff00 >> (i & 7)) 37 | for c in chunk[1: ]: 38 | value = (value << 8) | ord(c) 39 | if (end & 7) != 0: 40 | value = value >> (8 - (end & 7)) 41 | self.i = end 42 | return value 43 | 44 | def read_byte(self): 45 | i = self.i 46 | self.i = i + 8 47 | byte = ord(self.s[i >> 3]) 48 | if (i & 7) == 0: 49 | return byte 50 | byte = (byte << 8) | ord(self.s[(i >> 3) + 1]) 51 | return (byte >> (8 - (i & 7))) & 0xff 52 | 53 | class WriteBitstream(object): 54 | 55 | def __init__(self): 56 | self.s = "" 57 | self.byte = 0 58 | self.i = 7 59 | 60 | def write_bit(self, b): 61 | i = self.i 62 | byte = self.byte | (b << i) 63 | if i == 0: 64 | self.s += chr(byte) 65 | self.byte = 0 66 | self.i = 7 67 | else: 68 | self.byte = byte 69 | self.i = i - 1 70 | 71 | def write_bits(self, b, n): 72 | s = self.s 73 | byte = self.byte 74 | i = self.i 75 | while n >= (i + 1): 76 | shift = n - (i + 1) 77 | n = n - (i + 1) 78 | byte = byte | (b >> shift) 79 | b = b &~ (byte << shift) 80 | s = s + chr(byte) 81 | byte = 0 82 | i = 7 83 | if n > 0: 84 | byte = byte | (b << (i + 1 - n)) 85 | i = i - n 86 | self.s = s 87 | self.byte = byte 88 | self.i = i 89 | 90 | def write_byte(self, b): 91 | i = self.i 92 | if i == 7: 93 | self.s += chr(b) 94 | else: 95 | self.s += chr(self.byte | (b >> (7 - i))) 96 | self.byte = (b << (i + 1)) & 0xff 97 | 98 | def getvalue(self): 99 | if self.i != 7: 100 | return self.s + chr(self.byte) 101 | else: 102 | return self.s 103 | 104 | 105 | def read_huffman_tree(b): 106 | node_type = b.read_bit() 107 | if node_type == 0: 108 | return (None, (read_huffman_tree(b), read_huffman_tree(b))) 109 | else: 110 | return (None, b.read_byte()) 111 | 112 | def write_huffman_tree(node, b): 113 | if type(node[1]) is int: 114 | b.write_bit(1) 115 | b.write_byte(node[1]) 116 | else: 117 | b.write_bit(0) 118 | write_huffman_tree(node[1][0], b) 119 | write_huffman_tree(node[1][1], b) 120 | 121 | def make_huffman_tree(data): 122 | frequencies = [0] * 256 123 | for c in data: 124 | frequencies[ord(c)] += 1 125 | 126 | nodes = [[f, i] for (i, f) in enumerate(frequencies) if f != 0] 127 | nodes.sort() 128 | 129 | while len(nodes) > 1: 130 | l, r = nodes[: 2] 131 | nodes = nodes[2: ] 132 | insort(nodes, [l[0] + r[0], [l, r]]) 133 | 134 | return nodes[0] 135 | 136 | def invert_tree(node, code=0, bits=0): 137 | if type(node[1]) is int: 138 | return {chr(node[1]): (code, bits)} 139 | else: 140 | d = {} 141 | d.update(invert_tree(node[1][0], code << 1, bits + 1)) 142 | d.update(invert_tree(node[1][1], (code << 1) | 1, bits + 1)) 143 | return d 144 | 145 | def huffman_decompress(tree, bitstream, size): 146 | output = "" 147 | while len(output) < size: 148 | node = tree 149 | while 1: 150 | b = bitstream.read_bit() 151 | node = node[1][b] 152 | if type(node[1]) is int: 153 | output += chr(node[1]) 154 | break 155 | return output 156 | 157 | def huffman_compress(encoding, data, bitstream): 158 | for c in data: 159 | code, nbits = encoding[c] 160 | bitstream.write_bits(code, nbits) 161 | 162 | 163 | item_sizes = ( 164 | (8, 17, 20, 11, 7, 7, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16), 165 | (8, 13, 20, 11, 7, 7, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17) 166 | ) 167 | 168 | def pack_item_values(is_weapon, values): 169 | i = 0 170 | bytes = [0] * 32 171 | for value, size in zip(values, item_sizes[is_weapon]): 172 | if value is None: 173 | break 174 | j = i >> 3 175 | value = value << (i & 7) 176 | while value != 0: 177 | bytes[j] |= value & 0xff 178 | value = value >> 8 179 | j = j + 1 180 | i = i + size 181 | if (i & 7) != 0: 182 | value = 0xff << (i & 7) 183 | bytes[i >> 3] |= (value & 0xff) 184 | return "".join(map(chr, bytes[: (i + 7) >> 3])) 185 | 186 | def unpack_item_values(is_weapon, data): 187 | i = 8 188 | data = " " + data 189 | values = [] 190 | end = len(data) * 8 191 | for size in item_sizes[is_weapon]: 192 | j = i + size 193 | if j > end: 194 | values.append(None) 195 | continue 196 | value = 0 197 | for b in data[j >> 3: (i >> 3) - 1: -1]: 198 | value = (value << 8) | ord(b) 199 | values.append((value >> (i & 7)) &~ (0xff << size)) 200 | i = j 201 | return values 202 | 203 | def rotate_data_right(data, steps): 204 | steps = steps % len(data) 205 | return data[-steps: ] + data[: -steps] 206 | 207 | def rotate_data_left(data, steps): 208 | steps = steps % len(data) 209 | return data[steps: ] + data[: steps] 210 | 211 | def xor_data(data, key): 212 | key = key & 0xffffffff 213 | output = "" 214 | for c in data: 215 | key = (key * 279470273) % 4294967291 216 | output += chr((ord(c) ^ key) & 0xff) 217 | return output 218 | 219 | def wrap_item(is_weapon, values, key): 220 | item = pack_item_values(is_weapon, values) 221 | header = struct.pack(">Bi", (is_weapon << 7) | 7, key) 222 | padding = "\xff" * (33 - len(item)) 223 | h = binascii.crc32(header + "\xff\xff" + item + padding) & 0xffffffff 224 | checksum = struct.pack(">H", ((h >> 16) ^ h) & 0xffff) 225 | body = xor_data(rotate_data_left(checksum + item, key & 31), key >> 5) 226 | return header + body 227 | 228 | def unwrap_item(data): 229 | version_type, key = struct.unpack(">Bi", data[: 5]) 230 | is_weapon = version_type >> 7 231 | raw = rotate_data_right(xor_data(data[5: ], key >> 5), key & 31) 232 | return is_weapon, unpack_item_values(is_weapon, raw[2: ]), key 233 | 234 | def replace_raw_item_key(data, key): 235 | old_key = struct.unpack(">i", data[1: 5])[0] 236 | item = rotate_data_right(xor_data(data[5: ], old_key >> 5), old_key & 31)[2: ] 237 | header = data[0] + struct.pack(">i", key) 238 | padding = "\xff" * (33 - len(item)) 239 | h = binascii.crc32(header + "\xff\xff" + item + padding) & 0xffffffff 240 | checksum = struct.pack(">H", ((h >> 16) ^ h) & 0xffff) 241 | body = xor_data(rotate_data_left(checksum + item, key & 31), key >> 5) 242 | return header + body 243 | 244 | 245 | def read_varint(f): 246 | value = 0 247 | offset = 0 248 | while 1: 249 | b = ord(f.read(1)) 250 | value |= (b & 0x7f) << offset 251 | if (b & 0x80) == 0: 252 | break 253 | offset = offset + 7 254 | return value 255 | 256 | def write_varint(f, i): 257 | while i > 0x7f: 258 | f.write(chr(0x80 | (i & 0x7f))) 259 | i = i >> 7 260 | f.write(chr(i)) 261 | 262 | def read_protobuf(data): 263 | fields = {} 264 | end_position = len(data) 265 | bytestream = StringIO(data) 266 | while bytestream.tell() < end_position: 267 | key = read_varint(bytestream) 268 | field_number = key >> 3 269 | wire_type = key & 7 270 | value = read_protobuf_value(bytestream, wire_type) 271 | fields.setdefault(field_number, []).append([wire_type, value]) 272 | return fields 273 | 274 | def read_protobuf_value(b, wire_type): 275 | if wire_type == 0: 276 | value = read_varint(b) 277 | elif wire_type == 1: 278 | value = struct.unpack("> 1) 340 | else: 341 | return i >> 1 342 | 343 | 344 | def apply_structure(pbdata, s): 345 | fields = {} 346 | raw = {} 347 | for k, data in pbdata.items(): 348 | mapping = s.get(k) 349 | if mapping is None: 350 | raw[k] = data 351 | continue 352 | elif type(mapping) is str: 353 | fields[mapping] = data[0][1] 354 | continue 355 | key, repeated, child_s = mapping 356 | if child_s is None: 357 | values = [d[1] for d in data] 358 | fields[key] = values if repeated else values[0] 359 | elif type(child_s) is int: 360 | if repeated: 361 | fields[key] = read_repeated_protobuf_value(data[0][1], child_s) 362 | else: 363 | fields[key] = data[0][1] 364 | elif type(child_s) is tuple: 365 | values = [child_s[0](d[1]) for d in data] 366 | fields[key] = values if repeated else values[0] 367 | elif type(child_s) is dict: 368 | values = [apply_structure(read_protobuf(d[1]), child_s) for d in data] 369 | fields[key] = values if repeated else values[0] 370 | else: 371 | raise Exception("Invalid mapping %r for %r: %r" % (mapping, k, data)) 372 | if len(raw) != 0: 373 | fields["_raw"] = {} 374 | for k, values in raw.items(): 375 | safe_values = [] 376 | for (wire_type, v) in values: 377 | if wire_type == 2: 378 | v = [ord(c) for c in v] 379 | safe_values.append([wire_type, v]) 380 | fields["_raw"][k] = safe_values 381 | return fields 382 | 383 | def remove_structure(data, inv): 384 | pbdata = {} 385 | pbdata.update(data.get("_raw", {})) 386 | for k, value in data.items(): 387 | if k == "_raw": 388 | continue 389 | mapping = inv.get(k) 390 | if mapping is None: 391 | raise BL2Error("Unknown key %r in data" % (k, )) 392 | elif type(mapping) is int: 393 | pbdata[mapping] = [[guess_wire_type(value), value]] 394 | continue 395 | key, repeated, child_inv = mapping 396 | if child_inv is None: 397 | value = [value] if not repeated else value 398 | pbdata[key] = [[guess_wire_type(v), v] for v in value] 399 | elif type(child_inv) is int: 400 | if repeated: 401 | b = StringIO() 402 | for v in value: 403 | write_protobuf_value(b, child_inv, v) 404 | pbdata[key] = [[2, b.getvalue()]] 405 | else: 406 | pbdata[key] = [[child_inv, value]] 407 | elif type(child_inv) is tuple: 408 | value = [value] if not repeated else value 409 | values = [] 410 | for v in map(child_inv[1], value): 411 | if type(v) is list: 412 | values.append(v) 413 | else: 414 | values.append([guess_wire_type(v), v]) 415 | pbdata[key] = values 416 | elif type(child_inv) is dict: 417 | value = [value] if not repeated else value 418 | values = [] 419 | for d in [remove_structure(v, child_inv) for v in value]: 420 | values.append([2, write_protobuf(d)]) 421 | pbdata[key] = values 422 | else: 423 | raise Exception("Invalid mapping %r for %r: %r" % (mapping, k, value)) 424 | return pbdata 425 | 426 | def guess_wire_type(value): 427 | return 2 if isinstance(value, basestring) else 0 428 | 429 | def invert_structure(structure): 430 | inv = {} 431 | for k, v in structure.items(): 432 | if type(v) is tuple: 433 | if type(v[2]) is dict: 434 | inv[v[0]] = (k, v[1], invert_structure(v[2])) 435 | else: 436 | inv[v[0]] = (k, ) + v[1: ] 437 | else: 438 | inv[v] = k 439 | return inv 440 | 441 | def unwrap_bytes(value): 442 | return [ord(d) for d in value] 443 | 444 | def wrap_bytes(value): 445 | return "".join(map(chr, value)) 446 | 447 | def unwrap_float(v): 448 | return struct.unpack("> bits 481 | asset = item[1 + i] &~ (lib << bits) 482 | data[k] = {"lib": lib, "asset": asset} 483 | bits = 10 + is_weapon 484 | parts = [] 485 | for value in item[6: ]: 486 | if value is None: 487 | parts.append(None) 488 | else: 489 | lib = value >> bits 490 | asset = value &~ (lib << bits) 491 | parts.append({"lib": lib, "asset": asset}) 492 | data["parts"] = parts 493 | return data 494 | 495 | def wrap_item_info(value): 496 | item = [value["set"]] 497 | for key, bits in item_header_sizes[value["is_weapon"]]: 498 | v = value[key] 499 | item.append((v["lib"] << bits) | v["asset"]) 500 | item.extend(value["level"]) 501 | bits = 10 + value["is_weapon"] 502 | for v in value["parts"]: 503 | if v is None: 504 | item.append(None) 505 | else: 506 | item.append((v["lib"] << bits) | v["asset"]) 507 | return wrap_item(value["is_weapon"], item, value["key"]) 508 | 509 | save_structure = { 510 | 1: "class", 511 | 2: "level", 512 | 3: "experience", 513 | 4: "skill_points", 514 | 6: ("currency", True, 0), 515 | 7: "playthroughs_completed", 516 | 8: ("skills", True, { 517 | 1: "name", 518 | 2: "level", 519 | 3: "unknown3", 520 | 4: "unknown4" 521 | }), 522 | 11: ("resources", True, { 523 | 1: "resource", 524 | 2: "pool", 525 | 3: ("amount", False, (unwrap_float, wrap_float)), 526 | 4: "level" 527 | }), 528 | 13: ("sizes", False, { 529 | 1: "inventory", 530 | 2: "weapon_slots", 531 | 3: "weapon_slots_shown" 532 | }), 533 | 15: ("stats", False, (unwrap_bytes, wrap_bytes)), 534 | 16: ("active_fast_travel", True, None), 535 | 17: "last_fast_travel", 536 | 18: ("missions", True, { 537 | 1: "playthrough", 538 | 2: "active", 539 | 3: ("data", True, { 540 | 1: "name", 541 | 2: "status", 542 | 3: "is_from_dlc", 543 | 4: "dlc_id", 544 | 5: ("unknown5", False, (unwrap_bytes, wrap_bytes)), 545 | 6: "unknown6", 546 | 7: ("unknown7", False, (unwrap_bytes, wrap_bytes)), 547 | 8: "unknown8", 548 | 9: "unknown9", 549 | 10: "unknown10", 550 | 11: "level", 551 | }), 552 | }), 553 | 19: ("appearance", False, { 554 | 1: "name", 555 | 2: ("color1", False, {1: "a", 2: "r", 3: "g", 4: "b"}), 556 | 3: ("color2", False, {1: "a", 2: "r", 3: "g", 4: "b"}), 557 | 4: ("color3", False, {1: "a", 2: "r", 3: "g", 4: "b"}), 558 | }), 559 | 20: "save_game_id", 560 | 21: "mission_number", 561 | 23: ("unlocks", False, (unwrap_bytes, wrap_bytes)), 562 | 24: ("unlock_notifications", False, (unwrap_bytes, wrap_bytes)), 563 | 25: "time_played", 564 | 26: "save_timestamp", 565 | 29: ("game_stages", True, { 566 | 1: "name", 567 | 2: "level", 568 | 3: "is_from_dlc", 569 | 4: "dlc_id", 570 | 5: "playthrough", 571 | }), 572 | 30: ("areas", True, { 573 | 1: "name", 574 | 2: "unknown2" 575 | }), 576 | 34: ("id", False, { 577 | 1: ("a", False, 5), 578 | 2: ("b", False, 5), 579 | 3: ("c", False, 5), 580 | 4: ("d", False, 5), 581 | }), 582 | 35: ("wearing", True, None), 583 | 36: ("black_market", False, (unwrap_black_market, wrap_black_market)), 584 | 37: "active_mission", 585 | 38: ("challenges", True, { 586 | 1: "name", 587 | 2: "is_from_dlc", 588 | 3: "dlc_id" 589 | }), 590 | 41: ("bank", True, { 591 | 1: ("data", False, (unwrap_item_info, wrap_item_info)), 592 | }), 593 | 43: ("lockouts", True, { 594 | 1: "name", 595 | 2: "time", 596 | 3: "is_from_dlc", 597 | 4: "dlc_id" 598 | }), 599 | 46: ("explored_areas", True, None), 600 | 49: "active_playthrough", 601 | 53: ("items", True, { 602 | 1: ("data", False, (unwrap_item_info, wrap_item_info)), 603 | 2: "unknown2", 604 | 3: "is_equipped", 605 | 4: "star" 606 | }), 607 | 54: ("weapons", True, { 608 | 1: ("data", False, (unwrap_item_info, wrap_item_info)), 609 | 2: "slot", 610 | 3: "star", 611 | 4: "unknown4", 612 | }), 613 | 55: "stats_bonuses_disabled", 614 | 56: "bank_size", 615 | } 616 | 617 | 618 | def unwrap_player_data(data): 619 | if data[: 4] == "CON ": 620 | raise BL2Error("You need to use a program like Horizon or Modio to extract the SaveGame.sav file first") 621 | 622 | if data[: 20] != hashlib.sha1(data[20: ]).digest(): 623 | raise BL2Error("Invalid save file") 624 | 625 | data = lzo1x_decompress("\xf0" + data[20: ]) 626 | size, wsg, version = struct.unpack(">I3sI", data[: 11]) 627 | if version != 2 and version != 0x02000000: 628 | raise BL2Error("Unknown save version " + str(version)) 629 | 630 | if version == 2: 631 | crc, size = struct.unpack(">II", data[11: 19]) 632 | else: 633 | crc, size = struct.unpack("I3s", len(data) + 15, "WSG") 654 | if endian == 1: 655 | header = header + struct.pack(">III", 2, crc, len(player)) 656 | else: 657 | header = header + struct.pack(" 17: 687 | t = t - 17 688 | dst.extend(src[ip: ip + t]); ip += t 689 | t = src[ip]; ip += 1 690 | elif t < 16: 691 | if t == 0: 692 | t, ip = expand_zeroes(src, ip, 15) 693 | dst.extend(src[ip: ip + t + 3]); ip += t + 3 694 | t = src[ip]; ip += 1 695 | 696 | while 1: 697 | while 1: 698 | if t >= 64: 699 | copy_earlier(dst, 1 + ((t >> 2) & 7) + (src[ip] << 3), (t >> 5) + 1); ip += 1 700 | elif t >= 32: 701 | count = t & 31 702 | if count == 0: 703 | count, ip = expand_zeroes(src, ip, 31) 704 | t = src[ip] 705 | copy_earlier(dst, 1 + ((t | (src[ip + 1] << 8)) >> 2), count + 2); ip += 2 706 | elif t >= 16: 707 | offset = (t & 8) << 11 708 | count = t & 7 709 | if count == 0: 710 | count, ip = expand_zeroes(src, ip, 7) 711 | t = src[ip] 712 | offset += (t | (src[ip + 1] << 8)) >> 2; ip += 2 713 | if offset == 0: 714 | return str(dst) 715 | copy_earlier(dst, offset + 0x4000, count + 2) 716 | else: 717 | copy_earlier(dst, 1 + (t >> 2) + (src[ip] << 2), 2); ip += 1 718 | 719 | t = t & 3 720 | if t == 0: 721 | break 722 | dst.extend(src[ip: ip + t]); ip += t 723 | t = src[ip]; ip += 1 724 | 725 | while 1: 726 | t = src[ip]; ip += 1 727 | if t < 16: 728 | if t == 0: 729 | t, ip = expand_zeroes(src, ip, 15) 730 | dst.extend(src[ip: ip + t + 3]); ip += t + 3 731 | t = src[ip]; ip += 1 732 | if t < 16: 733 | copy_earlier(dst, 1 + 0x0800 + (t >> 2) + (src[ip] << 2), 3); ip += 1 734 | t = t & 3 735 | if t == 0: 736 | continue 737 | dst.extend(src[ip: ip + t]); ip += t 738 | t = src[ip]; ip += 1 739 | break 740 | 741 | 742 | def read_xor32(src, p1, p2): 743 | v1 = src[p1] | (src[p1 + 1] << 8) | (src[p1 + 2] << 16) | (src[p1 + 3] << 24) 744 | v2 = src[p2] | (src[p2 + 1] << 8) | (src[p2 + 2] << 16) | (src[p2 + 3] << 24) 745 | return v1 ^ v2 746 | 747 | clz_table = ( 748 | 32, 0, 1, 26, 2, 23, 27, 0, 3, 16, 24, 30, 28, 11, 0, 13, 4, 749 | 7, 17, 0, 25, 22, 31, 15, 29, 10, 12, 6, 0, 21, 14, 9, 5, 750 | 20, 8, 19, 18 751 | ) 752 | 753 | def lzo1x_1_compress_core(src, dst, ti, ip_start, ip_len): 754 | dict_entries = [0] * 16384 755 | 756 | in_end = ip_start + ip_len 757 | ip_end = ip_start + ip_len - 20 758 | 759 | ip = ip_start 760 | ii = ip_start 761 | 762 | ip += (4 - ti) if ti < 4 else 0 763 | ip += 1 + ((ip - ii) >> 5) 764 | while 1: 765 | while 1: 766 | if ip >= ip_end: 767 | return in_end - (ii - ti) 768 | dv = src[ip: ip + 4] 769 | dindex = dv[0] | (dv[1] << 8) | (dv[2] << 16) | (dv[3] << 24) 770 | dindex = ((0x1824429d * dindex) >> 18) & 0x3fff 771 | m_pos = ip_start + dict_entries[dindex] 772 | dict_entries[dindex] = (ip - ip_start) & 0xffff 773 | if dv == src[m_pos: m_pos + 4]: 774 | break 775 | ip += 1 + ((ip - ii) >> 5) 776 | 777 | ii -= ti; ti = 0 778 | t = ip - ii 779 | if t != 0: 780 | if t <= 3: 781 | dst[-2] |= t 782 | dst.extend(src[ii: ii + t]) 783 | elif t <= 16: 784 | dst.append(t - 3) 785 | dst.extend(src[ii: ii + t]) 786 | else: 787 | if t <= 18: 788 | dst.append(t - 3) 789 | else: 790 | tt = t - 18 791 | dst.append(0) 792 | n, tt = divmod(tt, 255) 793 | dst.extend("\x00" * n) 794 | dst.append(tt) 795 | dst.extend(src[ii: ii + t]) 796 | ii += t 797 | 798 | m_len = 4 799 | v = read_xor32(src, ip + m_len, m_pos + m_len) 800 | if v == 0: 801 | while 1: 802 | m_len += 4 803 | v = read_xor32(src, ip + m_len, m_pos + m_len) 804 | if ip + m_len >= ip_end: 805 | break 806 | elif v != 0: 807 | m_len += clz_table[(v & -v) % 37] >> 3 808 | break 809 | else: 810 | m_len += clz_table[(v & -v) % 37] >> 3 811 | 812 | m_off = ip - m_pos 813 | ip += m_len 814 | ii = ip 815 | if m_len <= 8 and m_off <= 0x0800: 816 | m_off -= 1 817 | dst.append(((m_len - 1) << 5) | ((m_off & 7) << 2)) 818 | dst.append(m_off >> 3) 819 | elif m_off <= 0x4000: 820 | m_off -= 1 821 | if m_len <= 33: 822 | dst.append(32 | (m_len - 2)) 823 | else: 824 | m_len -= 33 825 | dst.append(32) 826 | n, m_len = divmod(m_len, 255) 827 | dst.extend("\x00" * n) 828 | dst.append(m_len) 829 | dst.append((m_off << 2) & 0xff) 830 | dst.append((m_off >> 6) & 0xff) 831 | else: 832 | m_off -= 0x4000 833 | if m_len <= 9: 834 | dst.append(0xff & (16 | ((m_off >> 11) & 8) | (m_len - 2))) 835 | else: 836 | m_len -= 9 837 | dst.append(0xff & (16 | ((m_off >> 11) & 8))) 838 | n, m_len = divmod(m_len, 255) 839 | dst.extend("\x00" * n) 840 | dst.append(m_len) 841 | dst.append((m_off << 2) & 0xff) 842 | dst.append((m_off >> 6) & 0xff) 843 | 844 | def lzo1x_1_compress(s): 845 | src = bytearray(s) 846 | dst = bytearray() 847 | 848 | ip = 0 849 | l = len(s) 850 | t = 0 851 | 852 | dst.append(240) 853 | dst.append((l >> 24) & 0xff) 854 | dst.append((l >> 16) & 0xff) 855 | dst.append((l >> 8) & 0xff) 856 | dst.append( l & 0xff) 857 | 858 | while l > 20 and t + l > 31: 859 | ll = min(49152, l) 860 | t = lzo1x_1_compress_core(src, dst, t, ip, ll) 861 | ip += ll 862 | l -= ll 863 | t += l 864 | 865 | if t > 0: 866 | ii = len(s) - t 867 | 868 | if len(dst) == 5 and t <= 238: 869 | dst.append(17 + t) 870 | elif t <= 3: 871 | dst[-2] |= t 872 | elif t <= 18: 873 | dst.append(t - 3) 874 | else: 875 | tt = t - 18 876 | dst.append(0) 877 | n, tt = divmod(tt, 255) 878 | dst.extend("\x00" * n) 879 | dst.append(tt) 880 | dst.extend(src[ii: ii + t]) 881 | 882 | dst.append(16 | 1) 883 | dst.append(0) 884 | dst.append(0) 885 | 886 | return str(dst) 887 | 888 | 889 | def modify_save(data, changes, endian=1): 890 | player = read_protobuf(unwrap_player_data(data)) 891 | 892 | if changes.has_key("level"): 893 | level = int(changes["level"]) 894 | lower = int(60 * (level ** 2.8) - 59.2) 895 | upper = int(60 * ((level + 1) ** 2.8) - 59.2) 896 | if player[3][0][1] not in range(lower, upper): 897 | player[3][0][1] = lower 898 | player[2] = [[0, int(changes["level"])]] 899 | 900 | if changes.has_key("skillpoints"): 901 | player[4] = [[0, int(changes["skillpoints"])]] 902 | 903 | if any(map(changes.has_key, ("money", "eridium", "seraph", "tokens"))): 904 | raw = player[6][0][1] 905 | b = StringIO(raw) 906 | values = [] 907 | while b.tell() < len(raw): 908 | values.append(read_protobuf_value(b, 0)) 909 | if changes.has_key("money"): 910 | values[0] = int(changes["money"]) 911 | if changes.has_key("eridium"): 912 | values[1] = int(changes["eridium"]) 913 | if changes.has_key("seraph"): 914 | values[2] = int(changes["seraph"]) 915 | if changes.has_key("tokens"): 916 | values[4] = int(changes["tokens"]) 917 | player[6][0] = [0, values] 918 | 919 | if changes.has_key("itemlevels"): 920 | if changes["itemlevels"]: 921 | level = int(changes["itemlevels"]) 922 | else: 923 | level = player[2][0][1] 924 | for field_number in (53, 54): 925 | for field in player[field_number]: 926 | field_data = read_protobuf(field[1]) 927 | is_weapon, item, key = unwrap_item(field_data[1][0][1]) 928 | if item[4] > 1: 929 | item = item[: 4] + [level, level] + item[6: ] 930 | field_data[1][0][1] = wrap_item(is_weapon, item, key) 931 | field[1] = write_protobuf(field_data) 932 | 933 | if changes.has_key("backpack"): 934 | size = int(changes["backpack"]) 935 | sdus = int(math.ceil((size - 12) / 3.0)) 936 | size = 12 + (sdus * 3) 937 | slots = read_protobuf(player[13][0][1]) 938 | slots[1][0][1] = size 939 | player[13][0][1] = write_protobuf(slots) 940 | s = read_repeated_protobuf_value(player[36][0][1], 0) 941 | player[36][0][1] = write_repeated_protobuf_value(s[: 7] + [sdus] + s[8: ], 0) 942 | 943 | if changes.has_key("bank"): 944 | size = int(changes["bank"]) 945 | sdus = int(min(255, math.ceil((size - 6) / 2.0))) 946 | size = 6 + (sdus * 2) 947 | if player.has_key(56): 948 | player[56][0][1] = size 949 | else: 950 | player[56] = [[0, size]] 951 | s = read_repeated_protobuf_value(player[36][0][1], 0) 952 | if len(s) < 9: 953 | s = s + (9 - len(s)) * [0] 954 | player[36][0][1] = write_repeated_protobuf_value(s[: 8] + [sdus] + s[9: ], 0) 955 | 956 | if changes.get("gunslots", "0") in "234": 957 | n = int(changes["gunslots"]) 958 | slots = read_protobuf(player[13][0][1]) 959 | slots[2][0][1] = n 960 | if slots[3][0][1] > n - 2: 961 | slots[3][0][1] = n - 2 962 | player[13][0][1] = write_protobuf(slots) 963 | 964 | if changes.has_key("unlocks"): 965 | unlocked, notifications = [], [] 966 | if player.has_key(23): 967 | unlocked = map(ord, player[23][0][1]) 968 | if player.has_key(24): 969 | notifications = map(ord, player[24][0][1]) 970 | unlocks = changes["unlocks"].split(":") 971 | if "slaughterdome" in unlocks: 972 | if 1 not in unlocked: 973 | unlocked.append(1) 974 | if 1 not in notifications: 975 | notifications.append(1) 976 | if unlocked: 977 | player[23] = [[2, "".join(map(chr, unlocked))]] 978 | if notifications: 979 | player[24] = [[2, "".join(map(chr, notifications))]] 980 | if "truevaulthunter" in unlocks: 981 | if player[7][0][1] < 1: 982 | player[7][0][1] = 1 983 | 984 | return wrap_player_data(write_protobuf(player), endian) 985 | 986 | def export_items(data, output): 987 | player = read_protobuf(unwrap_player_data(data)) 988 | for i, name in ((41, "Bank"), (53, "Items"), (54, "Weapons")): 989 | content = player.get(i) 990 | if content is None: 991 | continue 992 | print >>output, "; " + name 993 | for field in content: 994 | raw = read_protobuf(field[1])[1][0][1] 995 | raw = replace_raw_item_key(raw, 0) 996 | code = "BL2(" + raw.encode("base64").strip() + ")" 997 | print >>output, code 998 | 999 | def import_items(data, codelist, endian=1): 1000 | player = read_protobuf(unwrap_player_data(data)) 1001 | 1002 | to_bank = False 1003 | for line in codelist.splitlines(): 1004 | line = line.strip() 1005 | if line.startswith(";"): 1006 | name = line[1: ].strip().lower() 1007 | if name == "bank": 1008 | to_bank = True 1009 | elif name in ("items", "weapons"): 1010 | to_bank = False 1011 | continue 1012 | elif line[: 4] + line[-1: ] != "BL2()": 1013 | continue 1014 | 1015 | code = line[4: -1] 1016 | try: 1017 | raw = code.decode("base64") 1018 | except binascii.Error: 1019 | continue 1020 | 1021 | key = random.randrange(0x100000000) - 0x80000000 1022 | raw = replace_raw_item_key(raw, key) 1023 | if to_bank: 1024 | field = 41 1025 | entry = {1: [[2, raw]]} 1026 | elif (ord(raw[0]) & 0x80) == 0: 1027 | field = 53 1028 | entry = {1: [[2, raw]], 2: [[0, 1]], 3: [[0, 0]], 4: [[0, 1]]} 1029 | else: 1030 | field = 53 1031 | entry = {1: [[2, raw]], 2: [[0, 0]], 3: [[0, 1]]} 1032 | 1033 | player.setdefault(field, []).append([2, write_protobuf(entry)]) 1034 | 1035 | return wrap_player_data(write_protobuf(player), endian) 1036 | 1037 | 1038 | def parse_args(): 1039 | usage = "usage: %prog [options] [source file] [destination file]" 1040 | p = optparse.OptionParser() 1041 | p.add_option( 1042 | "-d", "--decode", 1043 | action="store_true", 1044 | help="read from a save game, rather than creating one" 1045 | ) 1046 | p.add_option( 1047 | "-e", "--export-items", metavar="FILENAME", 1048 | help="save out codes for all bank and inventory items" 1049 | ) 1050 | p.add_option( 1051 | "-i", "--import-items", metavar="FILENAME", 1052 | help="read in codes for items and add them to the bank and inventory" 1053 | ) 1054 | p.add_option( 1055 | "-j", "--json", 1056 | action="store_true", 1057 | help="read or write save game data in JSON format, rather than raw protobufs" 1058 | ) 1059 | p.add_option( 1060 | "-l", "--little-endian", 1061 | action="store_true", 1062 | help="change the output format to little endian, to write PC-compatible save files" 1063 | ) 1064 | p.add_option( 1065 | "-m", "--modify", metavar="MODIFICATIONS", 1066 | help="comma separated list of modifications to make, eg money=99999999,eridium=99" 1067 | ) 1068 | p.add_option( 1069 | "-p", "--parse", 1070 | action="store_true", 1071 | help="parse the protocol buffer data further and generate more readable JSON" 1072 | ) 1073 | return p.parse_args() 1074 | 1075 | def main(options, args): 1076 | if len(args) >= 2 and args[0] != "-" and args[0] == args[1]: 1077 | print >>sys.stderr, "Cannot overwrite the save file, please use a different filename for the new save" 1078 | return 1079 | 1080 | if len(args) < 1 or args[0] == "-": 1081 | input = sys.stdin 1082 | else: 1083 | input = open(args[0], "rb") 1084 | 1085 | if len(args) < 2 or args[1] == "-": 1086 | output = sys.stdout 1087 | else: 1088 | output = open(args[1], "wb") 1089 | 1090 | if options.little_endian: 1091 | endian = 0 1092 | else: 1093 | endian = 1 1094 | 1095 | if options.modify is not None: 1096 | changes = {} 1097 | if options.modify: 1098 | for m in options.modify.split(","): 1099 | k, v = (m.split("=", 1) + [None])[: 2] 1100 | changes[k] = v 1101 | output.write(modify_save(input.read(), changes, endian)) 1102 | elif options.export_items: 1103 | output = open(options.export_items, "w") 1104 | export_items(input.read(), output) 1105 | elif options.import_items: 1106 | itemlist = open(options.import_items, "r") 1107 | output.write(import_items(input.read(), itemlist.read(), endian)) 1108 | elif options.decode: 1109 | savegame = input.read() 1110 | player = unwrap_player_data(savegame) 1111 | if options.json: 1112 | data = read_protobuf(player) 1113 | if options.parse: 1114 | data = apply_structure(data, save_structure) 1115 | player = json.dumps(data, encoding="latin1", sort_keys=True, indent=4) 1116 | output.write(player) 1117 | else: 1118 | player = input.read() 1119 | if options.json: 1120 | data = json.loads(player, encoding="latin1") 1121 | if not data.has_key("1"): 1122 | data = remove_structure(data, invert_structure(save_structure)) 1123 | player = write_protobuf(data) 1124 | savegame = wrap_player_data(player, endian) 1125 | output.write(savegame) 1126 | 1127 | if __name__ == "__main__": 1128 | options, args = parse_args() 1129 | try: 1130 | main(options, args) 1131 | except: 1132 | print >>sys.stderr, ( 1133 | "Something went wrong, but please ensure you have the latest " 1134 | "version from https://github.com/pclifford/borderlands2 before " 1135 | "reporting a bug. Information useful for a report follows:" 1136 | ) 1137 | print >>sys.stderr, repr(sys.argv) 1138 | raise 1139 | --------------------------------------------------------------------------------