diff --git a/README.md b/README.md index 7ab63bb..db5b12a 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,14 @@ Although it is stable enough that I use it, it is a work in progress and **proba ## Steam Workshop support PyMultibound supports steam workshop mods as part of profiles, but not in a conventional manner. -On a profile update it scans the currently installed workshop mods, and if you select `yes` when prompted it will take the `contents.pak` -of each mod, rename it to `workshop-mod-.pak` and group it with the manually installed mods. +On profile creation it optionally scans the currently installed workshop mods, and takes the `contents.pak` +of each mod, renames it to `workshop-mod-.pak` and groups it with the manually installed mods. It is then safe to unsubscribe to your subscribed workshop mods; they are now part of the profile and will be loaded when the profile is selected. ## How it Works PyMultibound creates snapshots of the `storage` and `mods` folders of Starbound, and saves them in "profiles" - a directory in the same location as the scripts. +It then starts Starbound with a profile-specific `sbinit.config` that directs Starbound to the profile's files. -The default mode is `use-sbinit: true` in `settings.json`. In this mode it will edit the selected profile's `sbinit.config` file, adding the `mods` and `storage` folders to the asset and storage directory fields in the init file. It then replaces Starbound's `sbinit.config` with this custom one, switching them back once Starbound exists. -In this mode PyMultibound will generate a fresh `sbinit.config` for each profile, meaning sbinits are profile-specific. - - -Originally PyMultibound did not edit `sbinit.config` and instead manually moved the `mods` and `storage` folders of each profile. It can take a while with large installs/many mods. This mode can be enabled by setting `use-sbinit` to `false` in the config. ## Character Appearance Editor ### THIS FEATURE IS EXPERIMENTAL, MAKE BACKUPS BEFORE USING diff --git a/main.py b/main.py index 4e36506..1641681 100644 --- a/main.py +++ b/main.py @@ -4,10 +4,10 @@ from util import * # Version of PyMultibound -version = "0.1-ALPHA" +version = "0.2-ALPHA" # Fore, Back, etc. allow us to use colored text through Colorama -# It is automatically replaced with placeholder "" by settingsloader if colored-text is false +# It is automatically replaced with placeholder "" by util.py if colored-text is false # How to use: # print(f"{Fore.RED}RED TEXT{Style.RESET_ALL}") # would print RED TEXT in the color red. f-strings make this @@ -82,28 +82,9 @@ def select_profile(profiles): print(profile_menu.display()) -def load_profile(): - global current_profile - """Switch the currently loaded profile for a different one""" - # Have the user select a profile and then load it in - # profile = select_profile(profiles) - logging.debug("Asking user for load confirmation") - print(f"{Fore.GREEN}Load profile {current_profile.name}? (Y/N) ") - if not settings["use-sbinit"]: - print(f"{Fore.YELLOW}This will erase the current game data!") - print("Save it as a profile/update the profile first!") - - if "y" in input().lower(): - return current_profile.load() - else: - logging.info("Profile load aborted") - return False - - def switch_profile(): - """Switch current profile but do no file manipulation""" + """Switch current profile""" global current_profile - current_profile.unload() current_profile = select_profile(profiles) @@ -113,7 +94,8 @@ def new_profile(): profile = Profile() profile.create(profile_name, starbound_dir, workshop_dir) profiles.append(profile) - + if "y" in input("Move current Starbound data into profile?").lower(): + profile.get_starbound_data(starbound_dir, workshop_dir) def delete_profile(): @@ -151,47 +133,27 @@ def help_page(): {Fore.CYAN} ---- PyMultibound Menu Options ---- -{Fore.GREEN}Help{Style.RESET_ALL}: Brings up this message (clearly you know this by now) -{Fore.GREEN}Run Starbound{Style.RESET_ALL}: This will run Starbound, with the currently installed profile - This works by deleting the current Starbound "mods" and "storage" folders, - and replacing them with the saved ones for this profile. - - The current profile will be auto-updated when you quit starbound. -{Fore.GREEN}Update Profile{Style.RESET_ALL}: This sets the profile"s data to whatever is currently in the Starbound folder - {Fore.YELLOW}WARNING: if used while Starbound folder is empty, this can - effectively delete the current profile! -{Fore.GREEN}Switch Profile{Style.RESET_ALL}: Change the currently selected profile -{Fore.GREEN}New Profile{Style.RESET_ALL}: Creates a new profile, but does not define any mods/universe - Use "Update Profile" with the new profile selected to define mods +{Fore.GREEN}Help{Style.RESET_ALL}: Brings up this message (clearly you know this by now) +{Fore.GREEN}Run Starbound{Style.RESET_ALL}: Run Starbound with the currently selected profile +{Fore.GREEN}Switch Profile{Style.RESET_ALL}: Change the currently selected profile +{Fore.GREEN}New Profile{Style.RESET_ALL}: Creates a new profile, optionally moving the current Starbound save into this profile {Fore.GREEN}Delete Profile{Style.RESET_ALL}: This will completely delete all of the selected profile"s data. {Fore.GREEN}Quit{Style.RESET_ALL}: Quit the program - """) def run_starbound(): """Run starbound as it is now, and update the current profile on exit""" global current_profile - if not load_profile(): # it failed + if not current_profile.load(): # it failed logging.error(f"Failed to load profile {current_profile.name}, not starting Starbound.") return logging.info("Starting starbound...") - cmd = os.path.join(starbound_dir, settings["starbound"], "starbound.exe") - # os.path.join() does not escape spaces, so - # it thinks you are trying to run - # C:/Program because of the space in program files - # So, have to do some string work lol + cmd = f'"{os.path.join(starbound_dir, settings["starbound"], "starbound.exe")}" ' \ + f'-bootconfig "{os.path.join(current_profile.directory, "sbinit.config")}"' + logging.info(f"Launch command: {cmd}") os.system(f'"{cmd}"') # Run the game - logging.info("Starbound closed, updating profile") - print(f"{Fore.GREEN}Updating profile, please wait...") - if (not settings["use-sbinit"]) or settings["duration-warning"]: - print(f"{Fore.YELLOW}This may take a while if you have a large universe or many mods") - print(f"{Fore.GREEN}If it takes too long, try setting `use-sbinit` to true in settings.json.") - print(f"{Fore.GREEN}However, that is an experimental feature and likely has bugs.") - print(f"{Fore.GREEN}To disable this warning, set duration-warning to false in the settings file") - current_profile.unload() - print(f"{Fore.GREEN}Profile {current_profile.name} updated!") if settings["compress-profiles"]: print(f"{Fore.GREEN}Compressing {current_profile.name}. This may take a while.") print(f"{Fore.GREEN}Profile compression can be disabled in settings.json!") @@ -200,8 +162,6 @@ def run_starbound(): def quit_program(): - global current_profile - current_profile.unload() shutil.rmtree(temp_dir) print(f"{Fore.CYAN}Profiles saved, quitting...") logging.info(f"Quitting PyMultibound - {version}") @@ -214,11 +174,6 @@ def quit_program(): print(f"{Fore.GREEN}By trainb0y1") print() - if settings["backup-warning"]: - logging.debug("settings['backup-warning'] is True") - print( - f"{Fore.RED}{Back.YELLOW}{Style.BRIGHT}BE SURE TO MAKE A BACKUP BEFORE USING, THIS WILL DELETE THE STARBOUND DATA (See help for more info){Style.RESET_ALL}") - print(f"{Fore.RED} To disable this message, set backup-warning to false in settings.json") logging.debug("Entering main menu loop") while True: # Main Menu Loop main_menu = menu.Menu( @@ -226,7 +181,6 @@ def quit_program(): ("Help", help_page), (f"Run Starbound ({Fore.CYAN + current_profile.name + Style.RESET_ALL})", run_starbound), ("Switch Profile", switch_profile), - (f"Update Profile ({Fore.CYAN + current_profile.name + Style.RESET_ALL})", current_profile.update), ("New Profile", new_profile), ("Delete Profile", delete_profile), ("Character Appearance Editor", editor.character_editor_menu), diff --git a/profile.py b/profile.py index 631bf45..322f45f 100644 --- a/profile.py +++ b/profile.py @@ -1,14 +1,13 @@ -import json -import os, shutil, logging +import os, shutil, logging, copy, json from os.path import join -import util from util import * + class Profile: def create(self, name, starbound_dir, workshop_dir): - logging.info(f"Creating profile with name {name}, sb dir {starbound_dir} and workshop dir {workshop_dir}") + logging.info(f"Creating profile with name {name}") # Get the directory of this script script_dir = os.path.dirname(os.path.realpath(__file__)) profiles_dir = join(script_dir, "profiles") @@ -28,40 +27,18 @@ def create(self, name, starbound_dir, workshop_dir): else: logging.info(f"Found pre-existing {directory} directory") - if settings["use-sbinit"]: - if not os.path.isfile(join(profile_dir, "sbinit.config")): - with open(join(profile_dir, "sbinit.config"), "x") as f: - json.dump(util.blank_sbinit, f, indent=2) - logging.info(f"Created blank sbinit.config for {name}") - else: - logging.info(f"Found existing sbinit.config for {name}") - - self.name = name # Save the name + if not os.path.isfile(join(profile_dir, "sbinit.config")): + with open(join(profile_dir, "sbinit.config"), "x") as f: + sbinit = copy.deepcopy(blank_sbinit) + sbinit["assetDirectories"].append(join(profile_dir, "mods")) + sbinit["storageDirectory"] = join(profile_dir, "storage") + print(sbinit) + json.dump(sbinit, f, indent=2) + logging.info(f"Created sbinit.config for {name}") + else: + logging.info(f"Found existing sbinit.config for {name}") self.directory = profile_dir - self.starbound_dir = starbound_dir - self.workshop_dir = workshop_dir - self.loaded = False - logging.debug(f"Saved attributes for {name}") - - def clear_starbound(self): - """Delete all of the profile specific stuff in the starbound and workshop folders""" - logging.debug(f"Call to clear_starbound() in profile {self.name}") - if settings["use-sbinit"]: - logging.critical("Attempted to clear Starbound, but we're set to use sbinit.config!") - raise Exception("Attempted to clear Starbound, but we're set to use sbinit.config! This is a critical bug!") - - if self.loaded: - logging.warning("Attempt to clear starbound while this profile is loaded! Asking user for confirmation.") - print( - f"{Fore.YELLOW}Clearing Starbound directory while profile {self.name} is loaded! Are you sure you " - f"want to procede? (Y/N)") - if "y" not in input(f"{Fore.RED}THIS WILL CLEAR THIS PROFILE").lower(): - logging.info("Starbound directory clear aborted") - return - for directory in ["mods", "storage"]: - for d in [join(self.starbound_dir, directory)]: - if os.path.exists(d): shutil.rmtree(d) - logging.info('Deleted Starbound "mods" and "storage" folders') + self.name = name # Save the name def load(self): """Load this profile into the starbound install""" @@ -69,124 +46,35 @@ def load(self): # First, inflate any compressed profile data self.unpack() + if not os.path.isfile(join(self.directory, "sbinit.config")): + # In all honesty we could just create a blank one here, + # but this implies it was moved/deleted or there is a serious bug, + # as there is no way use-sbinit could be false on profile creation + # and be true here - if settings["use-sbinit"]: - logging.info(f"Loading {self.name} using sbinit.config") - if not os.path.isfile(join(self.directory, "sbinit.config")): - # In all honesty we could just create a blank one here, - # but this implies it was moved/deleted or there is a serious bug, - # as there is no way use-sbinit could be false on profile creation - # and be true here - - # ...and also I already wrote all of this before I thought - # of just creating one :) -trainb0y1 - - print(f"\n{Fore.RED}No sbinit.config found for profile {self.name}!") - print("Either you moved or deleted it after you started this program, or this") - print("is a bug, please report it (along with PyMultibound.log) on the GitHub!") - print("https://github.com/trainb0y1/PyMultibound/issues\n") - print() - print("Will attempt to load profile by moving files...") - - logging.warning("Attempted to load profile using sbinit but no sbinit.config found for this profile!") - logging.info("Attempting to load profile by moving files...") - - settings["use-sbinit"] = False - self.load() - settings["use-sbinit"] = True - return False - - # Take the current sbinit.config, add our - # storage folder and mods folder - - # DONT DELETE THIS PROFILE'S SBINIT.CONFIG - # in case the user has custom stuff in there. - # we don't really want to have to deal with undoing - # this, it's easier just to leave it - - logging.debug("Loading data from profile's sbinit...") - try: - with open(join(self.directory, "sbinit.config"), "r") as f: - sbinit = json.load(f) - logging.debug("Got data from sbinit") - except Exception as e: - logging.error(f"Error reading {self.name}'s sbinit.config': {e}") - return False + # ...and also I already wrote all of this before I thought + # of just creating one :) -trainb0y1 - sbinit["assetDirectories"].append(join(self.directory, "mods")) - sbinit["storageDirectory"] = join(self.directory, "storage") - logging.debug("Edited sbinit data") + print(f"\n{Fore.RED}No sbinit.config found for profile {self.name}!") + print("Either you moved or deleted it after you started this program, or this") + print("is a bug, please report it (along with PyMultibound.log) on the GitHub!") + print("https://github.com/trainb0y1/PyMultibound/issues\n") + logging.warning("Attempted to load profile using sbinit but no sbinit.config found for this profile!") + return False + return True - util.safe_move( - join(self.starbound_dir, settings["starbound"], "sbinit.config"), - join(self.directory, "sbinit-original.config") - ) - logging.info("Moved sbinit.config to sbinit-original.config") - - with open(join(self.starbound_dir, settings["starbound"], "sbinit.config"), "x") as sb: - json.dump(sbinit, sb) - self.loaded = True - logging.info("Replaced Starbound's sbinit.config") - return True - - - else: - logging.info(f"Loading {self.name} by moving files, not sbinit.config!") - # Actually move the files - self.clear_starbound() - for directory in ["mods", "storage"]: - util.safe_move(join(self.directory, directory), self.starbound_dir) - self.loaded = True - logging.info(f"Profile {self.name} loaded into Starbound") - return True - - def unload(self): - """Basically update(), but sets loaded to False and clears the Starbound dir""" - if not self.loaded: - logging.warning("Attempt to unload non-loaded profile, ignoring!") - return - if settings["use-sbinit"]: - logging.info("Replacing Starbound's sbinit.config with original") - util.safe_move( - join(self.directory,"sbinit-original.config"), - join(self.starbound_dir, settings["starbound"], "sbinit.config") - ) - logging.info("Replaced Starbound's sbinit.config") - self.loaded = False - - else: - logging.info(f"Unloading profile {self.name}...") - print(f"{Fore.GREEN}Unloading {self.name}...") - self.update(ignore_workshop=True) - self.loaded = False - self.clear_starbound() - print(f"{Fore.GREEN}Unloaded {self.name}") - logging.info(f"Unloaded profile {self.name}") - - def update(self, ignore_workshop=False): + def get_starbound_data(self, starbound_dir, workshop_dir): """Update this profile with the current starbound data""" - if not self.loaded: - logging.warning(f"Attempt to update profile {self.name} while not loaded! Asking user for confirmation") - if "y" not in input( - f"{Fore.YELLOW}Profile {self.name} is not currently loaded.\nAre you sure you want to update it? (Y/N) ").lower(): - logging.info("Profile update aborted by user") - return - else: - logging.debug(f"Updating loaded profile {self.name}") - - if os.path.exists(self.directory): - shutil.rmtree(self.directory) - logging.debug(f"Deleted profile directory for {self.name} for the purpose of a profile update") - print(f"{Fore.GREEN}Updating {self.name}...") + print(f"{Fore.GREEN}Replacing {self.name} with current Starbound data...") # This next chunk bothers me. # I feel like I should be able to get it done with one try/except and # iterate through the types, but right now I lack the brainpower # TODO: Clean this up! try: # Mods - util.safe_move(join(self.starbound_dir, "mods"), join(self.directory, "mods")) + util.safe_move(join(starbound_dir, "mods"), join(self.directory, "mods")) logging.debug("Moved mods folder to profile folder") except FileNotFoundError: logging.warning("Failed to move mods folder to profile folder; the Starbound folder does not have a mods " @@ -197,7 +85,7 @@ def update(self, ignore_workshop=False): logging.debug(f"Creating empty mods directory for profile {self.name}") try: # Storage - util.safe_move(join(self.starbound_dir, "storage"), join(self.directory, "storage")) + util.safe_move(join(starbound_dir, "storage"), join(self.directory, "storage")) logging.debug("Moved storage folder to profile folder") except FileNotFoundError: logging.warning("Failed to move storage folder to profile folder; the Starbound folder does not have a " @@ -213,13 +101,10 @@ def update(self, ignore_workshop=False): # First, we want to ask if we should include these workshop mods in the profile # If so, we should move the .pak s to our mods folder, and rename them # to workshop-mod-(numerical id) - if ignore_workshop: - logging.info(f"Ignoring Steam Workshop mods, finished updating profile {self.name}") - return logging.info("Checking for workshop mods...") - if len(next(os.walk(self.workshop_dir))[1]) > 0: + if len(next(os.walk(workshop_dir))[1]) > 0: logging.info(f"Found workshop mods, asking user if they should be included in profile {self.name}") if "y" not in input( f"{Fore.YELLOW}Steam Workshop mods detected, would you like to add them to the profile?{Style.RESET_ALL} (Y/N) ").lower(): @@ -228,14 +113,14 @@ def update(self, ignore_workshop=False): logging.info(f"Ignoring Steam Workshop mods, finished updating profile {self.name}") return - for name in next(os.walk(self.workshop_dir))[1]: + for name in next(os.walk(workshop_dir))[1]: # Basically for numerically id-ed folder - if not os.path.isfile(join(self.workshop_dir, name, "contents.pak")): + if not os.path.isfile(join(workshop_dir, name, "contents.pak")): print(f"{Fore.YELLOW}No contents.pak found in workshop mod {name}") logging.warning(f"No contents.pak file was found in workshop mod {name}") else: util.safe_move( - join(self.workshop_dir, name, "contents.pak"), + join(workshop_dir, name, "contents.pak"), join(self.directory, "mods", f"workshop-mod-{name}.pak") ) print(f"Installed workshop mod {name}") @@ -250,9 +135,6 @@ def update(self, ignore_workshop=False): def compress(self): """Compress the save"s data""" - if self.loaded: - logging.warning("Attempt to compress loaded profile! Ignoring!") - return if not settings["compress-profiles"]: logging.info("Call to Profile.compress() with compress-profiles false, ignoring!") logging.info(f"Compressing profile {self.name}") @@ -304,10 +186,5 @@ def unpack(self): def delete(self): """Delete all files relating to this profile""" logging.info(f"Deleting data for profile {self.name}") - if self.loaded: - logging.warning(f"Deleting profile {self.name} while profile is loaded! This is probably an error!") - print(f"{Fore.RED}WARNING: Deleting profile while profile is loaded!") - script_dir = os.path.dirname(os.path.realpath(__file__)) shutil.rmtree(self.directory) - self.loaded = False logging.info(f"Deleted profile {self.name}") diff --git a/util.py b/util.py index e40a02e..fdcd2e0 100644 --- a/util.py +++ b/util.py @@ -33,12 +33,9 @@ def __init__(self): logging.info("settings.json not found, creating it") with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings.json"), "x") as f: json.dump({ - "backup-warning": True, - "duration-warning": True, "colored-text": True, "steamapps-directory": ("c:\\", "Program Files (x86)", "Steam", "steamapps"), "compress-profiles": True, - "use-sbinit":True, "starbound": "win64" }, f, indent=4) @@ -63,24 +60,22 @@ def safe_move(src, dst): return False - - colorama.init(autoreset=True) Style, Fore, Back = colorama.Style, colorama.Fore, colorama.Back blank_sbinit = { - "assetDirectories" : [ - "..\\assets\\", - "..\\mods\\" - ], - - "storageDirectory" : "..\\storage\\", - - "defaultConfiguration" : { - "gameServerBind" : "*", - "queryServerBind" : "*", - "rconServerBind" : "*" - } + "assetDirectories": [ + "..\\assets\\", + "..\\mods\\" + ], + + "storageDirectory": "..\\storage\\", + + "defaultConfiguration": { + "gameServerBind": "*", + "queryServerBind": "*", + "rconServerBind": "*" + } } settings = load_settings() @@ -89,16 +84,17 @@ def safe_move(src, dst): Style, Fore, Back = PlaceHolder(), PlaceHolder(), PlaceHolder() # Find all of the directories -profiles_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "profiles") # Directory in which the profiles reside +profiles_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), + "profiles") # Directory in which the profiles reside steamapps_dir = os.path.join( *settings["steamapps-directory"]) # see docs on os.path.join # Directory for all steam apps ("steamapps") -starbound_dir = os.path.join(steamapps_dir, "common", "Starbound") # Main Starbound directory inside of steamapps -workshop_dir = os.path.join(steamapps_dir, "workshop", "content", "211820") # Directory for starbound's workshop mods +starbound_dir = os.path.join(steamapps_dir, "common", "Starbound") # Main Starbound directory inside of steamapps +workshop_dir = os.path.join(steamapps_dir, "workshop", "content", "211820") # Directory for starbound's workshop mods temp_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "temp") # Directory to store temporary files in -templates_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") # Directory to store appearance templates in +templates_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), + "templates") # Directory to store appearance templates in for directory in [temp_dir, templates_dir, profiles_dir]: if not os.path.exists(directory): os.makedirs(directory) logging.info(f"Created directory for {directory}") -