Skip to content

Add support for game installed on alternate drives #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 54 additions & 36 deletions bg3_mod_installer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3

import argparse
import json
import os
import shutil
Expand All @@ -11,15 +12,27 @@


class BG3ModInstaller:
def __init__(self):
self.steam_path = Path.home() / ".steam/steam"
def __init__(self, steam_path = None):
if not steam_path:
steam_path = Path.home() / ".steam/steam"
else:
steam_path = Path(steam_path)

self.steam_path = steam_path
self.game_id = "1086940"
self.larian_path = self.steam_path / f"steamapps/compatdata/{self.game_id}/pfx/drive_c/users/steamuser/AppData/Local/Larian Studios"
self.steam_userdata = self.steam_path / "userdata"

self.mods_path = self.larian_path / "Baldur's Gate 3/Mods"
self.profile_modsettings = self.larian_path / "Baldur's Gate 3/PlayerProfiles/Public/modsettings.lsx"

# Check on something that will exist in the most common scenario (a save game)
if not os.path.isdir(self.larian_path / "Baldur's Gate 3/PlayerProfiles/Public/Savegames/Story"):
print("Game not found, please ensure your Steam path is correct and Baldur's Gate 3 is installed.")
print(f"Currently set to:\n {self.steam_path} (change with the '--path' option)")
sys.exit(1)


def get_steam_id(self):
"""Get the first Steam ID from userdata directory."""
try:
Expand All @@ -36,9 +49,9 @@ def sync_modsettings(self):
try:
steam_id = self.get_steam_id()
userdata_modsettings = self.steam_userdata / steam_id / self.game_id / "modsettings.lsx"

userdata_modsettings.parent.mkdir(parents=True, exist_ok=True)

shutil.copy2(self.profile_modsettings, userdata_modsettings)
print(f"Synchronized modsettings.lsx to {userdata_modsettings}")
except Exception as e:
Expand All @@ -51,13 +64,13 @@ def get_installed_mods(self) -> List[Dict]:
tree = ET.parse(self.profile_modsettings)
root = tree.getroot()
mods = []

for mod in root.findall(".//node[@id='ModuleShortDesc']"):
mod_info = {}
for attr in mod.findall("attribute"):
mod_info[attr.get('id')] = attr.get('value')
mods.append(mod_info)

return mods
except Exception as e:
print(f"Error reading installed mods: {e}")
Expand Down Expand Up @@ -106,7 +119,7 @@ def remove_mod(self, mod_index: int):
return False

mod_to_remove = installed_mods[mod_index]

# Ask for confirmation
if not self.confirm_action("remove", mod_to_remove):
print("Mod removal cancelled.")
Expand All @@ -123,18 +136,18 @@ def remove_mod(self, mod_index: int):
# Update modsettings.lsx
tree = ET.parse(self.profile_modsettings)
root = tree.getroot()

mods_children = root.find(".//node[@id='Mods']/children")
if mods_children is not None:
for mod in mods_children.findall("node[@id='ModuleShortDesc']"):
folder = mod.find("attribute[@id='Folder']")
if folder is not None and folder.get('value') == mod_folder:
mods_children.remove(mod)
break

tree.write(self.profile_modsettings, encoding="utf-8", xml_declaration=True)
print(f"Updated {self.profile_modsettings}")

self.sync_modsettings()
return True

Expand All @@ -146,41 +159,41 @@ def create_mod_xml(self, mod_info):
"""Create XML structure for mod entry."""
module = ET.Element("node")
module.set("id", "ModuleShortDesc")

attributes = {
"Folder": mod_info["Folder"],
"MD5": mod_info.get("MD5", ""),
"Name": mod_info["Name"],
"UUID": mod_info["UUID"],
"Version64": str(mod_info.get("Version", "36028797018963968"))
}

for key, value in attributes.items():
attr = ET.SubElement(module, "attribute")
attr.set("id", key)
attr.set("type", "LSString")
attr.set("value", value)

return module

def update_modsettings(self, mod_info):
"""Update modsettings.lsx file with new mod information."""
try:
tree = ET.parse(self.profile_modsettings)
root = tree.getroot()

mods_children = root.find(".//node[@id='Mods']/children")
if mods_children is None:
raise Exception("Mods children section not found in modsettings.lsx")

new_module = self.create_mod_xml(mod_info)
mods_children.append(new_module)

tree.write(self.profile_modsettings, encoding="utf-8", xml_declaration=True)
print(f"Updated {self.profile_modsettings}")

self.sync_modsettings()

except Exception as e:
print(f"Error updating modsettings: {e}")
sys.exit(1)
Expand All @@ -190,32 +203,32 @@ def install_mod(self, mod_path):
try:
# Create mods directory if it doesn't exist
self.mods_path.mkdir(parents=True, exist_ok=True)

if mod_path.suffix.lower() in ['.zip', '.rar', '.7z']:
# Get mod info and confirm installation
mod_info = self.get_mod_info_from_zip(mod_path)
if mod_info:
if not self.confirm_action("install", mod_info):
print("Mod installation cancelled.")
return

# Extract archive
with zipfile.ZipFile(mod_path, 'r') as zip_ref:
pak_files = [f for f in zip_ref.namelist() if f.endswith('.pak')]
info_files = [f for f in zip_ref.namelist() if f.endswith('info.json')]

if not pak_files:
raise Exception("No .pak files found in archive")

for pak_file in pak_files:
zip_ref.extract(pak_file, self.mods_path)
print(f"Installed {pak_file} to mods directory")

if info_files:
info_data = json.loads(zip_ref.read(info_files[0]))
if "Mods" in info_data and len(info_data["Mods"]) > 0:
self.update_modsettings(info_data["Mods"][0])

elif mod_path.suffix.lower() == '.pak':
print("\nWarning: Installing a .pak file directly. No mod information available for confirmation.")
while True:
Expand All @@ -226,13 +239,13 @@ def install_mod(self, mod_path):
if response in ['yes', 'y']:
break
print("Please answer with 'yes' or 'no'")

shutil.copy2(mod_path, self.mods_path)
print(f"Installed {mod_path.name} to mods directory")

else:
raise Exception("Unsupported file type. Please provide a .zip archive or .pak file")

except Exception as e:
print(f"Error installing mod: {e}")
sys.exit(1)
Expand All @@ -257,7 +270,7 @@ def display_installed_mods(mods: List[Dict]):
print("\nInstalled Mods:")
for i, mod in enumerate(mods):
print(f"{i + 1}. {mod['Name']} ({mod['Folder']})")

while True:
try:
choice = int(input("\nEnter the number of the mod to remove (0 to cancel): "))
Expand All @@ -270,35 +283,40 @@ def display_installed_mods(mods: List[Dict]):
print("Please enter a valid number")

def main():
installer = BG3ModInstaller()

parser = argparse.ArgumentParser(description="Install Baldur's Gate 3 Mods on Linux")
parser.add_argument(
"-p", "--path",
help="Root path for Steam (default: '~/.steam/steam') - should contain the 'steamapps' dir.")
args = parser.parse_args()
installer = BG3ModInstaller(steam_path=args.path)

while True:
choice = display_menu()

if choice == 1: # Install mod
mod_path = input("\nEnter the path to the mod file (.zip or .pak): ")
try:
installer.install_mod(Path(mod_path))
print("Mod installation completed successfully!")
except Exception as e:
print(f"Error installing mod: {e}")

elif choice == 2: # Remove mod
installed_mods = installer.get_installed_mods()
if not installed_mods:
print("No mods currently installed.")
continue

mod_index = display_installed_mods(installed_mods)
if mod_index is not None:
if installer.remove_mod(mod_index):
print("Mod removed successfully!")
else:
print("Failed to remove mod.")

else: # Exit
print("Goodbye!")
break

if __name__ == "__main__":
main()
main()