├── .gitignore ├── LICENSE ├── README.md ├── SaveManager.py └── save-manager.bat /.gitignore: -------------------------------------------------------------------------------- 1 | *.character 2 | Characters/* 3 | Characters 4 | Creations 5 | Creations/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Llama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # save-manager 2 | A python program for adding new game / multiple character support to Steam games. 3 | # Features 4 | - Start a new game whenever you want without losing your progress on other characters 5 | - Create multiple characters and switch between them 6 | - Unlimited number of save files for each individual character you create 7 | # Disclaimer 8 | While I have done my best to test this extensively with my saves, I can make no guarantee that no damage will happen to your saves. I will do my best to keep this software updated with any bug fixes, but I am not responsible for data loss as a result of this tool. This is for people like me who are willing to risk incompatibility with game servers and/or accidental file loss to restore this functionality to my games. 9 | # Installation 10 | Requires Python 3 11 | (Optional: Requires Git) 12 | 13 | **THIS PROGRAM WILL NOT WORK IF YOU DO NOT DISABLE STEAM CLOUD FOR YOUR GAME** 14 | 15 | Before installing, I recommend backing up your save file manually just in case something goes wrong. 16 | 17 | **Option 1. Download ZIP file** 18 | 19 | If you are downloading on Github, Click on the green `Code` button and choose `Download ZIP`. Unzip this folder at the location you want to store the program (and your saves). 20 | 21 | In the file explorer, run save-manager.bat 22 | 23 | **Option 2. Install with Git** 24 | 25 | To install with Git, click on the green "Code" button and copy the link https://github.com/wiseLlama0/save-manager.git. 26 | 27 | Open a terminal and navigate to where you want to store the program. 28 | 29 | Type `git clone https://github.com/wiseLlama0/save-manager.git` 30 | 31 | Type `cd save-manager` 32 | 33 | Type `python SaveManager.py` 34 | 35 | # How to Use 36 | 37 | When you start the program, follow the setup instructions carefully. When it asks you to select a save folder, select the folder with the name matching the steam game id that contains two files and a folder. It will contain `remote`, another file, and `remotecache.vdf`. 38 | 39 | This file is usually found at `Program Files (x86)/Steam/userdata/{your steam account id}/{game id}`. For example, the game id for Dragon's Dogma 2 is 2054970, so the folder you want is `C:/Program Files (x86)/Steam/userdata/{your steam account id}/2054970`. 40 | 41 | Another important note: Remember that any action which deploys new data to the game save folder (loading a save, changing your character, or starting a new character) will permanently delete whatever files are in there. Make sure save them using the Save Game function provided within this tool, or back them up manually yourself. I have done my best to add warnings and a last minute backup save option in all cases that one could accidentally destroy data, however I can make no guarantees that there is perfect coverage. 42 | 43 | -------------------------------------------------------------------------------- /SaveManager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import datetime 4 | import secrets 5 | import time 6 | import threading 7 | 8 | import tkinter as tk 9 | from tkinter import filedialog 10 | 11 | save_path = "" 12 | current_character = "" 13 | 14 | auto_backup_option_text = "Start Auto Backup" # Text for the auto backup button 15 | backup_thread = None # Thread for the auto backup 16 | backup_active = False # Flag to indicate if the auto backup is active 17 | backup_lock = threading.Lock() # Define a lock for thread-safe operations, like modifying the backup_active flag 18 | set_max_backups = False # Flag to indicate if the user has set a maximum number of backups 19 | max_backups = 100 # Default maximum number of backups 20 | last_backup_timestamp = None # Timestamp of the last backup, initialized within the auto_backup function 21 | 22 | save_lock = threading.Lock() 23 | 24 | def deploy_creation_files(): 25 | global save_path 26 | 27 | win_save_path = save_path+"/remote/win64_save" 28 | current_directory = os.getcwd() 29 | creations_folder = os.path.join(current_directory, "Creations") 30 | 31 | print("") 32 | for filename in os.listdir(creations_folder): 33 | source_file = os.path.join(creations_folder, filename) 34 | destination_file = os.path.join(win_save_path, filename) 35 | shutil.copyfile(source_file, destination_file) 36 | print(f"\tDeployed file: {source_file}") 37 | 38 | def generate_random_string(): 39 | random_hex = ''.join(secrets.choice('0123456789abcdef') for _ in range(16)) 40 | return random_hex 41 | 42 | def get_file_timestamp(filename): 43 | global current_character 44 | file_path = "Characters/" + current_character + "/" + filename 45 | return os.path.getmtime(file_path) 46 | 47 | def promptEnter(extra_message=""): 48 | print(f"\n\t{extra_message}") 49 | print("\n\t[ -- Press Enter to continue -- ]") 50 | input("") 51 | 52 | def validate_save_path(): 53 | global save_path 54 | 55 | if (len(os.listdir(save_path))) == 0: 56 | return() 57 | 58 | if (len(os.listdir(save_path))) != 3 and (len(os.listdir(save_path))) != 4: 59 | print("\n\tWARNING: Error validating save directory. Unexpected file structure for save folder.\n\t\t Please ensure that you have selected the correct directory. File removal is permanent and unrecoverable.") 60 | quit() 61 | 62 | remote_folder = False 63 | remote_vdf = False 64 | for item in os.listdir(save_path): 65 | if (item == "remote"): 66 | remote_folder = True 67 | elif (item == "remotecache.vdf"): 68 | remote_vdf = True 69 | 70 | if (remote_folder == False): 71 | print("\n\tWARNING: Error validating save directory. Could not find remote folder.\n\t\t Please ensure that you have selected the correct directory. File removal is permanent and unrecoverable.") 72 | quit() 73 | 74 | if (remote_vdf == False): 75 | print("\n\tWARNING: Error validating save directory. Could not find remotecache.vdf.\n\t\t Please ensure that you have selected the correct directory. File removal is permanent and unrecoverable.") 76 | quit() 77 | 78 | def clear_save_directory(): 79 | global save_path 80 | 81 | #disable clearing save directory while another thread is saving data 82 | with save_lock: 83 | 84 | #clearing save directory 85 | print("\n\tClearing save directory...") 86 | 87 | #validate save path 88 | validate_save_path() 89 | 90 | count = 0 91 | for filename in os.listdir(save_path+"/remote/win64_save"): 92 | if (count > 10): 93 | error_path = save_path+"/remote/win64_save" 94 | print(f"WARNING: Abnormal file structure dectected while deleting from {error_path}. Aborting now.") 95 | quit() 96 | file_path = os.path.join(save_path+"/remote/win64_save", filename) 97 | os.remove(file_path) 98 | print(f"\tDeleted: {file_path}") 99 | count += 1 100 | 101 | def stage_save_directory(source_path): 102 | global save_path 103 | 104 | win_save_path = save_path+"/remote/win64_save" 105 | 106 | print("") 107 | for filename in os.listdir(source_path): 108 | source_file = os.path.join(source_path, filename) 109 | destination_file = os.path.join(win_save_path, filename) 110 | shutil.copyfile(source_file, destination_file) 111 | print(f"\tDeployed file: {source_file}") 112 | 113 | def remove_character_file(): 114 | for item in os.listdir(os.getcwd()): 115 | s = item.split(".") 116 | if (len(s) == 1): continue 117 | if (s[1] == "character"): 118 | os.remove(os.path.join(os.getcwd(), item)) 119 | 120 | def create_file(file_path): 121 | with open(file_path, 'w'): 122 | pass 123 | 124 | def save_creation_files(): 125 | global save_path 126 | win_save_path = save_path+"/remote/win64_save" 127 | current_directory = os.getcwd() 128 | creations_folder = os.path.join(current_directory, "Creations") 129 | 130 | for filename in os.listdir(win_save_path): 131 | if (filename.startswith("SS1")): 132 | source_file = os.path.join(win_save_path, filename) 133 | destination_file = os.path.join(creations_folder, filename) 134 | shutil.copyfile(source_file, destination_file) 135 | 136 | def initialize(): 137 | 138 | global save_path 139 | 140 | os.system("cls") 141 | print("\t\t\t+ +\t\t\t\t\t\t\t\t+ +") 142 | print("\t\t\t+ \t\t\t\t\t\t\t\t +") 143 | print("\t\t\t\t\t\t\tSave Manager\n\n") 144 | print("\t\t\t\t WARNING: This program is still in development and may\n\t\t\t\t\t cause issues with your game. Additionally,\n\t\t\t\t\t please be sure to CLOSE your game before using\n\t\t\t\t\t this program.\n\n\n\t\t\t\t\t Use at your own risk.") 145 | print("\n") 146 | print("\t\t\t\t\t [ -- Press enter to continue -- ]") 147 | print("\t\t\t+ \t\t\t\t\t\t\t\t +") 148 | print("\t\t\t+ +\t\t\t\t\t\t\t\t+ +") 149 | input("") 150 | 151 | os.system("cls") 152 | 153 | print("\n\tInitializing Save Manager...") 154 | 155 | current_directory = os.getcwd() 156 | character_folder = os.path.join(current_directory, "Characters") 157 | 158 | if (os.path.exists(character_folder) == False): 159 | os.mkdir("Characters") 160 | 161 | creations_folder = os.path.join(current_directory, "Creations") 162 | 163 | if (os.path.exists(creations_folder) == False): 164 | os.mkdir("Creations") 165 | 166 | print("\n\tPlease select your save folder. This folder usually has a path similar\n\tto Steam/userdata/{some number}/{game id}") 167 | 168 | print("\n\t [ -- Press Enter to continue to folder selection -- ]") 169 | input("") 170 | 171 | # Create a tkinter window (this will not be displayed) 172 | root = tk.Tk() 173 | root.withdraw() 174 | 175 | # Open a file dialog and store the selected file path 176 | file_path = filedialog.askdirectory() 177 | global save_path 178 | global current_character 179 | save_path = file_path 180 | # Print the selected file path 181 | print("\tSelected folder:", save_path) 182 | 183 | validate_save_path() 184 | 185 | character_list = os.listdir(os.path.join(os.getcwd(), "Characters")) 186 | if (len(character_list) == 0): 187 | print("\n\tNo saved characters detected. Importing current character...") 188 | folder_name = input("\tPlease name the folder for the imported character: ") 189 | 190 | os.mkdir("Characters/"+folder_name) 191 | print(f"\n\tCharacter {folder_name} successfully created.\n") 192 | 193 | os.mkdir("Characters/"+folder_name+"/main_save") 194 | 195 | if (len(os.listdir(save_path+"/remote/win64_save")) == 0 or len(os.listdir(save_path+"/remote/win64_save")) == 2): 196 | print("WARNING: Error importing current character. No save data found. Please start the game to generate save data.") 197 | quit() 198 | 199 | for filename in os.listdir(save_path+"/remote/win64_save"): 200 | source_file = os.path.join(save_path+"/remote/win64_save", filename) 201 | destination_file = os.path.join("Characters/"+folder_name+"/main_save", filename) 202 | shutil.copy(source_file, destination_file) 203 | print(f"\tImported {source_file}") 204 | 205 | current_character = folder_name 206 | create_file(current_character + ".character") 207 | 208 | print("\n\tImported current character successfully.") 209 | promptEnter() 210 | 211 | if (current_character == ""): 212 | #look for .character file 213 | for filename in os.listdir(os.getcwd()): 214 | s = filename.split(".") 215 | if (len(s) == 1): continue 216 | if (s[1] == "character"): 217 | current_character = s[0] 218 | 219 | if (current_character == ""): 220 | print("\n\tNo .character file found. Please indicate your current character.\n") 221 | character_count = 0 222 | for character in character_list: 223 | character_count += 1 224 | print(f"\t{character_count}. {character}") 225 | 226 | user_input = input("\n\tChoose an option: ") 227 | 228 | user_input = int(user_input) 229 | if (user_input <= 0 or user_input > character_count): 230 | print("\n\tInvalid character selected.") 231 | quit() 232 | 233 | current_character = character_list[user_input-1] 234 | create_file(current_character + ".character") 235 | 236 | creations_list = os.listdir(os.path.join(os.getcwd(), "Creations")) 237 | if (len(creations_list) == 0 ): 238 | save_creation_files() 239 | print("\n\tSaved Creation files") 240 | 241 | run() 242 | 243 | def new_character(): 244 | 245 | global current_character 246 | 247 | os.system("cls") 248 | 249 | print("\n\tWARNING: Creating a new character will remove your current save game.\n\t\t Please back up your current save game before proceeding.\n") 250 | 251 | user_input = input("\n\tDo you still wish to proceed? [Y/N]: ") 252 | 253 | if (user_input != "Y" and user_input != "y"): 254 | return 255 | 256 | auto_save() 257 | 258 | while (True): 259 | os.system("cls") 260 | 261 | print("================================") 262 | print("Create a New Character") 263 | 264 | folder_name = input("\tChoose a name: ") 265 | new_folder_directory = os.path.join(os.getcwd(), "Characters/"+folder_name) 266 | if (os.path.exists(new_folder_directory) == False): 267 | 268 | clear_save_directory() 269 | 270 | print("\n\tCreating new character folder...") 271 | 272 | os.mkdir("Characters/"+folder_name) 273 | print(f"\n\tCharacter {folder_name} successfully created.\n") 274 | 275 | os.mkdir("Characters/"+folder_name+"/main_save") 276 | 277 | current_character = folder_name 278 | 279 | remove_character_file() 280 | create_file(current_character+".character") 281 | 282 | deploy_creation_files() 283 | 284 | promptEnter() 285 | 286 | break 287 | else: 288 | print("\n\t Character folder name already exists. Please choose a new name.") 289 | print("\n\t[ -- Press Enter to continue -- ]") 290 | input("") 291 | 292 | 293 | def auto_save(display_prompt=True): 294 | global last_backup_timestamp 295 | 296 | with save_lock: 297 | if (display_prompt): 298 | user_input= input("\n\tDo you want to back up your current save? [Y/N]: ") 299 | 300 | if (user_input.lower() != "y"): 301 | return 302 | 303 | current_save_directory = os.path.join(save_path, 'remote', 'win64_save') 304 | current_save_timestamp = get_latest_save_timestamp(current_save_directory) 305 | # log current and last backup timestamps for debugging 306 | # print(f"DEBUG: Current save timestamp is {current_save_timestamp}") 307 | # print(f"DEBUG: Last backup timestamp is {last_backup_timestamp}") 308 | 309 | if current_save_timestamp == last_backup_timestamp: 310 | if (not display_prompt): 311 | print("Save file is already backed up. Backup aborted.") # debug 312 | return 313 | elif last_backup_timestamp is None or current_save_timestamp > last_backup_timestamp: 314 | last_backup_timestamp = current_save_timestamp 315 | 316 | # this will remove any excess backups if the max backups is set 317 | manage_backups() 318 | 319 | random_string = "_"+generate_random_string() 320 | save_name = "BackupSave" + random_string 321 | try: 322 | os.mkdir("Characters/"+current_character+"/"+save_name) 323 | except FileExistsError: 324 | if (display_prompt): 325 | promptEnter(f"The directory {save_name} already exists. Backup aborted.") 326 | return 327 | 328 | for filename in os.listdir(save_path+"/remote/win64_save"): 329 | source_file = os.path.join(save_path+"/remote/win64_save", filename) 330 | destination_file = os.path.join("Characters/"+current_character+"/"+save_name, filename) 331 | shutil.copy(source_file, destination_file) 332 | 333 | # print(f"DEBUG: Backup created at {current_save_timestamp}") 334 | 335 | def save_game(): 336 | 337 | os.system("cls") 338 | 339 | print("Note: Saving the game will always overwrite the 'main_save' as well as creating an additional backup save") 340 | print("================================") 341 | print("Save Game") 342 | print("\t\t1. Confirm Save") 343 | print("\t\t2. Cancel") 344 | print("================================") 345 | 346 | user_input = input("Choose an option: ") 347 | 348 | if (user_input != "1"): 349 | return 350 | 351 | user_input = input("\n\t Would you like to name your save? [Y/N]: ") 352 | 353 | save_list = os.listdir("Characters/"+current_character) 354 | num_saves = len(save_list) 355 | num_saves = str(num_saves) 356 | save_name = "Save"+num_saves 357 | random_string = "_"+generate_random_string() 358 | save_name = save_name + random_string 359 | if (user_input == "Y" or user_input == "y"): 360 | while (True): 361 | user_input = input("\n\tEnter a name for your save: ") 362 | 363 | if (os.path.exists("Characters/"+current_character+"/"+user_input+random_string) == False): 364 | save_name = user_input + random_string 365 | break 366 | else: 367 | print("\tSave name already taken, please choose another.") 368 | 369 | os.mkdir("Characters/"+current_character+"/"+save_name) 370 | 371 | for filename in os.listdir(save_path+"/remote/win64_save"): 372 | source_file = os.path.join(save_path+"/remote/win64_save", filename) 373 | destination_file = os.path.join("Characters/"+current_character+"/"+save_name, filename) 374 | shutil.copy(source_file, destination_file) 375 | 376 | for filename in os.listdir(save_path+"/remote/win64_save"): 377 | source_file = os.path.join(save_path+"/remote/win64_save", filename) 378 | destination_file = os.path.join("Characters/"+current_character+"/main_save", filename) 379 | shutil.copy(source_file, destination_file) 380 | print(f"\tSaved {source_file}") 381 | 382 | promptEnter() 383 | 384 | def get_latest_save_timestamp(save_directory): 385 | """Get the timestamp of the latest modified file in the save directory.""" 386 | save_files = os.listdir(save_directory) 387 | if not save_files: 388 | return None 389 | latest_file = max(save_files, key=lambda f: os.path.getmtime(os.path.join(save_directory, f))) 390 | return os.path.getmtime(os.path.join(save_directory, latest_file)) 391 | 392 | def load_game(): 393 | 394 | os.system("cls") 395 | 396 | print("================================") 397 | print("Load Game") 398 | print("\t(m). Main Save") 399 | save_count = 0 400 | save_list = os.listdir("Characters/"+current_character) 401 | sorted_save_list = sorted(save_list, key=get_file_timestamp, reverse=True) 402 | for save in sorted_save_list: 403 | save_count += 1 404 | save_file_path = os.path.join(os.getcwd(), "Characters/"+current_character+"/"+save) 405 | timestamp = os.path.getmtime(save_file_path) 406 | timestamp_dt_obj = datetime.datetime.fromtimestamp(timestamp) 407 | print(f"\t{save_count}. {save}\t\t\t\t |\t{timestamp_dt_obj}") 408 | print(f"\t{save_count+1}. Cancel") 409 | print("================================") 410 | 411 | while (True): 412 | user_input = input("Choose an option: ") 413 | 414 | if (user_input == "m"): 415 | # main save 416 | auto_save() 417 | clear_save_directory() 418 | stage_save_directory("Characters/"+current_character+"/main_save") 419 | promptEnter() 420 | return 421 | 422 | user_input = int(user_input) 423 | 424 | if (user_input <= 0 or user_input > save_count): 425 | return 426 | 427 | save_folder = sorted_save_list[user_input-1] 428 | 429 | auto_save() 430 | clear_save_directory() 431 | stage_save_directory("Characters/"+current_character+"/"+save_folder) 432 | break 433 | 434 | promptEnter() 435 | 436 | def change_character(): 437 | 438 | global current_character 439 | 440 | os.system("cls") 441 | print("\n\tWARNING: Changing your character will permanently remove your current save game.\n\t\t Please back up your current save game before proceeding.\n") 442 | 443 | user_input = input("\n\tDo you still wish to proceed? [Y/N]: ") 444 | 445 | if (user_input != "Y" and user_input != "y"): 446 | return 447 | 448 | auto_save() 449 | 450 | os.system("cls") 451 | 452 | print("================================") 453 | print("Select a Character") 454 | 455 | character_count = 0 456 | character_list = os.listdir(os.path.join(os.getcwd(), "Characters")) 457 | for item in character_list: 458 | character_count += 1 459 | print(f"{character_count}. {item}") 460 | 461 | print(f"{character_count+1}. Exit") 462 | 463 | print("================================") 464 | user_input = input("Choose an option: ") 465 | 466 | user_input = int(user_input) 467 | 468 | if (user_input <= 0): 469 | return 470 | if (user_input > character_count): 471 | return 472 | 473 | current_character = character_list[user_input-1] 474 | remove_character_file() 475 | create_file(current_character+".character") 476 | 477 | character_dir = os.path.join(os.path.join(os.getcwd(), "Characters"), current_character) 478 | 479 | print("Selected character at: ", character_dir) 480 | 481 | clear_save_directory() 482 | 483 | stage_save_directory(character_dir+"\main_save") 484 | 485 | promptEnter() 486 | 487 | def view_saves(): 488 | os.system("cls") 489 | 490 | save_list = os.listdir("Characters/"+current_character) 491 | sorted_save_list = sorted(save_list, key=get_file_timestamp, reverse=True) 492 | 493 | print("================================") 494 | print("Save List:") 495 | save_count = 0 496 | for save in sorted_save_list: 497 | save_count += 1 498 | save_file_path = os.path.join(os.getcwd(), "Characters/"+current_character+"/"+save) 499 | timestamp = os.path.getmtime(save_file_path) 500 | timestamp_dt_obj = datetime.datetime.fromtimestamp(timestamp) 501 | print(f"\t{save_count}. {save}\t\t\t\t |\t{timestamp_dt_obj}") 502 | print("================================") 503 | 504 | promptEnter() 505 | 506 | def auto_backup(): 507 | global backup_thread, backup_active, auto_backup_option_text, set_max_backups, max_backups, last_backup_timestamp 508 | 509 | os.system("cls") 510 | 511 | if backup_active: 512 | # print("DEBUG: Start lock 1 at auto_backup()") 513 | with backup_lock: 514 | backup_active = False 515 | if backup_thread is not None: 516 | backup_thread.join() 517 | auto_backup_option_text = "Start Auto Backup" 518 | set_max_backups = False 519 | max_backups = 100 520 | promptEnter("Auto backup stopped.") 521 | return 522 | 523 | current_save_directory = os.path.join(save_path, 'remote', 'win64_save') 524 | last_backup_timestamp = get_latest_save_timestamp(current_save_directory) 525 | 526 | print("Note: Save Data can be around 10mb per save. 10 saves = 100mb. 100 saves = 1gb.") 527 | print("Note: Please be aware of the space on your drive.") 528 | print("Note: If max backups is enabled, excess backups are culled on next auto backup.") 529 | print("================================") 530 | print("Auto Backup") 531 | print("\t\tEnter how often to make backups, in minutes (1-60)") 532 | print("\t\tThen, select if you want to set a maximum number of backups.") 533 | print("\t\t0. Choose at any time to cancel the whole process.") 534 | print("================================") 535 | 536 | # Get the interval for the auto backup 537 | try: 538 | interval = int(input("\n\tChoose backup interval, in minutes (1-60): ")) 539 | except ValueError: 540 | promptEnter(f"Input was not a valid number. Auto backup cancelled.") 541 | return 542 | 543 | # Cancel the auto backup if the interval is 0 544 | if interval == 0: 545 | promptEnter("Auto backup cancelled.") 546 | return 547 | 548 | # Check if the interval is within the valid range 549 | if interval < 1 or interval > 60: 550 | promptEnter(f"'{interval}' is not a valid input. Auto backup cancelled.") 551 | return 552 | 553 | user_input = input("\n\tDo you want to set a backup max? (recommended) [Y/N]: ") 554 | if user_input == "0": 555 | promptEnter("Auto backup cancelled.") 556 | return 557 | elif (user_input.lower() == "y"): 558 | try: 559 | user_input = int(input("\n\tEnter the maximum number of backups (10-100): ")) 560 | except ValueError: 561 | promptEnter(f"Input was not a valid number. Auto backup cancelled.") 562 | return 563 | if user_input == 0: 564 | promptEnter("Auto backup cancelled.") 565 | return 566 | elif user_input < 10 or user_input > 100: 567 | promptEnter(f"'{user_input}' is not a valid input. Auto backup cancelled.") 568 | return 569 | set_max_backups = True 570 | max_backups = user_input 571 | print(f"\n\tMaximum number of backups set to {max_backups}. Excess will be culled on next auto backup.") 572 | else: 573 | set_max_backups = False 574 | max_backups = 100 # set to 100, just in case 575 | print("\n\tMax backups set to unlimited.") 576 | 577 | # print("DEBUG: Start lock 2 at auto_backup()") 578 | with backup_lock: 579 | backup_active = True 580 | auto_backup_option_text = f"Stop Auto Backup [set to {interval} minutes]" 581 | backup_thread = threading.Thread(target=backup_timer, args=(interval,)) 582 | backup_thread.start() 583 | promptEnter(f"Auto backup started with interval of {interval} minutes.") 584 | return 585 | 586 | # used in the auto_backup function 587 | def backup_timer(interval): 588 | global backup_active 589 | 590 | # Calculate the interval in seconds 591 | interval_seconds = interval * 60 # Convert interval to seconds 592 | # interval_seconds = 5 # debug interval 593 | 594 | next_backup_time = time.time() + interval_seconds # Schedule the first backup 595 | 596 | while backup_active: 597 | time.sleep(1) # Thread sleeps for 1 second intervals to avoid busy-waiting 598 | current_time = time.time() 599 | 600 | if current_time >= next_backup_time: 601 | # print("starting auto backup") #debug 602 | # print("DEBUG: start lock 1 at backup_timer()") 603 | with backup_lock: 604 | if not backup_active: # Double-check if the backup is still active 605 | return 606 | auto_save(display_prompt=False) 607 | next_backup_time = time.time() + interval_seconds 608 | 609 | def manage_backups(): 610 | if not set_max_backups: 611 | return 612 | 613 | backup_dir = os.path.join("Characters", current_character) 614 | backups = [d for d in os.listdir(backup_dir) if d.startswith('BackupSave')] 615 | 616 | backups.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir, x))) 617 | 618 | while len(backups) > (max_backups - 1): 619 | oldest_backup = backups.pop(0) 620 | path_to_delete = os.path.join(backup_dir, oldest_backup) 621 | shutil.rmtree(path_to_delete) 622 | 623 | 624 | 625 | 626 | def run(): 627 | 628 | while (True): 629 | 630 | os.system("cls") 631 | 632 | print("================================") 633 | print("Main Menu") 634 | print(f"\tActive Character: {current_character}") 635 | print("\t1. New Character") 636 | print("\t2. Save Game") 637 | print("\t3. Load Game") 638 | print("\t4. Change Character") 639 | print("\t5. View Saves") 640 | print(f"\t6. {auto_backup_option_text}") 641 | print("\t9. Quit") 642 | print("================================") 643 | 644 | user_input = input("Choose option: ") 645 | if (user_input == "9"): 646 | break 647 | elif (user_input == "1"): 648 | new_character() 649 | elif (user_input == "2"): 650 | save_game() 651 | elif (user_input == "3"): 652 | load_game() 653 | elif (user_input == "4"): 654 | change_character() 655 | elif (user_input == "5"): 656 | view_saves() 657 | elif (user_input == "6"): 658 | auto_backup() 659 | 660 | terminate() 661 | 662 | def terminate(): 663 | global backup_active, backup_thread 664 | 665 | print("Terminating the application. Please wait...") 666 | 667 | # Inform any running threads that the application is closing 668 | # Signal the backup thread to stop, if it's running 669 | if backup_active: 670 | print("Stopping auto backup...") 671 | with backup_lock: # Ensure thread-safe modification 672 | backup_active = False 673 | 674 | # Wait for the backup thread to finish 675 | if backup_thread is not None: 676 | backup_thread.join() 677 | print("Auto backup stopped.") 678 | 679 | print("Application terminated. Goodbye!") 680 | quit() 681 | 682 | if __name__ == "__main__": 683 | initialize() -------------------------------------------------------------------------------- /save-manager.bat: -------------------------------------------------------------------------------- 1 | python SaveManager.py 2 | pause --------------------------------------------------------------------------------