diff options
author | B. Stack <bgstack15@gmail.com> | 2024-03-11 17:13:40 -0400 |
---|---|---|
committer | B. Stack <bgstack15@gmail.com> | 2024-03-11 17:13:40 -0400 |
commit | 3adb7c553a8c8c2bad21940e9d3c8f35ea6ac94a (patch) | |
tree | 7a666f067633afe64c46e054652e5cddbde0e0b4 | |
parent | add get/set name (diff) | |
download | srb_lib-3adb7c553a8c8c2bad21940e9d3c8f35ea6ac94a.tar.gz srb_lib-3adb7c553a8c8c2bad21940e9d3c8f35ea6ac94a.tar.bz2 srb_lib-3adb7c553a8c8c2bad21940e9d3c8f35ea6ac94a.zip |
get/set purchased weapons
-rwxr-xr-x | srb.py | 37 | ||||
-rw-r--r-- | srb_lib.py | 193 |
2 files changed, 177 insertions, 53 deletions
@@ -23,10 +23,16 @@ import srb_lib, argparse, sys from srb_lib import ferror, debuglev +HELP_WEAPONS = "" + +for w in srb_lib.WEAPONS: + HELP_WEAPONS += str(w["id"]) + "," + w["name"] + ',' +HELP_WEAPONS = HELP_WEAPONS.rstrip(",") + parser = argparse.ArgumentParser(description="Cli tool for manipulating savegame files for srb.exe", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f"""LEVELS include {[i['name'] for i in srb_lib.LEVELS]+list(range(0,len(srb_lib.LEVELS)))} -WEAPONS include {[i for i in srb_lib.WEAPONS if i != "undefined"]+list(range(0,16))} +WEAPONS include {HELP_WEAPONS} NAME_CHARS include "{srb_lib.NAME_CHARS}" """) parser.add_argument("-V","--version",action="version",version="%(prog)s " + srb_lib.srb_lib_version) @@ -35,14 +41,19 @@ parser.add_argument("--profile",type=int,choices=range(1,4),help="Profile in use parser.add_argument("--get-money",action="store_true",help="Print current money for profile.") parser.add_argument("--set-money",type=int,help="Set money for profile.") parser.add_argument("--get-weapon",action="store_true",help="Print currently equipped weapon for profile.") -# choices seems to be too strict here for the numbers. We can live with just the +# choices seems to be too strict here for the numbers. We can live with just the #choices=[i for i in srb_lib.WEAPONS if i != "undefined"]+list(range(0,16)) #parser.add_argument("--set-weapon",choices=[i for i in srb_lib.WEAPONS if i != "undefined"],help="Print currently equipped weapon for profile.") parser.add_argument("--set-weapon",help="Set currently equipped weapon for profile.") +parser.add_argument("--get-purchased-weapons",action="store_true",help="Print currently purchased weapon for profile.") +parser.add_argument("--add-purchased-weapons",action="append",help="For profile, add these purchased weapons. Can be used multiple times.") +parser.add_argument("--remove-purchased-weapons",action="append",help="For profile, add these purchased weapons. Can be used multiple times.") parser.add_argument("--get-level",help="Print status for this level for profile.") parser.add_argument("--get-name",action="store_true",help="Print name for profile.") parser.add_argument("--set-name",help="Set name for profile.") parser.add_argument("--get-profile-in-use",action="store_true",help="Print if profile is in use.") +parser.add_argument("--get-tutorial-completed",action="store_true",help="Print if profile has completed the tutorial.") +parser.add_argument("--set-tutorial-completed",choices=["True","False"],help="Set tutorial-completed for profile.") parser.add_argument("--checksum",action=argparse.BooleanOptionalAction,default=True,help="Correct checksum. Default is to do this. It happens at the end of everything else.") parser.add_argument("file",default="Profile 1.sav") args = parser.parse_args() @@ -61,7 +72,7 @@ profile_id = args.profile #print(f"profile_id={profile_id}") # WORKHERE: new actions that need --profile must be added here. -if not profile_id and (args.get_money or args.set_money or args.get_weapon or args.set_weapon or args.get_level or args.get_name or args.set_name or args.get_profile_in_use): +if not profile_id and (args.get_money or args.set_money or args.get_weapon or args.set_weapon or args.get_level or args.get_name or args.set_name or args.get_profile_in_use or args.get_purchased_weapons or args.get_tutorial_completed or args.add_purchased_weapons or args.remove_purchased_weapons): ferror("Warning: Cannot perform most actions without --profile. Not all tasks may run.") else: if args.get_money: @@ -98,6 +109,26 @@ else: srb_lib.write_file(args.file,0,data) if args.get_profile_in_use: print(f"Profile {profile_id} in use is {srb_lib.get_profile_in_use(args.file,profile_id)}") + if args.get_purchased_weapons: + print(f"Profile {profile_id} has weapons {srb_lib.get_purchased_weapons(args.file,profile_id)}") + if args.add_purchased_weapons: + data, message = srb_lib.set_purchased_weapons(args.file,profile_id,"add",args.add_purchased_weapons) + if (type(data) == int and data == -1) or message != "": + ferror(f"Failed to add purchased weapons {args.add_purchased_weapons} because {message}") + else: + srb_lib.write_file(args.file,0,data) + if args.remove_purchased_weapons: + data, message = srb_lib.set_purchased_weapons(args.file,profile_id,"remove",args.remove_purchased_weapons) + if (type(data) == int and data == -1) or message != "": + ferror(f"Failed to add purchased weapons {args.remove_purchased_weapons} because {message}") + else: + srb_lib.write_file(args.file,0,data) + if args.get_tutorial_completed: + print(f"Profile {profile_id} completed-tutorial is {srb_lib.get_tutorial_completed(args.file,profile_id)}") + if args.set_tutorial_completed: + thisbool = False if args.set_tutorial_completed == "False" else True + data, newstatus = srb_lib.set_tutorial_completed(args.file,profile_id,thisbool) + srb_lib.write_file(args.file,0,data) if args.checksum: f = args.file @@ -20,7 +20,7 @@ # Dependencies: import sys, struct -srb_lib_version = "20240310a" +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 "Z<dddddddd" start. @@ -32,28 +32,29 @@ POS_NAME = 0x294 NAME_CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!?" -# stinger purchased is relative 0x28C, value 0x40 (b01000000) or 64 -# water-balloon-gun is relative 0x2D, value 0x0001 -# stinger and potato gun is relative 0x28C, value 0x60, so potato gun is (b00100000) or 64 -#POS_STINGER = 0x028C - +POS_BYTES_WEAPONS_PURCHASED = 0x028C +# Woodstock Missile and Bottle Rockets are always available to use. +# for equipped-weapon +# id = value of equipped byte +# e = equippable +# p = purchase flag, where in the two bytes (when looking at it big-endian) the entry is. WEAPONS = [ - "undefined", # 0x0 - "undefined", # secondary weapon is a single machine gun?! - "undefined", - "undefined", - "undefined", - "Potato Gun", # 0x5 - "Stinger", - "Woodstock Missile", - "Water Balloon Cannon", - "Snow Blower", - "Fire Boomerang", # 0xA - "Lightning Rod", - "Bottle Rockets", - "Roman Candles", - "10 Gauge Pumpkin", - "undefined", # 0xF + {"id":0x0,"e":False,"p":0x0, "name":"none"}, # custom for this library + {"id":0x1,"e":False,"p":0x0, "name":"machine-gun-single"}, # not part of the game, but works if you set manually + {"id":0x2,"e":False,"p":0x0, "name":"machine-gun-double"}, # not part of the game, but works if you set manually + {"id":0x3,"e":False,"p":0x0, "name":"machine-gun-triple"}, # not part of the game, but works if you set manually + {"id":0x4,"e":False,"p":0x0, "name":"machine-gun-four"}, + {"id":0x5,"e":True, "p":0x0020,"name":"Potato Gun"}, # always available to buy + {"id":0x6,"e":True, "p":0x0040,"name":"Stinger"}, # always available to buy + {"id":0x7,"e":True, "p":0x0, "name":"Woodstock Missile"}, # always available to player + {"id":0x8,"e":True, "p":0x0100,"name":"Water Balloon Cannon"}, + {"id":0x9,"e":True, "p":0x0200,"name":"Snow Blower"}, + {"id":0xA,"e":True, "p":0x0400,"name":"Fire Boomerang"}, + {"id":0xB,"e":True, "p":0x0800,"name":"Lightning Rod"}, + {"id":0xC,"e":True, "p":0x0, "name":"Bottle Rockets"}, # always available to player + {"id":0xD,"e":True, "p":0x2000,"name":"Roman Candles"}, + {"id":0xE,"e":True, "p":0x4000,"name":"10 Gauge Pumpkin"}, + {"id":0xF,"e":False,"p":0x6F60,"name":"all"}, # custom for this library ] # WORKHERE: position is for where the balloon info is stored for this levelset, if it is stored here and not per-level. @@ -107,6 +108,8 @@ LEVEL_STATUSES = { "64": "all-balloons,all-letters,general" } +POS_TUTORIAL_COMPLETED = 0x2D0 # bool + # DEFINE FUNCTIONS def ferror(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) @@ -222,45 +225,85 @@ def set_money(data_object,profile_id, money_dec): """ Using data, set the money given in decimal for the given profile_id. """ data = _get_data_from_data_object(data_object) #money_bytes = _convert_dec_to_reverse_bytes(money_dec) - #pos_money_low = PROFILE_START_POSITION[profile_id]+POS_MONEY - data_bytearray = bytearray(data) - struct.pack_into('<i',data_bytearray,PROFILE_START_POSITION[profile_id]+POS_MONEY,money_dec) - data = bytes(data_bytearray) + data = srb_pack('<i',data,PROFILE_START_POSITION[profile_id]+POS_MONEY,money_dec) print(f"after setting money to {money_dec}, we checked and got {get_money(data,profile_id)}") return data def get_weapon(data_object,profile_id): + """ For given profile, print currently equipped weapon. """ data = _get_data_from_data_object(data_object) - weapon = data[PROFILE_START_POSITION[profile_id]+POS_EQUIPPED_WEAPON] - return WEAPONS[weapon] + weapon_id = data[PROFILE_START_POSITION[profile_id]+POS_EQUIPPED_WEAPON] + weapon_id = int(weapon_id) + try: + weapon, message = get_weapon_info(weapon_id) + except: + return f"unable to determine weapon" + if message != "": + return f"failed to get weapon due to {message}" + print(f"debug: got weapon {weapon}") + return weapon["name"] def set_weapon(data_object,profile_id,weapon): data = _get_data_from_data_object(data_object) - data_bytearray = bytearray(data) - weapon, weapon_name = get_weapon_info(weapon) - if weapon == -1: - ferror(f"Warning: cannot set weapon to {weapon}, because {weapon_name}") + weapon, message = get_weapon_info(weapon) + if (not bool(weapon)) or message != "": + ferror(f"Warning: cannot set weapon to {weapon}, because {message}") return -1 # armed with weapon as the index number, let's change the savegame data - struct.pack_into('<i',data_bytearray,PROFILE_START_POSITION[profile_id]+POS_EQUIPPED_WEAPON,weapon) - data = bytes(data_bytearray) - print(f"after setting weapon to {WEAPONS[weapon]}, we checked and got {get_weapon(data,profile_id)}") + data = srb_pack('<i',data,PROFILE_START_POSITION[profile_id]+POS_EQUIPPED_WEAPON,weapon["id"]) + print(f"after setting weapon to {weapon['name']}, we checked and got {get_weapon(data,profile_id)}") return data def get_weapon_info(weapon): - """ Returns index, and name of weapon, or -1 and "invalid" """ + """ + Returns tuple of (weapon object, message) searching by name or id. Message is empty if the weapon object is returned. + """ + try: + weapon = int(weapon) + except: + pass if type(weapon) == str: try: - weapon_id = [index for index in range(len(WEAPONS)) if WEAPONS[index].lower() == weapon.lower()][0] - return weapon_id, WEAPONS[weapon_id] + weapon_obj = [i for i in WEAPONS if i["name"].lower() == weapon.lower()][0] + #weapon_id = [index for index in range(len(WEAPONS)) if WEAPONS[index].lower() == weapon.lower()][0] + return weapon_obj, "" except: - return -1, f"cannot find weapon {weapon}" # must be an incorrect name + return {}, f"cannot find weapon {weapon}" # must be an incorrect name elif type(weapon) == int: if weapon in range(0,16): - return weapon, WEAPONS[weapon] + weapon_obj = [i for i in WEAPONS if i["id"] == weapon][0] + return weapon_obj, "" + #return weapon, WEAPONS[weapon] else: - return -1, f"invalid index {weapon}; use 0-15" # must be <0 or >15 - return -1, f"invalid way to reference weapon: [{type(weapon)}]. Use index or name." + return {}, f"invalid index {weapon}; use 0-15" # must be <0 or >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. """ @@ -310,7 +353,6 @@ def get_name(data_object,profile_id): 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) - data_bytearray = bytearray(data) # convert new_name to tuple of integer values name_array = [] for i in new_name.upper(): @@ -325,11 +367,13 @@ def set_name(data_object,profile_id,new_name): if len(name_array) > 8 or len(name_array) < 0: return -1, f"Invalid length {len(name_array)} of name {new_name}." x = 0 - # for some reason I cannot figure out '<8i' with a tuple or list of ints. So just do it manually. - 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) + # 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, "" @@ -341,6 +385,53 @@ def get_profile_in_use(data_object,profile_id): #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('<?',data,PROFILE_START_POSITION[profile_id]+POS_TUTORIAL_COMPLETED,bool(completed)) + return data, get_tutorial_completed(data,profile_id) + +def set_purchased_weapons(data_object,profile_id,action,weapons_list): + """ For the given profile, take action on weapons_list, where action is in ["add","remove"] from the player. """ + data = _get_data_from_data_object(data_object) + if action not in ["add","remove"]: + return -1, f"Failed: can only [\"add\",\"remove\"] purchased weapons" + # validate weapons_list + action_mask = 0x0 + for w in weapons_list: + weapon, message = get_weapon_info(w) + if message != "": + return -1, f"unable to {action} weapons because {message} on weapon {w}" + action_mask = action_mask | weapon["p"] + cur_weapons, cur_mask = get_purchased_weapons(data,profile_id) + #print(f"debug: action_mask(type {type(action_mask)})={action_mask}") + #print(f"debug: cur_mask(type {type(cur_mask)})={cur_mask}") + #print(f"debug: need to {action}-combine {cur_mask:016b} and {action_mask:016b}") + if action == "add": + final_mask = cur_mask | action_mask + elif action == "remove": + final_mask = cur_mask & ~action_mask + if final_mask != cur_mask: + print(f"debug: beginning 0x{cur_mask:04x}, b{cur_mask:016b} {cur_weapons}") + print( f"debug: {action:6s} 0x{action_mask:04x}, b{action_mask:016b} {','.join(weapons_list)}") + data = srb_pack('<1i',data,PROFILE_START_POSITION[profile_id]+POS_BYTES_WEAPONS_PURCHASED, final_mask) + print( f"debug: final 0x{final_mask:04x}, b{final_mask:016b} {get_purchased_weapons(data,profile_id,silent=True)[0]}") + # if we make it to the end + return data, "" + +def srb_pack(format_str, data, offset, *new_contents): + """ Helper function that accepts data as bytes, instead of requiring bytesarray. """ + data_bytearray = bytearray(data) + struct.pack_into(format_str,data_bytearray,offset,*new_contents) + return bytes(data_bytearray) + def calculate_checksum(data): """ Return the 4-byte checksum used by the game for the provided data. """ # aka # CRC-32/BZIP2 @@ -350,8 +441,10 @@ def calculate_checksum(data): 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"Got csum={csum}") - csum = bytearray.fromhex(str(csum)[2:]) + 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) |