#!/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 = "20240311a" # 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. # money is 0x270 bytes after the "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): """ WORKHERE: not sure how to use this, but making sure my position in the data works. """ data = _get_data_from_data_object(data_object) level_obj, message = get_level_info(level) if message != "valid" or level == -1: ferror(f"Unable to get level status for {level}.") print(f"Debug: got level_obj {level_obj} and message {message}") profile_level_status = data[PROFILE_START_POSITION[profile_id]+POS_LEVEL_START+(4*level_obj["pos_r"])] # it comes back as an int, but does it look better as a hex? return hex(profile_level_status) 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, "valid" 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, "valid" 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_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+(4*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