#!/usr/bin/env python3 # Startdate: 2024-03-08-6 15:28 # File: srb_lib.py # Purpose: library for srb.exe savegame hacking # SPDX-License-Identifier: GPL-3.0-only # History: # Usage: # from srb.py # Reference: # blog posts 2024-03 # # crc width=32 poly=0x4c11db7 init=0x0 xorout=0x235b4b9c refin=false refout=false out_endian=little # Incorrect functions: https://gist.github.com/djsweet/3477115595efab31905be7000bb013bc FAILED # Fuctions that worked: https://gist.github.com/Lauszus/6c787a3bc26fea6e842dfb8296ebd630 Author: Lauszus # https://stackoverflow.com/questions/46109815/reversing-the-byte-order-of-a-string-containing-hexadecimal-characters # https://gamefaqs.gamespot.com/pc/930591-snoopy-vs-the-red-baron/faqs/46161 # Documentation: # winetricks vd=1024x768 # winetricks vd=off # Dependencies: import sys, struct srb_lib_version = "20240318a" # Table of byte positions of values in the savegame file, minus the first four bytes which are the checksum. Due to zero-indexing of python lists, but for ease of usage, we will always put a zero as the value of index 0. That is, profile 1 will use index 1 of the list. # "ZL',checksum)) #f.write(struct.pack('>I',int(checksum))) f.write(data) ######## start copy-paste 2 # https://gist.github.com/Lauszus/6c787a3bc26fea6e842dfb8296ebd630 def reflect_data(x, width): # See: https://stackoverflow.com/a/20918545 if width == 8: x = ((x & 0x55) << 1) | ((x & 0xAA) >> 1) x = ((x & 0x33) << 2) | ((x & 0xCC) >> 2) x = ((x & 0x0F) << 4) | ((x & 0xF0) >> 4) elif width == 16: x = ((x & 0x5555) << 1) | ((x & 0xAAAA) >> 1) x = ((x & 0x3333) << 2) | ((x & 0xCCCC) >> 2) x = ((x & 0x0F0F) << 4) | ((x & 0xF0F0) >> 4) x = ((x & 0x00FF) << 8) | ((x & 0xFF00) >> 8) elif width == 32: x = ((x & 0x55555555) << 1) | ((x & 0xAAAAAAAA) >> 1) x = ((x & 0x33333333) << 2) | ((x & 0xCCCCCCCC) >> 2) x = ((x & 0x0F0F0F0F) << 4) | ((x & 0xF0F0F0F0) >> 4) x = ((x & 0x00FF00FF) << 8) | ((x & 0xFF00FF00) >> 8) x = ((x & 0x0000FFFF) << 16) | ((x & 0xFFFF0000) >> 16) else: raise ValueError('Unsupported width') return x def crc_poly(data, n, poly, crc=0, ref_in=False, ref_out=False, xor_out=0): g = 1 << n | poly # Generator polynomial # Loop over the data for d in data: # Reverse the input byte if the flag is true if ref_in: d = reflect_data(d, 8) # XOR the top byte in the CRC with the input byte crc ^= d << (n - 8) # Loop over all the bits in the byte for _ in range(8): # Start by shifting the CRC, so we can check for the top bit crc <<= 1 # XOR the CRC if the top bit is 1 if crc & (1 << n): crc ^= g # Reverse the output if the flag is true if ref_out: crc = reflect_data(crc, n) # Return the CRC value return crc ^ xor_out ##### end copy-paste 2 def _get_data_from_data_object(data_object): """ Helper function to either open file, or pass bytes through. """ data = None if type(data_object) == str: _, data = read_file(data_object) elif type(data_object) == bytes: data = data_object return data def _convert_dec_to_reverse_bytes(num_dec): """ Helper function to turn a given decimal value into the little-endian values for insertion into the data. """ # Here is how to do this manually. #num_str = str(hex(num_dec))[2:] #num_str = ("0" if len(num_str) % 2 else "") + num_str #num_bytes = bytearray.fromhex(num_str) #num_bytes.reverse() #return bytes(num_bytes) # I learned how to do this the pythonic way. return struct.pack('15 return {}, f"invalid way to reference weapon: [{type(weapon)}]. Use index or name." def get_purchased_weapons(data_object,profile_id,silent=False): """ For the given profile, return which weapons are already purchased, as a comma-separated string and a bitmask. """ data = _get_data_from_data_object(data_object) weapons_purchased = struct.unpack_from('<1i',data,PROFILE_START_POSITION[profile_id]+POS_BYTES_WEAPONS_PURCHASED)[0] #print(f"debug: got weapons_purchased 0x{weapons_purchased:0x}") all_weapons_mask = [i for i in WEAPONS if i["name"] == "all"][0]["p"] none_weapons_mask = [i for i in WEAPONS if i["name"] == "none"][0]["p"] # short-circuit if all if weapons_purchased & all_weapons_mask == all_weapons_mask: return "all", all_weapons_mask # short-circuit if none elif weapons_purchased | none_weapons_mask == 0: return "none", none_weapons_mask weapons_list = [] weapons_mask = 0x0 for i in WEAPONS: #result = weapons_purchased & i["p"] #print(f"debug: checking {i['p']:x} \"{i['name']}\" bitwise against {weapons_purchased:x}: {result:x}") if weapons_purchased & i["p"] and (i["name"] not in ["all","none"]): weapons_list.append(i["name"]) weapons_mask += i["p"] if not silent: print(f"debug: currently have 0x{weapons_mask:04x} b{weapons_mask:016b}, {weapons_list}") return ','.join(weapons_list), weapons_mask def get_level_status(data_object,profile_id,level,silent=False): """ WORKHERE: need to write set methods for this. """ data = _get_data_from_data_object(data_object) level_obj, message = get_level_info(level) if message != "" or level == -1: ferror(f"Unable to get level status for {level}.") levelset_obj, message = get_levelset_info(level_obj["setid"]) if message != "" or levelset_obj == -1: ferror(f"Unable to get levelset status for {level_obj['setid']}.") if not silent: print(f"Debug: for input {level} found level_obj {level_obj} and message {message}") pos_level_status = PROFILE_START_POSITION[profile_id]+POS_LEVEL_START+(INT_SIZE*level_obj["pos_r"]) pos_level_balloons = PROFILE_START_POSITION[profile_id]+POS_LEVEL_BALLOONS+(level_obj["setid"]*POS_LEVEL_BALLOONS_MULTIPLIER_LEVELSET)+(POS_LEVEL_BALLOONS_MULTIPLIER_LEVEL*level_obj["set_pos"]) pos_level_which_balloons = pos_level_balloons + INT_SIZE pos_level_letters = PROFILE_START_POSITION[profile_id]+POS_LEVEL_LETTERS+(INT_SIZE*level_obj["set_pos"])+(INT_SIZE*POS_LEVEL_LETTERS_MULTIPLIER_LEVELSET*level_obj["setid"]) profile_level_status = data[pos_level_status] profile_level_balloons = data[pos_level_balloons] profile_level_which_balloons = struct.unpack_from('<1i',data,pos_level_which_balloons)[0] profile_level_letters = data[pos_level_letters] if not silent: print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_status:04x} level status 0x{profile_level_status:x}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_balloons:04x} collected balloon count: {profile_level_balloons}/{level_obj['b']}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_which_balloons:04x} which balloons: b{profile_level_which_balloons:010b}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_letters:04x} letter count: {profile_level_letters}/{len(level_obj['l'])}") # WORKHERE: show which balloons are collected already for this level? # it comes back as an int, but does it look better as a hex? return hex(profile_level_status) def set_level_status(data_object,profile_id,level,status): data = _get_data_from_data_object(data_object) level_obj, message = get_level_info(level) if message != "" or level == -1: ferror(f"Unable to get level status for {level}.") current_status = get_level_status(data, profile_id, level, silent=True) print(f"Before changing, level {level} has status {current_status}") bits = 0x0 try: bits = [i for i in LEVEL_STATUSES if i["name"]==status][0]["b"] except: return data, "", f"unable-to-set-level-completion-status-{status}" print(f"debug: will try to set bits {bits:02x}") data = srb_pack('<1i',data,PROFILE_START_POSITION[profile_id]+POS_LEVEL_START+(INT_SIZE*level_obj["pos_r"]),bits) current_status = get_level_status(data, profile_id, level, silent=True) return data, current_status, "" def set_level_balloons(data_object,profile_id,level,count): data = _get_data_from_data_object(data_object) level_obj, message = get_level_info(level) if message != "" or level == -1: ferror(f"Unable to get level status for {level}.") # simplify the task; does a user really need to set individual balloons?! if count not in ["all","none"]: return -1, f"Can only set balloons to all or none for a level." pos_level_balloons = PROFILE_START_POSITION[profile_id]+POS_LEVEL_BALLOONS+(level_obj["setid"]*POS_LEVEL_BALLOONS_MULTIPLIER_LEVELSET)+(POS_LEVEL_BALLOONS_MULTIPLIER_LEVEL*level_obj["set_pos"]) pos_level_which_balloons = pos_level_balloons + INT_SIZE #profile_level_balloons = data[pos_level_balloons] #profile_level_which_balloons = struct.unpack_from('<1i',data,pos_level_which_balloons)[0] #print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_balloons:04x} collected balloon count: {profile_level_balloons}/{level_obj['b']}") #print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_level_which_balloons:04x} which balloons: b{profile_level_which_balloons:010b}") if count == "all": target_count = 10 target_which = 0x3FF # b1111111111, or bitmask that is 10 bits long else: target_count = 0 target_which = 0x0 data = srb_pack('<1i',data,pos_level_balloons,target_count) data = srb_pack('<1i',data,pos_level_which_balloons,target_which) return data, "" def get_levelset_status(data_object,profile_id,levelset): """ WORKHERE: need to make set methods. """ data = _get_data_from_data_object(data_object) levelset_obj, message = get_levelset_info(levelset) if message != "" or levelset == -1: ferror(f"Unable to get levelset status for {levelset_obj['setid']}.") print(f"Debug: got levelset {levelset_obj}") pos_levelset_completed_letters_count = PROFILE_START_POSITION[profile_id]+POS_LEVELSET_COMPLETED_LETTERS_COUNT+(INT_SIZE*2*levelset_obj["id"]) pos_levelset_completed_letters_mask = pos_levelset_completed_letters_count + INT_SIZE pos_levelset_completed_mission_mask = PROFILE_START_POSITION[profile_id]+POS_LEVELSET_COMPLETED_MISSIONS_MASK+(INT_SIZE*levelset_obj["id"]) profile_levelset_completed_letters_count = data[pos_levelset_completed_letters_count] # for some reason bit 1 is not used, so bitshift right 1. profile_levelset_completed_letters_mask = struct.unpack_from('<1i',data,pos_levelset_completed_letters_mask)[0] >> 1 # python trick to reverse a custom-formatted string and convert back to int while reading the string as base 2 profile_levelset_completed_letters_mask = int(f"{profile_levelset_completed_letters_mask:0{len(levelset_obj['le'])}b}"[::-1],2) profile_levelset_completed_mission_mask = data[pos_levelset_completed_mission_mask] profile_levelset_status = f"b{profile_levelset_completed_mission_mask:06b}" collected_balloons_for_levelset, message = get_collected_balloons_for_levelset(data,profile_id,levelset,silent=True) if message != "": ferror("Unable to count collected balloons.") print(f"Debug: levelset {levelset_obj['id']},\"{levelset_obj['name']:23s}\" has mission mask: b{pow(2,levelset_obj['l'])-1:06b}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_levelset_completed_mission_mask:04x} levelset_obj {levelset_obj['id']} completed missions: {profile_levelset_status}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_levelset_completed_letters_count:04x} completed letter count: {profile_levelset_completed_letters_count}/{len(levelset_obj['le'])}") print(f"Debug: abs 0x{CHECKSUM_LENGTH+pos_levelset_completed_letters_mask:04x} completed letter mask: b{profile_levelset_completed_letters_mask:0{len(levelset_obj['le'])}b}") print(f"Debug: collected balloons: {collected_balloons_for_levelset}/{levelset_obj['b']} {levelset_obj['c']}") completed_letters = list(levelset_obj['le']) x = 0 for i in f"{profile_levelset_completed_letters_mask:0{len(levelset_obj['le'])}b}": completed_letters[x] = completed_letters[x].upper() if int(i) == 1 else completed_letters[x] x += 1 completed_letters = ''.join(completed_letters) print(f"Debug: letters, uppercase is collected: {completed_letters}") # it comes back as an int, but does it look better as a hex? return profile_levelset_status def get_collected_balloons_for_levelset(data_object,profile_id,levelset,silent=False): data = _get_data_from_data_object(data_object) levelset_obj, message = get_levelset_info(levelset) if message != "": ferror(f"For balloon count unable to get levelset for {levelset}.") return -1, "failed" levels = [i for i in LEVELS if i["setid"] == levelset_obj["id"]] x = 0 profile_balloons = 0 while x < levelset_obj["l"]: level_obj = [i for i in levels if i["set_pos"] == x][0] pos_level_balloons = PROFILE_START_POSITION[profile_id]+POS_LEVEL_BALLOONS+(level_obj["setid"]*POS_LEVEL_BALLOONS_MULTIPLIER_LEVELSET)+(POS_LEVEL_BALLOONS_MULTIPLIER_LEVEL*level_obj["set_pos"]) profile_level_balloons = data[pos_level_balloons] profile_balloons += profile_level_balloons x += 1 if not silent: print(f"Debug: for levelset {levelset_obj['id']}, collected {profile_balloons} balloons.") return profile_balloons, "" def get_level_info(level): """ Returns dictionary of level from LEVELS, searching by id or name. """ try: # if it is an integer, make sure it shows up as one in the next check level = int(level) except: pass if type(level) == str: try: level = [i for i in LEVELS if i["name"].lower() == level.lower()][0] return level, "" except: return -1, f"cannot find level {level}" elif type(level) == int: if level in range(0,len(LEVELS)+1): try: level = [i for i in LEVELS if i["id"] == level][0] return level, "" except: return -1, f"cannot find level by id {level}" else: return -1, f"invalid level index {level}; use 0-{len(LEVELS)}" return -1, f"invalid way to reference level: [{type(level)}]. Use id or name." def get_levelset_info(levelset): """ Returns dictionary of levelset from LEVELSETS with the addition of the letters and balloon counts from the associated missions. Search by id or name. """ try: # if it is an integer, make sure it shows up as one in the next check levelset = int(levelset) except: pass if type(levelset) == str: try: levelset = [i for i in LEVELSETS if i["name"].lower() == levelset.lower()][0] except: return -1, f"cannot find levelset {levelset}" elif type(levelset) == int: if levelset in range(0,len(LEVELSETS)+1): try: levelset = [i for i in LEVELSETS if i["id"] == levelset][0] #return levelset, "valid" except: return -1, f"cannot find levelset by id {levelset}" else: return -1, f"invalid levelset index {levelset}; use 0-{len(LEVELSETS)}" if type(levelset) != dict: return -1, f"invalid way to reference levelset: [{type(levelset)}]. Use id or name." # so now we have the dictionary and we need to add the letters and balloons these_levels = [i for i in LEVELS if i["setid"] == levelset["id"]] # thankfully every levelset has letters. Not all levelsets have balloons but we can check on only this. if len(levelset["le"]) == 0: x = 0 while x < len(these_levels): this_level = [i for i in these_levels if i["set_pos"] == x][0] levelset["le"] += this_level["l"] # because we only add balloon count under that if statement. levelset["b"] += this_level["b"] x = x + 1 return levelset, "" def get_name(data_object,profile_id): """ Print the name of the profile_id. """ data = _get_data_from_data_object(data_object) name_array = struct.unpack_from('<8i',data,PROFILE_START_POSITION[profile_id]+POS_NAME) #print(f"debug: name_array: {name_array}, type {type(name_array)}") name_str = "" for i in name_array: name_str += NAME_CHARS[i] return name_str def set_name(data_object,profile_id,new_name): """ Set the name of the profile_id. Note that this allows you to set a name for a profile that is not in use, which preseeds the name when you select to make a profile in this slot in-game. """ data = _get_data_from_data_object(data_object) # convert new_name to tuple of integer values name_array = [] for i in new_name.upper(): try: j = NAME_CHARS.index(i) except ValueError: return -1, f"Invalid characters in requested name {new_name}." name_array.append(j) # right-pad with spaces while len(name_array) < 8: name_array.append(0) if len(name_array) > 8 or len(name_array) < 0: return -1, f"Invalid length {len(name_array)} of name {new_name}." x = 0 # How I was doing this before: #data_bytearray = bytearray(data) #for i in name_array: # struct.pack_into('<1i',data_bytearray,PROFILE_START_POSITION[profile_id]+POS_NAME+(4*x),i) # x = x + 1 #data = bytes(data_bytearray) data = srb_pack('<8i',data,PROFILE_START_POSITION[profile_id]+POS_NAME+(INT_SIZE*x),*name_array) #print(f"debug: after setting name to {new_name}, we checked and got {get_name(data,profile_id)}") return data, "" def get_profile_in_use(data_object,profile_id): """ Print if the profile_id is in use. """ data = _get_data_from_data_object(data_object) # it always comes as a tuple in_use = struct.unpack_from('<1?',data,PROFILE_START_POSITION[profile_id]+POS_PROFILE_IN_USE)[0] #print(f"debug: in_use: {in_use}, type {type(in_use)}") return in_use def get_tutorial_completed(data_object,profile_id): """ Print if the profile has completed the tutorial. """ data = _get_data_from_data_object(data_object) # it always comes as a tuple tutorial_completed = struct.unpack_from('<1?',data,PROFILE_START_POSITION[profile_id]+POS_TUTORIAL_COMPLETED)[0] return tutorial_completed def set_tutorial_completed(data_object,profile_id,completed): """ Set tutorial-completed for given profile. """ data = _get_data_from_data_object(data_object) data = srb_pack(' # crc width=32 poly=0x4c11db7 init=0x0 xorout=0x235b4b9c refin=false refout=false out_endian=little csum = hex(crc_poly(data, 32, 0x4C11DB7, crc=0x0, ref_in=False, ref_out=False, xor_out=0x235b4b9c)) # comes in as ceb434d4, but needs to be d434b4ce, and bytearray is convenient way to swap it like that # trim off the '0x' from the string print(f"debug: Got csum={csum}") # pad any odd-digit-count hex value with a leading 0 for bytearray.fromhex() which is not very smart. csum = ("0" if len(str(csum)) % 2 else "") + str(csum)[2:] csum = bytearray.fromhex(csum) csum.reverse() # and back to bytes because that is how we will want to use it. return bytes(csum) def correct_file(filename, debuglevel = 0): """ Given a savegame file, calculate the correct checksum and store that sum instead of whatever is there. """ cs, data = read_file(filename) csum = calculate_checksum(data) ran = False if csum != cs: if debuglev(5,debuglevel): ferror(f"Stored checksum is {cs} and will update it to {csum}") write_file(filename,csum,data) ran = True else: if debuglev(2,debuglevel): ferror(f"Stored checksum is still correct, {csum}. Skipping {filename}") return ran