import os import sys import OakSave_pb2 dk1 = bytearray([0x71, 0x34, 0x36, 0xB3, 0x56, 0x63, 0x25, 0x5F, 0xEA, 0xE2, 0x83, 0x73, 0xF4, 0x98, 0xB8, 0x18, 0x2E, 0xE5, 0x42, 0x2E, 0x50, 0xA2, 0x0F, 0x49, 0x87, 0x24, 0xE6, 0x65, 0x9A, 0xF0, 0x7C, 0xD7]) dk2 = bytearray([0x7C, 0x07, 0x69, 0x83, 0x31, 0x7E, 0x0C, 0x82, 0x5F, 0x2E, 0x36, 0x7F, 0x76, 0xB4, 0xA2, 0x71, 0x38, 0x2B, 0x6E, 0x87, 0x39, 0x05, 0x02, 0xC6, 0xCD, 0xD8, 0xB1, 0xCC, 0xA1, 0x33, 0xF9, 0xB6]) def write_u32_le(sc : bytearray, offset : int, val: int): sc[offset] = val & 0xFF sc[offset + 1] = (val >> 8) & 0xFF sc[offset + 2] = (val >> 16) & 0xFF sc[offset + 3] = (val >> 24) & 0xFF def read_u32_le(sc : bytearray, offset : int): ret = sc[offset] ret |= sc[offset + 1] << 8 ret |= sc[offset + 2] << 16 ret |= sc[offset + 3] << 24 return ret def find_save_core_offset(sc : bytearray): #core offset is always "OakSaveGame\0" + 4 bytes (<- this is the payload size) sc_str = "OakSaveGame" ret = -1 for offset in range(0, len(sc) - len(sc_str)): match = True for i in range(0, len(sc_str)): if sc[offset + i] != ord(sc_str[i]): match = False break if match: ret = offset break if ret == -1: print("Cannot locate core offset") exit(-1) # skip the OakSaveGame string ret = ret + len(sc_str) + 1 core_sz = read_u32_le(sc, ret) return ret, core_sz def usage(): print("Usage: python bdeh.py ") def decrypt(src : bytearray): offset = len(src) - 1 while offset >= 0: k1 = 0 if offset >= 0x20: k1 = src[offset - 0x20] else: k1 = dk1[offset] k2 = offset k2 = k2 % 0x20 k2 = dk2[k2] k2 = k2 ^ k1 src[offset] = int(src[offset]) ^ k2 offset = offset - 1 return src def encrypt(src : bytearray): offset = 0 while offset < len(src): if offset >= 0x20: k1 = src[offset - 0x20] else: k1 = dk1[offset] k2 = offset k2 = k2 % 0x20 k2 = dk2[k2] k2 = k2 ^ k1 src[offset] = int(src[offset]) ^ k2 offset = offset + 1 return src def editor(save_obj): TYPE_INT = 0 TYPE_FLOAT = 1 TYPE_STRING = 2 TYPE_DICT = 3 TYPE_BOOL = 4 TYPE_MS = 5 #MS_Complete or MS_Active OP_READ = 0 OP_WRITE = 1 #only works for write since primitives will be copied vMap = { 'save_game_id': { 'func': save_obj.save_game_id, 'type': TYPE_INT }, 'last_save_timestamp': { 'func': save_obj.last_save_timestamp, 'type': TYPE_INT }, 'time_played_seconds': { 'func': save_obj.time_played_seconds, 'type': TYPE_INT }, 'player_class_data': { 'func': save_obj.player_class_data, 'type': TYPE_DICT }, 'resource_pools': { 'func': save_obj.resource_pools, 'type': TYPE_DICT }, 'saved_regions': { 'func': save_obj.saved_regions, 'type': TYPE_DICT }, 'experience_points': { 'func': save_obj.experience_points, 'type': TYPE_INT }, 'game_stats_data': { 'func': save_obj.game_stats_data, 'type': TYPE_DICT }, 'inventory_category_list': { 'func': save_obj.inventory_category_list, 'type': TYPE_DICT }, 'inventory_items': { 'func': save_obj.inventory_items, 'type': TYPE_DICT } } def show(vType): if vType in vMap: print("==========================\n") print(str(vType) + ": " + str(vMap[vType]['func'])) print("==========================\n") return print("Error: " + str(vType) + " is not a valid value.") def cash(op, cType, val): #op 0 read 1 write cType 0 cash 1 eridium hashVal = 0 if cType == 0: hashVal = 618814354 elif cType == 1: hashVal = 3679636065 for i in save_obj.inventory_category_list: if i.base_category_definition_hash == hashVal: if cType == 0: print("Current cash: " + str(i.quantity)) elif cType == 1: print("Current eridium: " + str(i.quantity)) if op == OP_WRITE: i.quantity = int(val) print("Success.\n") return print("Error: Cash category hash is not existing.") def experience_points(op, val): print("Current experience points: " + str(save_obj.experience_points)) if op == OP_WRITE: save_obj.experience_points = int(val) print("Success.\n") def print_menu(): print("BdEH: Save Editor for Borderlands 3\n") print("Commands:\nhelp\nget\nset\nsaveexit\nexit\n") def command(input): #try: ele = input.split() cmd = ele[0] stat = ele[1] if cmd == 'get': if stat == 'cash': cash(OP_READ, 0, 0) elif stat == 'eridium': cash(OP_READ, 1, 0) elif stat == 'experience_points': experience_points(OP_READ, 0) else: show(stat) elif cmd == 'set': if stat == 'cash': cash(OP_WRITE, 0, ele[2]) elif stat == 'eridium': cash(OP_WRITE, 1, ele[2]) elif stat == 'experience_points': experience_points(OP_WRITE, ele[2]) else: print("Error: Invalid command") #except: # print("Error: Invalid command.") print_menu() while True: uin = input('Please input a command: ') if uin == 'saveexit': break elif uin == 'exit': exit() command(uin) f = open('obj.txt', 'w') f.write(str(save_obj)) f.close() return def main(): if len(sys.argv) != 2: usage() exit() print("Reading " + sys.argv[1]) with open(sys.argv[1], "rb") as f: savfile = f.read() core_offset, core_sz = find_save_core_offset(savfile) if core_offset + core_sz + 4 != len(savfile): print("Incorrect file size!") exit(1) print("Save file loaded - size: " + hex(len(savfile)) + " core offset: " + hex(core_offset) + " core size: " + hex(core_sz)) # copy the payload payload = bytearray(core_sz) for i in range(0, core_sz): payload[i] = savfile[core_offset + 4 + i] # copy the header header = bytearray(core_offset) for i in range(0, core_offset): header[i] = savfile[i] # garbage collect the savfile buffer savfile = None # decrypt the payload payload = decrypt(payload) # deserialize the payload save_obj = OakSave_pb2.Character() save_obj.ParseFromString(payload) # editor loop editor(save_obj) # serialize the payload payload_ro = save_obj.SerializeToString() payload = bytearray(len(payload_ro)) for i in range(0, len(payload_ro)): payload[i] = payload_ro[i] # encrypt the payload payload = encrypt(payload) # build the full save full_save = bytearray(core_offset + 4 + len(payload)) for i in range(0, core_offset): full_save[i] = header[i] write_u32_le(full_save, core_offset, len(payload)) for i in range(0, len(payload)): full_save[core_offset + 4 + i] = payload[i] # Write with open(sys.argv[1] + ".bdeh", "wb") as f: f.write(full_save) main()