From b4a209c0eecfe0383c584050181f05197b9ca457 Mon Sep 17 00:00:00 2001 From: Ariescyn Date: Tue, 20 Dec 2022 17:56:58 -0500 Subject: [PATCH] v1.64 final --- SaveManager.py | 920 +++++++++++++++++++++++++++++---------------- data/changelog.txt | 8 +- hexedit.py | 81 +++- os_layer.py | 6 +- 4 files changed, 665 insertions(+), 350 deletions(-) diff --git a/SaveManager.py b/SaveManager.py index 951fa79..2cc1215 100644 --- a/SaveManager.py +++ b/SaveManager.py @@ -10,11 +10,7 @@ import subprocess, os, zipfile, requests, re, time, hexedit, webbrowser, itemdata, lzma, datetime, json from os_layer import * from pathlib import Path as PATH - -# TODO + Notes -# Replace all nested pop_up functions with new popup impplementation -# Left off with displaying file paths when opening save files in - +#Collapse all functions to navigate. In Atom editor: "crtl + ALT + ] To Close" @@ -23,8 +19,6 @@ os.chdir(os.path.dirname(os.path.abspath(__file__))) - - class Config: def __init__(self): @@ -85,44 +79,112 @@ def delete_custom_id(self, k): with open(config_path, 'w') as f: json.dump(self.cfg, f) -def archive_file(file, name, metadata, names): - name = name.replace(" ", "_") - if not os.path.exists(file): # If you try to load a save from listbox, and it tries to archive the file already present in the gamedir, but it doesn't exist, then skip - return - now = datetime.datetime.now() - date = now.strftime("%Y-%m-%d__(%I.%M.%S)") - name = f"{name}__{date}" - os.makedirs(f"./data/archive/{name}") +# ///// UTILITIES ///// + +def popup(text, command=None, functions=False, buttons=False, button_names=("Yes", "No"), b_width=(6,6), title="Manager", parent_window=None): + """text: Message to display on the popup window. + command: Simply run the windows CMD command if you press yes. + functions: Pass in external functions to be executed for yes/no""" + def run_cmd(): + cmd_out = run_command(command) + popupwin.destroy() + if cmd_out[0] == "error": + popupwin.destroy() + + def dontrun_cmd(): + popupwin.destroy() + + def run_func(arg): + arg() + popupwin.destroy() + if parent_window is None: + parent_window = root + popupwin = Toplevel(parent_window) + popupwin.title(title) + # popupwin.geometry("200x75") + lab = Label(popupwin, text=text) + lab.grid(row=0, column=0, padx=5, pady=5, columnspan=2) + # Places popup window at center of the root window + x = parent_window.winfo_x() + y = parent_window.winfo_y() + popupwin.geometry("+%d+%d" % (x + 200, y + 200)) + + # Runs for simple windows CMD execution + if functions is False and buttons is True: + but_yes = Button( + popupwin, text=button_names[0], borderwidth=5, width=b_width[0], command=run_cmd + ).grid(row=1, column=0, padx=(10, 0), pady=(0, 10)) + but_no = Button( + popupwin, text=button_names[1], borderwidth=5, width=b_width[1], command=dontrun_cmd + ).grid(row=1, column=1, padx=(10, 10), pady=(0, 10)) + + elif functions is not False and buttons is True: + but_yes = Button( + popupwin, + text=button_names[0], + borderwidth=5, + width=b_width[0], + command=lambda: run_func(functions[0]), + ).grid(row=1, column=0, padx=(10, 0), pady=(0, 10)) + but_no = Button( + popupwin, + text=button_names[1], + borderwidth=5, + width=b_width[1], + command=lambda: run_func(functions[1]), + ).grid(row=1, column=1, padx=(10, 10), pady=(0, 10)) + # if text is the only arguement passed in, it will simply be a popup window to display text - with open(file, "rb") as fhi, lzma.open(f"./data/archive/{name}/ER0000.xz", 'w') as fho: - fho.write(fhi.read()) - names = [i for i in names if not i is None] - formatted_names = ", ".join(names) - meta = f"{metadata}\nCHARACTERS: {formatted_names}" - meta_ls = [i for i in meta] +def archive_file(file, name, metadata, names): try: - x = meta.encode("ascii") # Will fail with UnicodeEncodeError if special characters exist - with open(f"./data/archive/{name}/info.txt", 'w') as f: - f.write(meta) - except: - for ind,i in enumerate(meta): - try: - x = i.encode("ascii") - meta_ls[ind] = i - except: - meta_ls[ind] = '?' - fixed_meta = "" - for i in meta_ls: - fixed_meta = fixed_meta + i + name = name.replace(" ", "_") + + if not os.path.exists(file): # If you try to load a save from listbox, and it tries to archive the file already present in the gamedir, but it doesn't exist, then skip + return - with open(f"./data/archive/{name}/info.txt", 'w') as f: - f.write(fixed_meta) + + now = datetime.datetime.now() + date = now.strftime("%Y-%m-%d__(%I.%M.%S)") + name = f"{name}__{date}" + os.makedirs(f"./data/archive/{name}") + + + with open(file, "rb") as fhi, lzma.open(f"./data/archive/{name}/ER0000.xz", 'w') as fho: + fho.write(fhi.read()) + names = [i for i in names if not i is None] + formatted_names = ", ".join(names) + meta = f"{metadata}\nCHARACTERS: {formatted_names}" + + meta_ls = [i for i in meta] + try: + x = meta.encode("ascii") # Will fail with UnicodeEncodeError if special characters exist + with open(f"./data/archive/{name}/info.txt", 'w') as f: + f.write(meta) + except: + for ind,i in enumerate(meta): + try: + x = i.encode("ascii") + meta_ls[ind] = i + except: + meta_ls[ind] = '?' + fixed_meta = "" + for i in meta_ls: + fixed_meta = fixed_meta + i + + with open(f"./data/archive/{name}/info.txt", 'w') as f: + f.write(fixed_meta) + + except Exception as e: + traceback.print_exc() + str_err = "".join(traceback.format_exc()) + popup(str_err) + return def unarchive_file(file): @@ -147,144 +209,68 @@ def grab_metadata(file): popup(meta) -def change_default_steamid(): - - - def done(): - s_id = ent.get() - if not len(s_id) == 17: - popup("SteamID should be 17 digits long") - return - config.set("steamid", s_id) - - - popup("Successfully changed default SteamID") - popupwin.destroy() - - def cancel(): - popupwin.destroy() - - def validate(P): - if len(P) == 0: - return True - elif len(P) < 18 and P.isdigit(): - return True - else: - # Anything else, reject it - return False - - popupwin = Toplevel(root) - popupwin.title("Set SteamID") - vcmd = (popupwin.register(validate), "%P") - # popupwin.geometry("200x70") - - s_id = config.cfg["steamid"] - lab = Label(popupwin, text=f"Current ID: {s_id}\nEnter new ID:") - lab.grid(row=0, column=0) - ent = Entry(popupwin, borderwidth=5, validate="key", validatecommand=vcmd) - ent.grid(row=1, column=0, padx=25, pady=10) - x = root.winfo_x() - y = root.winfo_y() - popupwin.geometry("+%d+%d" % (x + 200, y + 200)) - but_done = Button(popupwin, text="Done", borderwidth=5, width=6, command=done) - but_done.grid(row=2, column=0, padx=(25, 65), pady=(0, 15), sticky="w") - but_cancel = Button(popupwin, text="Cancel", borderwidth=5, width=6, command=cancel) - but_cancel.grid(row=2, column=0, padx=(70, 0), pady=(0, 15)) - - -def import_save(): - """Opens file explorer to choose a save file to import, Then checks if the files steam ID matches users, and replaces it with users id""" - - if os.path.isdir(savedir) is False: - os.makedirs(savedir) - d = fd.askopenfilename() - - if len(d) < 1: - return - - if not d.endswith(ext()): - popup("Select a valid save file!\nIt should be named: ER0000.sl2/ER0000.co2") - return - - - - - def cancel(): - popupwin.destroy() +def get_charnames(file): + """wrapper for hexedit.get_names""" - def done(): - name = ent.get().strip() - if len(name) < 1: - popup("No name entered.") - return - isforbidden = False - for char in name: - if char in "~'{};:./\,:*?<>|-!@#$%^&()+": - isforbidden = True - if isforbidden is True: - popup("Forbidden character used") - return - elif isforbidden is False: - entries = [] - for entry in os.listdir(savedir): - entries.append(entry) - if name.replace(" ", "-") in entries: - popup("Name already exists") - return + out = hexedit.get_names(file) + if out is False: + popup(f"Error: Unable to get character names.\nDoes the following path exist?\n{file}") + else: + return out +def finish_update(): + if os.path.exists("./data/GameSaveDir.txt"): # Legacy file for pre v1.5 versions + os.remove("./data/GameSaveDir.txt") + if config.post_update: # Will be ran on first launch after running update.exe + if not os.path.exists("./data/save-files-pre-V1.5-BACKUP"): # NONE OF THIS WILL BE RUN ON v1.5+ + try: + copy_folder(savedir, "./data/save-files-pre-V1.5-BACKUP") + except Exception as e: + traceback.print_exc() + str_err = "".join(traceback.format_exc()) + popup(str_err) - names = get_charnames(d) - archive_file(d, name, "ACTION: Imported", names) + for dir in os.listdir(savedir): # Reconstruct save-file structure for pre v1.5 versions - newdir = "{}{}/".format(savedir, name.replace(" ", "-")) - cp_to_saves_cmd = lambda: copy_file(d, newdir) + try: + id = re.findall(r"\d{17}", str(os.listdir(f"{savedir}{dir}/"))) + if len(id) < 1: + continue - if os.path.isdir(newdir) is False: - cmd_out = run_command(lambda: os.makedirs(newdir)) + shutil.move(f"{savedir}{dir}/{id[0]}/{ext()}", f"{savedir}{dir}/{ext()}") + for i in ["GraphicsConfig.xml", "notes.txt", "steam_autocloud.vdf"]: + if os.path.exists(f"{savedir}{dir}/{i}"): + os.remove(f"{savedir}{dir}/{i}") - if cmd_out[0] == "error": - print("---ERROR #1----") - return + delete_folder(f"{savedir}{dir}/{id[0]}") + except Exception as e: + traceback.print_exc() + str_err = "".join(traceback.format_exc()) + popup(str_err) + continue - lb.insert(END, " " + name) - cmd_out = run_command(cp_to_saves_cmd) - if cmd_out[0] == "error": - return - create_notes(name, "{}{}/".format(savedir, name.replace(" ", "-"))) - file_id = hexedit.get_id(f"{newdir}/{ext()}") - user_id = config.cfg["steamid"] - if len(user_id) < 17: - popupwin.destroy() - return - if file_id != int(user_id): - popup( - f"File SteamID: {file_id}\nYour SteamID: {user_id}", buttons=True, button_names=("Patch with your ID", "Leave it"), b_width=(15,8), functions=(lambda:hexedit.replace_id(f"{newdir}/{ext()}", int(user_id)), donothing) - ) - #hexedit.replace_id(f"{newdir}/ER0000.sl2", int(id)) +def ext(): + if config.cfg["seamless-coop"]: + return "ER0000.co2" + elif config.cfg["seamless-coop"] is False: + return "ER0000.sl2" - popupwin.destroy() - popupwin = Toplevel(root) - popupwin.title("Import") - # popupwin.geometry("200x70") - lab = Label(popupwin, text="Enter a Name:") - lab.grid(row=0, column=0) - ent = Entry(popupwin, borderwidth=5) - ent.grid(row=1, column=0, padx=25, pady=10) - x = root.winfo_x() - y = root.winfo_y() - popupwin.geometry("+%d+%d" % (x + 200, y + 200)) - but_done = Button(popupwin, text="Done", borderwidth=5, width=6, command=done) - but_done.grid(row=2, column=0, padx=(25, 65), pady=(0, 15), sticky="w") - but_cancel = Button(popupwin, text="Cancel", borderwidth=5, width=6, command=cancel) - but_cancel.grid(row=2, column=0, padx=(70, 0), pady=(0, 15)) +def open_game_save_dir(): + if config.cfg["gamedir"] == "": + popup("Please set your default game save directory first") + return + else: + print(config.cfg["gamedir"]) + open_folder_standard_exporer(config.cfg["gamedir"]) + return def open_folder(): @@ -339,7 +325,6 @@ def reset_default_dir(): popup("Successfully reset default directory") - def help_me(): # out = run_command("notepad ./data/readme.txt") info = "" @@ -459,69 +444,6 @@ def wrapper(comm): ) -def popup( - text, - command=None, - functions=False, - buttons=False, - button_names=("Yes", "No"), - b_width=(6,6), - title="Manager", - parent_window=None): - """text: Message to display on the popup window. - command: Simply run the windows CMD command if you press yes. - functions: Pass in external functions to be executed for yes/no""" - def run_cmd(): - cmd_out = run_command(command) - popupwin.destroy() - if cmd_out[0] == "error": - popupwin.destroy() - - def dontrun_cmd(): - popupwin.destroy() - - def run_func(arg): - arg() - popupwin.destroy() - if parent_window is None: - parent_window = root - popupwin = Toplevel(parent_window) - popupwin.title(title) - # popupwin.geometry("200x75") - lab = Label(popupwin, text=text) - lab.grid(row=0, column=0, padx=5, pady=5, columnspan=2) - # Places popup window at center of the root window - x = parent_window.winfo_x() - y = parent_window.winfo_y() - popupwin.geometry("+%d+%d" % (x + 200, y + 200)) - - # Runs for simple windows CMD execution - if functions is False and buttons is True: - but_yes = Button( - popupwin, text=button_names[0], borderwidth=5, width=b_width[0], command=run_cmd - ).grid(row=1, column=0, padx=(10, 0), pady=(0, 10)) - but_no = Button( - popupwin, text=button_names[1], borderwidth=5, width=b_width[1], command=dontrun_cmd - ).grid(row=1, column=1, padx=(10, 10), pady=(0, 10)) - - elif functions is not False and buttons is True: - but_yes = Button( - popupwin, - text=button_names[0], - borderwidth=5, - width=b_width[0], - command=lambda: run_func(functions[0]), - ).grid(row=1, column=0, padx=(10, 0), pady=(0, 10)) - but_no = Button( - popupwin, - text=button_names[1], - borderwidth=5, - width=b_width[1], - command=lambda: run_func(functions[1]), - ).grid(row=1, column=1, padx=(10, 10), pady=(0, 10)) - # if text is the only arguement passed in, it will simply be a popup window to display text - - def run_command(subprocess_command, optional_success_out="OK"): """Used throughout to run commands into subprocess and capture the output. Note that it is integrated with popup function for in app error reporting.""" @@ -674,7 +596,29 @@ def rename_char(file, nw_nm, dest_slot): raise -def char_manager(): +def changelog(run=False): + info = "" + with open("./data/changelog.txt", "r") as f: + dat = f.readlines() + for line in dat: + info = info + f"\n\u2022 {line}\n" + if run: + popup(info, title="Changelog") + return + if config.post_update: + popup(info, title="Changelog") + + + + + + + + + +# ////// MENUS ////// + +def char_manager_menu(): """Entire character manager window for copying characters between save files""" def readme(): @@ -904,7 +848,7 @@ def cancel(): #mainloop() -def rename_characters(): +def rename_characters_menu(): """Opens popup window and renames character of selected listbox item""" def do(): @@ -975,20 +919,7 @@ def do(): but_go.grid(row=2, column=0, padx=(35, 0), pady=(10, 0)) -def changelog(run=False): - info = "" - with open("./data/changelog.txt", "r") as f: - dat = f.readlines() - for line in dat: - info = info + f"\n\u2022 {line}\n" - if run: - popup(info, title="Changelog") - return - if config.post_update: - popup(info, title="Changelog") - - -def stat_editor(): +def stat_editor_menu(): def recalc_lvl(): # entries = [vig_ent, min_ent, end_ent, str_ent, dex_ent, int_ent, fai_ent, arc_ent] lvl = 0 @@ -1247,7 +1178,7 @@ def get_char_stats(): but_set_stats.grid(row=0, column=1, padx=(0, 135), pady=(450, 0), sticky="n") -def set_steam_id(): +def set_steam_id_menu(): def done(): @@ -1301,7 +1232,7 @@ def validate(P): but_cancel.grid(row=3, column=0, padx=(70, 0), pady=(0, 15)) -def inventory_editor(): +def inventory_editor_menu(): def pop_up(txt, bold=True): """Basic popup window used only for parent function""" @@ -1387,7 +1318,7 @@ def add(): # x = hexedit.additem(dest_file,char_ind,item, qty) if x is None: pop_up( - "Unable to set quantity. Ensure you have at least 1 of the selected items." + "Unable to set quantity. Ensure you have at least 1 of the selected items.\nIf you already have one of the items in your inventory and are still unable to set the quantity,\nGo to Custom Items > Search and manually scan for the item ID." ) else: pop_up("Successfully added items") @@ -1410,6 +1341,12 @@ def populate_items(*args): def manual_search(): + try: + delete_folder(f"{temp_dir}1") + delete_folder(f"{temp_dir}2") + delete_folder(f"{temp_dir}3") + except Exception as e: + pass popupwin.destroy() find_itemid() @@ -1429,7 +1366,7 @@ def done(): idwin.destroy() popupwin.destroy() - inventory_editor() + inventory_editor_menu() def validate_id(P): @@ -1484,19 +1421,47 @@ def validate(P): return False - def open_save(pos): - path = fd.askopenfilename() + def load_temp_save(pos): + if config.cfg["gamedir"] == "" or len(config.cfg["gamedir"]) < 2: + popup("Please set your Default Game Directory first") + return + if os.path.isdir(temp_dir) is False: + cmd_out = run_command(lambda: os.makedirs(temp_dir)) + if cmd_out[0] == "error": + popup("Error! unable to make temp directory.") + return + + if os.path.isdir(f"{temp_dir}1") is False: + cmd_out = run_command(lambda: os.makedirs(f"{temp_dir}1")) + if cmd_out[0] == "error": + popup("Error! unable to make temp directory.") + return + + if os.path.isdir(f"{temp_dir}2") is False: + cmd_out = run_command(lambda: os.makedirs(f"{temp_dir}2")) + if cmd_out[0] == "error": + popup("Error! unable to make temp directory.") + return + + if os.path.isdir(f"{temp_dir}3") is False: + cmd_out = run_command(lambda: os.makedirs(f"{temp_dir}3")) + if cmd_out[0] == "error": + popup("Error! unable to make temp directory.") + return + + if pos == 1: - file_paths[0] = path - s1_label.config(text=path) + copy_file(f"{config.cfg['gamedir']}/{ext()}", f"{temp_dir}/1/{ext()}") + file_paths[0] = f"{temp_dir}/1/{ext()}" + if pos == 2: - file_paths[1] = path - s2_label.config(text=path) + copy_file(f"{config.cfg['gamedir']}/{ext()}", f"{temp_dir}/2/{ext()}") + file_paths[1] = f"{temp_dir}/2/{ext()}" if pos == 3: - file_paths[2] = path - s3_label.config(text=path) + copy_file(f"{config.cfg['gamedir']}/{ext()}", f"{temp_dir}/3/{ext()}") + file_paths[2] = f"{temp_dir}/3/{ext()}" window.lift() @@ -1511,7 +1476,7 @@ def add_custom_id(id): config.add_to("custom_ids", {name:id}) window.destroy() - inventory_editor() + inventory_editor_menu() except Exception as e: @@ -1537,6 +1502,42 @@ def add_custom_id(id): but_cancel.grid(row=2, column=0, padx=(70, 0), pady=(0, 15)) + def multi_item_select(indexes): + def grab_id(listbox): + ind = fetch_listbox_entry(listbox)[0].split(":")[0] + + if ind == '': + popup("No value selected!") + return + else: + popupwin.destroy() + name_id_popup(indexes[int(ind)]) + + popupwin = Toplevel(window) + popupwin.title("Add Item ID") + vcmd = (popupwin.register(validate), "%P") + x = window.winfo_x() + y = window.winfo_y() + popupwin.geometry("+%d+%d" % (x + 200, y + 200)) + lab = Label(popupwin, text=f"Multiple locations found! Select an address.\nLower addresses have a higher chance of success.") + lab.grid(row=0, column=0, padx=(5,5)) + + lb1 = Listbox(popupwin, borderwidth=3, width=19, height=10, exportselection=0) + lb1.config(font=bolded) + lb1.grid(row=1, column=0) + + but_select = Button(popupwin, text="Select", borderwidth=5, width=6, command=lambda:grab_id(lb1)) + but_select.grid(row=2, column=0, padx=(50, 65), pady=(5, 15), sticky="w") + but_cancel = Button(popupwin, text="Cancel", borderwidth=5, width=6, command=lambda: popupwin.destroy()) + but_cancel.grid(row=2, column=0, padx=(85, 0), pady=(5, 15)) + # Insert itemids alongside addresses so users can see if ids are like [0,0] and thus wrong + for k,v in indexes.items(): + if v == [0, 0]: # Obviously not an item ID + continue + lb1.insert(END, " " + f"{k}: {v}") + + + def search(): valid = True @@ -1562,9 +1563,15 @@ def search(): if item_id is None: popup("Unable to find item ID") return - name_id_popup(item_id) + if item_id[0] == "match": + name_id_popup(item_id[1]) + if item_id[0] == "multi-match": + multi_item_select(item_id[1]) + delete_folder(f"{temp_dir}1") + delete_folder(f"{temp_dir}2") + delete_folder(f"{temp_dir}3") def callback(url): webbrowser.open_new(url) @@ -1576,7 +1583,7 @@ def callback(url): window = Toplevel(root) window.title("Inventory Editor") window.resizable(width=True, height=True) - window.geometry("530x580") + window.geometry("530x560") vcmd = (window.register(validate), "%P") @@ -1590,43 +1597,42 @@ def callback(url): window.config(menu=menubar) helpmenu = Menu(menubar, tearoff=0) helpmenu.add_command(label="Search", command=find_itemid) - menubar.add_cascade(label="Manually add item", menu=helpmenu) + #menubar.add_cascade(label="Manually add item", menu=helpmenu) + padding_lab1 = Label(window, text=" ") + padding_lab1.pack() - s1_label = Label(window, text="Save file #1:") + but_open1 = Button(window, text="Grab Data 1", command=lambda:load_temp_save(1)) + but_open1.pack() + s1_label = Label(window, text="Quantity:") s1_label.pack() q1_ent = Entry(window, borderwidth=5, width=3, validate="key", validatecommand=vcmd) q1_ent.pack() - s2_label = Label(window, text="Save file #2:") + but_open2 = Button(window, text="Grab Data 2", command=lambda:load_temp_save(2)) + but_open2.pack() + s2_label = Label(window, text="Quantity:") s2_label.pack() q2_ent = Entry(window, borderwidth=5, width=3, validate="key", validatecommand=vcmd) q2_ent.pack() - - s3_label = Label(window, text="Save file #3:") + but_open3 = Button(window, text="Grab Data 3", command=lambda:load_temp_save(3)) + but_open3.pack() + s3_label = Label(window, text="Quantity:") s3_label.pack() q3_ent = Entry(window, borderwidth=5, width=3, validate="key", validatecommand=vcmd) q3_ent.pack() - - but_open1 = Button(window, text="Open #1", command=lambda:open_save(1)) - but_open1.pack() - - but_open2 = Button(window, text="Open #2", command=lambda:open_save(2)) - but_open2.pack() - - but_open3 = Button(window, text="Open #3", command=lambda:open_save(3)) - but_open3.pack() + padding_lab2 = Label(window, text=" ") + padding_lab2.pack() but_search = Button(window, text="Search", command=search) but_search.pack() - part1 = "\n\n----- HOW TO -----\n\n1. Make two copies of a save file\n\n2. Load into the saves and change the value of the item you want\n so they are all different\n\n3. Now select each save file and enter the quantity for each item\n\nNOTE: You must do this with the FIRST character slot\n\n" - part2 = f"\n\n # You can post the item IDs on you found on Nexus so other users can add them" - help_lab = Label(window, text=part1+part2) + help_text = "\n\n----- HOW TO -----\n\n1. Go in-game and note the quantity of the item you are trying to find. \n2. Exit to main menu and enter the quantity in the Manager.\n3. Click grab data 1.\n4. Go back in-game and load into the save file.\n5. Drop some of the items so the quantity is different from the original.\n6. Exit to main menu again.\n7. Enter the new quantity and click grab data 2.\n8. Repeat the process for #3.\n9. Click Search.\n\nNOTE: You must be using the first character in your save file!\n" + help_lab = Label(window, text=help_text) help_lab.pack() - post_but = Button(window, text= "Post", command=lambda: callback("https://www.nexusmods.com/eldenring/mods/214?tab=bugs")) + post_but = Button(window, text="Watch Video", command=lambda: callback(custom_search_tutorial_url)) post_but.pack() @@ -1642,7 +1648,7 @@ def done(): popup(f"Error: Unable to delete Item\n\n{repr(e)}") idwin.destroy() popupwin.destroy() - inventory_editor() + inventory_editor_menu() idwin = Toplevel(root) idwin.title("Remove Custom ID") @@ -1667,10 +1673,6 @@ def done(): - - - - # Main GUI content STAT popupwin = Toplevel(root) popupwin.title("Inventory Editor") @@ -1742,7 +1744,7 @@ def done(): but_set.grid(row=6, column=0, padx=(155, 0), pady=(22, 10)) -def recovery(): +def recovery_menu(): def do_popup(event): try: rt_click_menu.tk_popup( @@ -1831,68 +1833,314 @@ def pop_up(txt, bold=True): but_select1.grid(row=2, column=0, padx=(120, 0), pady=(0, 10)) -def get_charnames(file): - """wrapper for hexedit.get_names""" +def seamless_coop_menu(): + x = lambda: 'Enabled' if config.cfg['seamless-coop'] else 'Disabled' + popup(f"Enable this option to support the seamless Co-op mod .co2 extension\nIt's recommended to use a separate copy of the Manager just for seamless co-op.\n\nCurrent State: {x()}", buttons=True, button_names=("Enable", "Disable"), functions=(lambda:config.set("seamless-coop", True), lambda:config.set("seamless-coop", False))) +def set_playtimes_menu(): + # This function is unused. The game will overwrite modified playtime value on reload with original value. + def set(): + choice = vars.get() + try: + choice_real = choice.split(". ")[1] + except IndexError: + popup("Select a character!") + return + slot_ind = int(choice.split(".")[0]) + if len(hr_ent.get()) < 1 or len(min_ent.get()) < 1 or len(sec_ent.get()) < 1: + popup("Set a value for hr/min/sec") + return + time = [hr_ent.get(), min_ent.get(), sec_ent.get()] + archive_file(path, choice_real, "ACTION: Change Play Time", names) + hexedit.set_play_time(path,slot_ind,time) + popup("Success") - out = hexedit.get_names(file) - if out is False: - popup(f"Error: Unable to get character names.\nDoes the following path exist?\n{file}") - else: - return out + def validate_hr(P): + if len(P) > 0 and len(P) < 5 and P.isdigit(): + return True + else: + return False + def validate_min_sec(P): + if len(P) > 0 and len(P) < 3 and P.isdigit() and int(P) < 61: + return True + else: + return False -def finish_update(): - if os.path.exists("./data/GameSaveDir.txt"): # Legacy file for pre v1.5 versions - os.remove("./data/GameSaveDir.txt") - if config.post_update: # Will be ran on first launch after running update.exe + name = fetch_listbox_entry(lb)[0] + if name == "": + popup("No listbox item selected.") + return + path = f"{savedir}{name}/{ext()}" + names = get_charnames(path) + if names is False: + popup("FileNotFoundError: This is a known issue.\nPlease try re-importing your save file.") - if not os.path.exists("./data/save-files-pre-V1.5-BACKUP"): # NONE OF THIS WILL BE RUN ON v1.5+ - try: - copy_folder(savedir, "./data/save-files-pre-V1.5-BACKUP") - except Exception as e: - traceback.print_exc() - str_err = "".join(traceback.format_exc()) - popup(str_err) - for dir in os.listdir(savedir): # Reconstruct save-file structure for pre v1.5 versions + chars = [] + for ind, i in enumerate(names): + if i != None: + chars.append(f"{ind +1}. {i}") - try: - id = re.findall(r"\d{17}", str(os.listdir(f"{savedir}{dir}/"))) - if len(id) < 1: - continue - shutil.move(f"{savedir}{dir}/{id[0]}/{ext()}", f"{savedir}{dir}/{ext()}") - for i in ["GraphicsConfig.xml", "notes.txt", "steam_autocloud.vdf"]: - if os.path.exists(f"{savedir}{dir}/{i}"): - os.remove(f"{savedir}{dir}/{i}") - delete_folder(f"{savedir}{dir}/{id[0]}") - except Exception as e: - traceback.print_exc() - str_err = "".join(traceback.format_exc()) - popup(str_err) - continue + rwin = Toplevel(root) + rwin.title("Set Play Time") + rwin.geometry("200x250") + vcmd_hr = (rwin.register(validate_hr), "%P") + vcmd_min_sec = (rwin.register(validate_min_sec), "%P") -def seamless_coop(): - x = lambda: 'Enabled' if config.cfg['seamless-coop'] else 'Disabled' - popup(f"Enable this option to support the seamless Co-op mod .co2 extension\nIt's recommended to use a separate copy of the Manager just for seamless co-op.\n\nCurrent State: {x()}", buttons=True, button_names=("Enable", "Disable"), functions=(lambda:config.set("seamless-coop", True), lambda:config.set("seamless-coop", False))) + bolded = FNT.Font(weight="bold") # will use the default font + x = root.winfo_x() + y = root.winfo_y() + rwin.geometry("+%d+%d" % (x + 250, y + 200)) + opts = chars + vars = StringVar(rwin) + vars.set("Character") -def ext(): - if config.cfg["seamless-coop"]: - return "ER0000.co2" - elif config.cfg["seamless-coop"] is False: - return "ER0000.sl2" + drop = OptionMenu(rwin, vars, *opts) + drop.grid(row=0, column=0, padx=(15, 0), pady=(15, 0)) + drop.configure(width=20) + hr_lab = Label(rwin, text="Hours: ") + hr_lab.grid(row=1, column=0, padx=(15,0), pady=(15,0), sticky="w") + hr_ent = Entry(rwin, borderwidth=5, width=5, validate="key", validatecommand=vcmd_hr) + hr_ent.grid(row=1, column=0, padx=(70, 0), pady=(15, 0)) + min_lab = Label(rwin, text="Minutes: ") + min_lab.grid(row=2, column=0, padx=(15,0), pady=(15,0), sticky="w") + min_ent = Entry(rwin, borderwidth=5, width=5, validate="key", validatecommand=vcmd_min_sec) + min_ent.grid(row=2, column=0, padx=(70, 0), pady=(15, 0)) -# ----- LEGACY FUNCTIONS (NO LONGER USED) ----- + min_lab = Label(rwin, text="Seconds: ") + min_lab.grid(row=3, column=0, padx=(15,0), pady=(15,0), sticky="w") + sec_ent = Entry(rwin, borderwidth=5, width=5, validate="key", validatecommand=vcmd_min_sec) + sec_ent.grid(row=3, column=0, padx=(70, 0), pady=(15, 0)) + + but_go = Button(rwin, text="Set", borderwidth=5, command=set) + but_go.config(font=bolded) + but_go.grid(row=4, column=0, padx=(15, 0), pady=(20, 0)) + + +def set_starting_class_menu(): + def set(): + if class_var.get() == "Class": + popup("No class selected!") + return + if char_var.get() == "Character": + popup("No Character Selected!") + return + src_ind = int(char_var.get().split(".")[0]) + selected_name = char_var.get().split(".")[1] + archive_file(path, name, f"Modified starting class of {selected_name}", names) + hexedit.set_starting_class(path,src_ind,class_var.get()) + popup("Success!") + return + + # Populate dropdown containing characters. + name = fetch_listbox_entry(lb)[0] + if name == "": + popup("No listbox item selected.") + return + path = f"{savedir}{name}/{ext()}" + names = get_charnames(path) + if names is False: + popup("FileNotFoundError: This is a known issue.\nPlease try re-importing your save file.") + + chars = [] + for ind, i in enumerate(names): + if i != None: + chars.append(f"{ind +1}. {i}") + + + + + rwin = Toplevel(root) + rwin.title("Set Starting Class") + rwin.geometry("200x190") + + bolded = FNT.Font(weight="bold") # will use the default font + x = root.winfo_x() + y = root.winfo_y() + rwin.geometry("+%d+%d" % (x + 250, y + 200)) + + opts = chars + char_var = StringVar(rwin) + char_var.set("Character") + + drop = OptionMenu(rwin, char_var, *opts) + drop.grid(row=0, column=0, padx=(15, 0), pady=(15, 0)) + drop.configure(width=20) + + class_opts = ["Vagabond", "Warrior", "Hero", "Bandit", "Astrologer", "Prophet", "Confessor", "Samurai", "Prisoner", "Wretch"] + class_var = StringVar(rwin) + class_var.set("Class") + + class_drop = OptionMenu(rwin, class_var, *class_opts) + class_drop.grid(row=1, column=0, padx=(15, 0), pady=(15, 0)) + + + + but_set = Button(rwin, text="Set", borderwidth=5, command=set) + but_set.config(font=bolded) + but_set.grid(row=4, column=0, padx=(15, 0), pady=(20, 0)) + + +def change_default_steamid_menu(): + + + def done(): + s_id = ent.get() + if not len(s_id) == 17: + popup("SteamID should be 17 digits long") + return + config.set("steamid", s_id) + + + popup("Successfully changed default SteamID") + popupwin.destroy() + + def cancel(): + popupwin.destroy() + + def validate(P): + if len(P) == 0: + return True + elif len(P) < 18 and P.isdigit(): + return True + else: + # Anything else, reject it + return False + + popupwin = Toplevel(root) + popupwin.title("Set SteamID") + vcmd = (popupwin.register(validate), "%P") + # popupwin.geometry("200x70") + + s_id = config.cfg["steamid"] + lab = Label(popupwin, text=f"Current ID: {s_id}\nEnter new ID:") + lab.grid(row=0, column=0) + ent = Entry(popupwin, borderwidth=5, validate="key", validatecommand=vcmd) + ent.grid(row=1, column=0, padx=25, pady=10) + x = root.winfo_x() + y = root.winfo_y() + popupwin.geometry("+%d+%d" % (x + 200, y + 200)) + but_done = Button(popupwin, text="Done", borderwidth=5, width=6, command=done) + but_done.grid(row=2, column=0, padx=(25, 65), pady=(0, 15), sticky="w") + but_cancel = Button(popupwin, text="Cancel", borderwidth=5, width=6, command=cancel) + but_cancel.grid(row=2, column=0, padx=(70, 0), pady=(0, 15)) + + +def import_save_menu(): + """Opens file explorer to choose a save file to import, Then checks if the files steam ID matches users, and replaces it with users id""" + + if os.path.isdir(savedir) is False: + os.makedirs(savedir) + d = fd.askopenfilename() + + if len(d) < 1: + return + + if not d.endswith(ext()): + popup("Select a valid save file!\nIt should be named: ER0000.sl2 or ER0000.co2 if seamless co-op is enabled.") + return + + + + + def cancel(): + popupwin.destroy() + + def done(): + + name = ent.get().strip() + if len(name) < 1: + popup("No name entered.") + return + isforbidden = False + for char in name: + if char in "~'{};:./\,:*?<>|-!@#$%^&()+": + isforbidden = True + if isforbidden is True: + popup("Forbidden character used") + return + elif isforbidden is False: + entries = [] + for entry in os.listdir(savedir): + entries.append(entry) + if name.replace(" ", "-") in entries: + popup("Name already exists") + return + + + + + + + + names = get_charnames(d) + archive_file(d, name, "ACTION: Imported", names) + + newdir = "{}{}/".format(savedir, name.replace(" ", "-")) + cp_to_saves_cmd = lambda: copy_file(d, newdir) + + if os.path.isdir(newdir) is False: + cmd_out = run_command(lambda: os.makedirs(newdir)) + + if cmd_out[0] == "error": + print("---ERROR #1----") + return + + lb.insert(END, " " + name) + cmd_out = run_command(cp_to_saves_cmd) + if cmd_out[0] == "error": + return + create_notes(name, "{}{}/".format(savedir, name.replace(" ", "-"))) + + file_id = hexedit.get_id(f"{newdir}/{ext()}") + user_id = config.cfg["steamid"] + if len(user_id) < 17: + popupwin.destroy() + return + if file_id != int(user_id): + popup( + f"File SteamID: {file_id}\nYour SteamID: {user_id}", buttons=True, button_names=("Patch with your ID", "Leave it"), b_width=(15,8), functions=(lambda:hexedit.replace_id(f"{newdir}/{ext()}", int(user_id)), donothing) + ) + #hexedit.replace_id(f"{newdir}/ER0000.sl2", int(id)) + + popupwin.destroy() + + popupwin = Toplevel(root) + popupwin.title("Import") + # popupwin.geometry("200x70") + lab = Label(popupwin, text="Enter a Name:") + lab.grid(row=0, column=0) + ent = Entry(popupwin, borderwidth=5) + ent.grid(row=1, column=0, padx=25, pady=10) + x = root.winfo_x() + y = root.winfo_y() + popupwin.geometry("+%d+%d" % (x + 200, y + 200)) + but_done = Button(popupwin, text="Done", borderwidth=5, width=6, command=done) + but_done.grid(row=2, column=0, padx=(25, 65), pady=(0, 15), sticky="w") + but_cancel = Button(popupwin, text="Cancel", borderwidth=5, width=6, command=cancel) + but_cancel.grid(row=2, column=0, padx=(70, 0), pady=(0, 15)) + + + + + + + + + + +# //// LEGACY FUNCTIONS (NO LONGER USED) //// def quick_restore(): """Copies the selected save file in temp to selected listbox item""" @@ -1967,7 +2215,14 @@ def about(): -# ----- MAIN GUI CONTENT ----- + + + + + + + +# ///// MAIN GUI CONTENT ///// root = Tk() @@ -2006,13 +2261,12 @@ def about(): # FILE MENU filemenu = Menu(menubar, tearoff=0) - #filemenu.add_command(label="Save Backup", command=save_backup) #filemenu.add_command(label="Restore Backup", command=load_backup) - -filemenu.add_command(label="Import Save File", command=import_save) -filemenu.add_command(label="seamless Co-op Mode", command=seamless_coop) +filemenu.add_command(label="Import Save File", command=import_save_menu) +filemenu.add_command(label="seamless Co-op Mode", command=seamless_coop_menu) filemenu.add_command(label="Force quit EldenRing", command=forcequit) +filemenu.add_command(label="Open Default Game Save Directory", command=open_game_save_dir) filemenu.add_separator() filemenu.add_command(label="Donate", command=lambda:webbrowser.open_new_tab("https://www.paypal.com/donate/?hosted_button_id=H2X24U55NUJJW")) filemenu.add_command(label="Exit", command=root.quit) @@ -2024,16 +2278,16 @@ def about(): editmenu = Menu(menubar, tearoff=0) editmenu.add_command(label="Change Default Directory", command=change_default_dir) #editmenu.add_command(label="Reset To Default Directory", command=reset_default_dir) -editmenu.add_command(label="Change Default SteamID", command=change_default_steamid) +editmenu.add_command(label="Change Default SteamID", command=change_default_steamid_menu) editmenu.add_command(label="Check for updates", command=update_app) menubar.add_cascade(label="Edit", menu=editmenu) # TOOLS MENU toolsmenu = Menu(menubar, tearoff=0) -toolsmenu.add_command(label="Character Manager", command=char_manager) -toolsmenu.add_command(label="Stat Editor", command=stat_editor) -toolsmenu.add_command(label="Inventory Editor", command=inventory_editor) -toolsmenu.add_command(label="File Recovery", command=recovery) +toolsmenu.add_command(label="Character Manager", command=char_manager_menu) +toolsmenu.add_command(label="Stat Editor", command=stat_editor_menu) +toolsmenu.add_command(label="Inventory Editor", command=inventory_editor_menu) +toolsmenu.add_command(label="File Recovery", command=recovery_menu) menubar.add_cascade(label="Tools", menu=toolsmenu) # HELP MENU @@ -2045,13 +2299,12 @@ def about(): helpmenu.add_command(label="Report Bug", command=lambda:popup("Report bugs on Nexus, GitHub or email me at scyntacks94@gmail.com")) menubar.add_cascade(label="Help", menu=helpmenu) -# + create_save_lab = Label(root, text="Create Save:", font=("Impact", 15)) create_save_lab.config(fg="grey") create_save_lab.grid(row=0, column=0, padx=(80, 10), pady=(0, 260)) cr_save_ent = Entry(root, borderwidth=5) -# cr_save_ent.config(bg='grey') cr_save_ent.grid(row=0, column=1, pady=(0, 260)) but_go = Button(root, text="Done", image=done_img, borderwidth=0, command=create_save) @@ -2088,11 +2341,12 @@ def open_notes(): rt_click_menu = Menu(lb, tearoff=0) #rt_click_menu.add_command(label="Edit Notes", command=open_notes) rt_click_menu.add_command(label="Rename Save", command=rename_slot) -rt_click_menu.add_command(label="Rename Characters", command=rename_characters) +rt_click_menu.add_command(label="Rename Characters", command=rename_characters_menu) rt_click_menu.add_command(label="Update", command=update_slot) #rt_click_menu.add_command(label="Quick Backup", command=quick_backup) #rt_click_menu.add_command(label="Quick Restore", command=quick_restore) -rt_click_menu.add_command(label="Change SteamID", command=set_steam_id) +rt_click_menu.add_command(label="Set Starting Classes", command=set_starting_class_menu) #FULLY FUNCTIONAL, but it doesn't work because game restores playtime to original values after loading..... :( +rt_click_menu.add_command(label="Change SteamID", command=set_steam_id_menu) rt_click_menu.add_command(label="Open File Location", command=open_folder) lb.bind( "", do_popup diff --git a/data/changelog.txt b/data/changelog.txt index 7eeb62f..5ac93a1 100644 --- a/data/changelog.txt +++ b/data/changelog.txt @@ -1,4 +1,4 @@ -WARNING: As of game version 1.08, it is no longer safe to use online! -I will continue to look into this but unfortunately it's looking like everyone is going to have to go back to grinding for hundreds of hours.. -Fixed bug with saves not appearing in main listbox when trying to create or import saves. -EVERY item ID has been added to a Master Spreadsheet! (inventory editor > Custom Items > View Master Spreadsheet) +Improvements to Custom ID Search tool +Tutorial video added for Custom ID Search tool +Ability to set starting class flag. Right-click a save file and select "Set starting classes" +Minor bug fixes and improvements diff --git a/hexedit.py b/hexedit.py index 60878ec..86b5a70 100644 --- a/hexedit.py +++ b/hexedit.py @@ -534,13 +534,14 @@ def additem(file, slot, itemids, quantity): index = [] cur = [int(i) for i in itemids] + if cur is None: return for ind, i in enumerate(cs): if ind < 30000: continue - if ind > 95000: + if ind > 195000: continue if ( l_endian(cs[ind : ind + 1]) == cur[0] @@ -593,22 +594,80 @@ def search_itemid(f1,f2,f3,q1,q2,q3): for ind, i in enumerate(c1): if ind < 30000: continue - - if ( - l_endian(c1[ind:ind+1]) == int(q1) - and l_endian(c2[ind:ind+1]) == int(q2) - and l_endian(c3[ind:ind+1]) == int(q3) - ): - - - + # Full Matches + if ( l_endian(c1[ind:ind+1]) == int(q1) and l_endian(c2[ind:ind+1]) == int(q2) and l_endian(c3[ind:ind+1]) == int(q3)): if ( l_endian(c1[ind - 2 : ind - 1]) == 0 and l_endian(c1[ind -1 : ind]) == 176 ) or ( l_endian(c1[ind - 2 : ind - 1]) == 128 and l_endian(c1[ind-1 : ind]) == 128): index.append(ind) + if len(index) == 1: idx = index[0] idx -= 6 - return [l_endian(c1[idx + 2:idx + 3]), l_endian(c1[idx + 3:idx+4])] + return ["match", [l_endian(c1[idx + 2:idx + 3]), l_endian(c1[idx + 3:idx+4])]] + + elif len(index) > 1 and len(index) < 500: + return_dict = {} + for i in index: + return_dict[i-6] = [l_endian(c1[i+2:i+3]), l_endian(c1[i+3:i+4])] + return ["multi-match", return_dict] + else: return None + + + +def set_play_time(file,slot,time): + # time = [hr,min,sec] + time = [int(i) for i in time] + hr = time[0] + min = time[1] + sec = time[2] + seconds = sec + (min*60) + (hr*3600) + time1 = 0x1901d0e+38 + time2 = 0x1901f5a+38 + time3 = 0x19021a6+38 + time4 = 0x19023f2+38 + time5 = 0x190263e+38 + time6 = 0x190288a+38 + time7 = 0x1902ad6+38 + time8 = 0x1902d22+38 + time9 = 0x1902f6e+38 + time10 = 0x19031ba+38 + + times = [time1,time2,time3,time4,time5,time6,time7,time8,time9,time10] + with open(file,"rb") as f: + dat = f.read() + ch = dat[:times[slot-1] ] + seconds.to_bytes(4, "little") + dat[times[slot-1] +4:] + with open(file, "wb") as ff: + ff.write(ch) + recalc_checksum(file) + + + +def set_starting_class(file, slot, char_class): + cs = get_slot_ls(file)[slot - 1] + slices = get_slot_slices(file) + s_start = slices[slot - 1][0] + s_end = slices[slot - 1][1] + pos = 42165 # class flag is 42165 bytes from start of character block + classes = {"Vagabond":0, "Warrior":1, "Hero":2, "Bandit":3, "Astrologer":4, + "Prophet":5, "Confessor":6, "Samurai":7, "Prisoner":8, "Wretch":9 + } + with open(file, "rb") as f: + dat = f.read() + + + with open(file, "wb") as fh: + ch = ( + s_start + + cs[:pos] + + classes[char_class].to_bytes(1, "little") + + cs[pos + 1 :] + + s_end + ) + + fh.write(ch) + + recalc_checksum(file) + return True diff --git a/os_layer.py b/os_layer.py index 4313b36..267abab 100644 --- a/os_layer.py +++ b/os_layer.py @@ -11,10 +11,12 @@ app_title = "Elden Ring Save Manager" backupdir = "./data/backup/" update_dir = "./data/updates/" +temp_dir = "./data/temp/" post_update_file = "./data/post.update" -version = "v1.63" -v_num = 1.63 # Used for checking version for update +version = "v1.64" +v_num = 1.64 # Used for checking version for update video_url = "https://youtu.be/RZIP0kYjvZM" +custom_search_tutorial_url = "https://youtu.be/li-ZiMXBmRk" background_img = "./data/background.png" icon_file = "./data/icon.ico" bk_p = (-140, 20) # Background image position