import os import argparse import uuid import pickle saved_path_file = ".cache.pk" # e.g. b3a212d7-e669-4f3d-aa2f-3a629d00ba01 def generate_uuid(): return str(uuid.uuid4()) class Folder: def __init__(self, name): self.name = name self.dir = None def __enter__(self): self.dir = os.getcwd() new_dir = os.path.join(self.dir, self.name) os.makedirs(new_dir, exist_ok=True) os.chdir(new_dir) def __exit__(self, exc_type, exc_value, traceback): os.chdir(self.dir) class File: def __init__(self, name): self.name = name self._lines = [] self.indent = 0 def __enter__(self): self._lines = [] return self def clear(self): self._lines = [] def __exit__(self, exc_type, exc_value, traceback): with open(self.name, 'w') as f: f.writelines(self._lines) def add_line(self, text: str): self._lines.append("\t" * self.indent + text) class XMLFile(File): def __enter__(self): ret = super().__enter__() self.add_line('') return ret def create_symlinks(args): name = args.name with Folder(name): def generate_symlink(rel_path): full_rel_path = os.path.join(rel_path, name) path_in_data = os.path.join(args.data, full_rel_path) path_local = os.path.join(os.getcwd(), full_rel_path) # create the parent directory os.makedirs(os.path.dirname(path_in_data), exist_ok=True) os.symlink(path_local, path_in_data, target_is_directory=True) try: # create symlink to some folders to enable hot loading if args.assets: generate_symlink("Generated/Public") if args.scripts: generate_symlink("Mods") if args.items: generate_symlink("Public") # Data\Generated\Public\GrazztRing should be linked to .\GrazztRing\Generated\Public\GrazztRing except OSError as e: print("Failed to create symlink. Please run this script with administrator privileges.") print(e) def clean_symlinks(args): name = args.name def remove_symlink(rel_path): full_rel_path = os.path.join(rel_path, name) path_in_data = os.path.join(args.data, full_rel_path) os.unlink(path_in_data) remove_symlink("Generated/Public") remove_symlink("Mods") remove_symlink("Public") def create_mod(args): name = args.name author = args.author # check if this mod already exists and warn against override if os.path.exists(os.path.join(os.getcwd(), name)): confirm = input(f"Mod {name} already exists. Are you sure you want to recreate parts of it? (y/n) ").lower() if confirm not in ["y", "yes"]: print("Aborting.") return with Folder(name): # only if there are models and textures # with Folder(f"Generated/Public/{name}"): with Folder("Localization/English"): with XMLFile(f"{name}.loca.xml") as f: f.add_line("""\n""") # every mod needs a meta mod_uuid = generate_uuid() with Folder(f"Mods/{name}"): with XMLFile("meta.lsx") as f: f.add_line(f""" """) print(f"Created mod {name} with UUID {mod_uuid}") if args.items: with Folder(f"Public/{name}/RootTemplates"): with XMLFile(f"{name}.lsf.lsx") as f: f.add_line(""" """) # touch this file with Folder(f"Public/{name}/Stats/Generated"): with Folder("Data"): with File(f"{name}_Stats.txt") as f: f.add_line(" ") with File("TreasureTable.txt"): f.add_line(" ") # large icons with Folder(f"Public/Game/GUI/Assets/Tooltips"): with Folder("Icons"): with File("large 380 DDS DXT3 icons for abilities here.txt") as f: f.add_line(" ") with Folder("ItemIcons"): with File("large 380 DDS DXT3 icons for items here.txt") as f: f.add_line(" ") # controller icons with Folder(f"Public/Game/GUI/Assets/ControllerUIicons"): with Folder("skills_png"): with File("144 by 144 DDS DXT3 icons for abilities here.txt") as f: f.add_line(" ") with Folder("items_png"): with File("144 by 144 DDS DXT3 icons for items here.txt") as f: f.add_line(" ") # small icons with Folder(f"Public/{name}/Assets/Textures/Icons"): with File(f"small icon tileset 256 DDS DXT3 each 64 here called {name}_Icons.dds.txt") as f: f.add_line(" ") icon_uuid = generate_uuid() with Folder(f"Public/{name}/Content/UI/[PAK]_UI"): with XMLFile("_merged.lsf.lsx") as f: f.add_line(f""" """) with Folder(f"Public/{name}/GUI"): with XMLFile("Icons_Items.lsx") as f: f.add_line(f""" This file is not converted to lsf! 1 Left Right Top Bottom 2 Left Right Top Bottom 3 Left Right Top Bottom 4 Left Right Top Bottom 1 Left Right Top Bottom 2 Left Right Top Bottom 3 Left Right Top Bottom 4 Left Right Top Bottom 1 Left Right Top Bottom 2 Left Right Top Bottom 3 Left Right Top Bottom 4 Left Right Top Bottom 1 Left Right Top Bottom 2 Left Right Top Bottom 3 Left Right Top Bottom 4 Left Right Top Bottom """) if args.scripts: with Folder(f"Mods/{name}/ScriptExtender"): with File("Config.json") as f: f.add_line(f""" {{ "RequiredVersion": 4, "ModTable": "{name}", "FeatureFlags": ["Lua"] }} """) with Folder("Lua"): with File("BootstrapClient.lua") as f: f.add_line(" ") with File("BootstrapServer.lua") as f: f.add_line(f"Ext.Require(\"Server/{name}.lua\")") with Folder("Server"): with File(f"{name}.lua") as f: f.add_line(" ") if args.assets: with Folder(f"Generated/Public/{name}/Assets"): with File("place DDS and GR2 files here.txt") as f: f.add_line(" ") with Folder(f"Public/{name}/Content/Assets/Characters"): with Folder(f"[PAK]_Armor"): with File("define meshes materials and textures here.txt") as f: f.add_line(" ") def prompt_for_binary_response(message): while True: response = input(message).lower() if response in ["y", "yes"]: return True elif response in ["n", "no"]: return False else: print("Invalid response. Please enter y or n.") if __name__ == "__main__": # parse arguments with argparse parser = argparse.ArgumentParser(description="Initialize a new mod") parser.add_argument("name", help="Name of the mod") parser.add_argument("author", help="Author of the mod") parser.add_argument("-q", "--quiet", help="skip asking about optional creation features if they are not selected. By default each" " option will prompt the user for them.", action="store_true") parser.add_argument("-a", "--assets", action="store_true", help="Whether this mod will have custom assets such as" "textures and 3D models (default False)") parser.add_argument("-i", "--items", action="store_true", help="Whether this mod will add custom items " "(default False)") parser.add_argument("-s", "--scripts", action="store_true", help="Whether this mod will have custom scripts enabled" "by BG3SE (default False)") parser.add_argument("-l", "--link", action="store_true", help="Whether to create a symlink to the mod to allow hot-loading (fast update of mod without restarting game, just needs to save or load)") parser.add_argument("-d", "--data", help="Path to the BG3 data folder (default C:\Program Files (x86)\Steam\steamapps\common\Baldurs Gate 3\Data)", default="") parser.add_argument("-n", "--dont_create", action="store_true", help="Don't create the mod folder, just create the symlinks") parser.add_argument("-r", "--clean_symlinks", action="store_true", help="Remove all symlinks to the mod folder") args = parser.parse_args() if args.assets: args.items = True if args.clean_symlinks: args.dont_create = True args.link = True args.quiet = True if not args.quiet: if not args.assets: args.assets = prompt_for_binary_response("Will this mod have custom assets? (y/n) ") if args.assets: args.items = True if not args.items: args.items = prompt_for_binary_response("Will this mod have custom items? (y/n) ") if not args.scripts: args.scripts = prompt_for_binary_response("Will this mod have custom scripts? (y/n) ") if not args.link: args.link = prompt_for_binary_response( "Do you want to enable hot-loading to update mod without restarting game, just needs to save or load? (y/n) ") if args.link: if args.data == "": # see if the data path is cached if os.path.exists(saved_path_file): with open(saved_path_file, "rb") as f: args.data = pickle.load(f) print(f"Using cached data path '{args.data}'") else: while not os.path.exists(args.data): args.data = input( "Enter the path to the BG3 data folder e.g. C:\Program Files (x86)\Steam\steamapps\common\Baldurs Gate 3\Data\n") with open(saved_path_file, "wb") as f: pickle.dump(args.data, f) if not args.dont_create: print("Creating mod folder") create_mod(args) if args.link: print("Creating symlinks") create_symlinks(args) if args.clean_symlinks: print("Removing symlinks") clean_symlinks(args)