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)