Created
August 19, 2025 05:47
-
-
Save pythoninthegrass/73113ebd9ff1ebffbe674fd4a56659c6 to your computer and use it in GitHub Desktop.
Clair Obscur Expedition 33 performance mod installer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S uv run --script | |
# /// script | |
# requires-python = ">=3.13" | |
# dependencies = [ | |
# "httpx>=0.28.1", | |
# "rich>=13.9.4", | |
# "sh>=2.2.2", | |
# "python-decouple>=3.8", | |
# ] | |
# [tool.uv] | |
# exclude-newer = "2025-08-31T00:00:00Z" | |
# /// | |
# pyright: reportMissingImports=false | |
""" | |
Clair Obscur: Expedition 33 - The Definitive Performance Mod Installer for Bazzite | |
Usage: | |
coexp33_installer [command] | |
Commands: | |
install: Download and install the performance mod | |
list: List available presets | |
help: Show this help message | |
Environment Variables: | |
STEAM_PREFIX: Override Steam prefix path (optional) | |
Example: export STEAM_PREFIX="/custom/steam/path" | |
Note: | |
This script automatically detects Steam installations on: | |
- Bazzite (Flatpak Steam) | |
- Steam Deck / SteamOS | |
- Regular Linux Steam | |
The script installs the performance mod for Clair Obscur: Expedition 33 | |
using the detected Steam Wine prefix. | |
Original mod by ru-bem: https://github.com/ru-bem/COExp33-The-Definitive-Performance-Mod | |
""" | |
import httpx | |
import os | |
import sys | |
import tempfile | |
import zipfile | |
from decouple import config | |
from pathlib import Path | |
from rich import print as rprint | |
from rich.console import Console | |
from rich.panel import Panel | |
from rich.prompt import Prompt | |
from rich.table import Table | |
# from sh import ErrorReturnCode # TODO: add bin wrapper | |
console = Console() | |
# Clair Obscur Steam App ID | |
STEAM_APP_ID = "1903340" | |
# GitHub repository details | |
GITHUB_REPO = "ru-bem/COExp33-The-Definitive-Performance-Mod" | |
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}" | |
def get_steam_prefix(): | |
"""Dynamically determine Steam prefix path based on environment and system.""" | |
# Get user home directory | |
home = Path.home() | |
username = os.getenv("USER", "deck") # Default to 'deck' for Steam Deck | |
# Try environment variable first | |
env_prefix = config("STEAM_PREFIX", default="") | |
if env_prefix and Path(env_prefix).exists(): | |
console.print(f"[blue]Using Steam prefix from environment: {env_prefix}[/blue]") | |
return Path(env_prefix) | |
# Define possible Steam prefix paths to check | |
possible_prefixes = [ | |
# Bazzite (Flatpak Steam) | |
home / f".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata/{STEAM_APP_ID}/pfx/drive_c/users/steamuser", | |
# Steam Deck / SteamOS | |
home / f".local/share/Steam/steamapps/compatdata/{STEAM_APP_ID}/pfx/drive_c/users/steamuser", | |
# Alternative Steam Deck path | |
Path(f"/home/deck/.local/share/Steam/steamapps/compatdata/{STEAM_APP_ID}/pfx/drive_c/users/steamuser"), | |
# Regular Linux Steam | |
home / f".steam/steam/steamapps/compatdata/{STEAM_APP_ID}/pfx/drive_c/users/steamuser", | |
# Alternative Linux Steam paths | |
home / f".steam/steamapps/compatdata/{STEAM_APP_ID}/pfx/drive_c/users/steamuser", | |
] | |
# Check each possible prefix | |
for prefix in possible_prefixes: | |
if prefix.exists(): | |
console.print(f"[green]Found Steam prefix: {prefix}[/green]") | |
return prefix | |
# If none found, return the first one (Bazzite default) for error reporting | |
console.print(f"[yellow]No existing Steam prefix found. Using default: {possible_prefixes[0]}[/yellow]") | |
return possible_prefixes[0] | |
def get_config_paths(steam_prefix: Path): | |
"""Get possible config directory paths for the game.""" | |
return [ | |
steam_prefix / "AppData/Local/Sandfall/Saved/Config/Windows", | |
steam_prefix / "AppData/Local/Sandfall/Saved/Config/WinGDK" | |
] | |
# Available presets with descriptions and performance boosts | |
PRESETS = { | |
"1_Lossless": { | |
"name": "Lossless", | |
"description": "Almost identical to Original", | |
"boost": "70%", | |
"recommended_for": "High end (RTX 4070+)" | |
}, | |
"2_Quality": { | |
"name": "Quality", | |
"description": "High quality (Recommended)", | |
"boost": "80%", | |
"recommended_for": "Mid end (RTX 3060~) / Mid-low end (RTX 2060~)" | |
}, | |
"3_Balanced": { | |
"name": "Balanced", | |
"description": "The perfect balance", | |
"boost": "105%", | |
"recommended_for": "Low end (RTX 3050~)" | |
}, | |
"4_Performance": { | |
"name": "Performance", | |
"description": "A miracle. Huge FPS boost!", | |
"boost": "127%", | |
"recommended_for": "Very old (GTX 1050~)" | |
}, | |
"5_Potato": { | |
"name": "Potato", | |
"description": "Low-end - Ugly but playable", | |
"boost": "201%", | |
"recommended_for": "Ancient hardware (GT 1030~)" | |
}, | |
"6_Monstrosity": { | |
"name": "Monstrosity", | |
"description": "Hell. Don't do this...", | |
"boost": "261%", | |
"recommended_for": "Desperate times" | |
}, | |
"7_Steamdeck_q": { | |
"name": "Steam Deck Quality", | |
"description": "Optimized for Steam Deck", | |
"boost": "Variable", | |
"recommended_for": "Steam Deck users" | |
}, | |
"8_Steamdeck_p": { | |
"name": "Steam Deck Performance", | |
"description": "Maximum performance for Steam Deck", | |
"boost": "Variable", | |
"recommended_for": "Steam Deck users" | |
} | |
} | |
def check_prerequisites(): | |
"""Check if the game is installed and directories exist.""" | |
steam_prefix = get_steam_prefix() | |
if not steam_prefix.exists(): | |
console.print(f"[red]Steam prefix not found at: {steam_prefix}[/red]") | |
console.print("[yellow]Make sure Clair Obscur is installed and has been run at least once.[/yellow]") | |
console.print("[dim]You can also set STEAM_PREFIX environment variable to specify a custom path.[/dim]") | |
return False, None, None | |
# Check if any config directory exists | |
config_paths = get_config_paths(steam_prefix) | |
config_dir = None | |
for path in config_paths: | |
if path.exists(): | |
config_dir = path | |
break | |
if not config_dir: | |
console.print("[red]Game config directory not found.[/red]") | |
console.print("[yellow]Make sure you've run the game at least once to create the config directory.[/yellow]") | |
console.print(f"[dim]Checked paths: {', '.join(str(p) for p in config_paths)}[/dim]") | |
return False, steam_prefix, config_paths | |
console.print(f"[green]Found game config directory: {config_dir}[/green]") | |
return True, steam_prefix, [config_dir] | |
def get_latest_release(): | |
"""Get the latest release information from GitHub.""" | |
try: | |
with httpx.Client() as client: | |
response = client.get(f"{GITHUB_API_URL}/releases/latest") | |
response.raise_for_status() | |
return response.json() | |
except httpx.RequestError as e: | |
console.print(f"[red]Failed to fetch release info: {e}[/red]") | |
return None | |
def download_mod(download_url: str, temp_dir: Path): | |
"""Download the mod zip file.""" | |
console.print(f"[blue]Downloading mod from: {download_url}[/blue]") | |
try: | |
with httpx.Client(follow_redirects=True) as client: | |
with client.stream("GET", download_url) as response: | |
response.raise_for_status() | |
total_size = int(response.headers.get('content-length', 0)) | |
zip_path = temp_dir / "mod.zip" | |
with open(zip_path, "wb") as f: | |
downloaded = 0 | |
for chunk in response.iter_bytes(chunk_size=8192): | |
f.write(chunk) | |
downloaded += len(chunk) | |
if total_size > 0: | |
progress = (downloaded / total_size) * 100 | |
console.print(f"\r[blue]Progress: {progress:.1f}%[/blue]", end="") | |
console.print(f"\n[green]Downloaded successfully: {zip_path}[/green]") | |
return zip_path | |
except httpx.RequestError as e: | |
console.print(f"[red]Download failed: {e}[/red]") | |
return None | |
def extract_mod(zip_path: Path, extract_dir: Path): | |
"""Extract the mod zip file.""" | |
try: | |
with zipfile.ZipFile(zip_path, 'r') as zip_ref: | |
zip_ref.extractall(extract_dir) | |
console.print(f"[green]Extracted mod to: {extract_dir}[/green]") | |
return True | |
except zipfile.BadZipFile as e: | |
console.print(f"[red]Failed to extract zip file: {e}[/red]") | |
return False | |
def list_presets(): | |
"""Display available presets in a nice table.""" | |
table = Table(title="Available Performance Presets") | |
table.add_column("ID", style="cyan") | |
table.add_column("Name", style="magenta") | |
table.add_column("Description", style="green") | |
table.add_column("Performance Boost", style="yellow") | |
table.add_column("Recommended For", style="blue") | |
for preset_id, info in PRESETS.items(): | |
table.add_row( | |
preset_id.split('_')[0], # Just the number/letter | |
info["name"], | |
info["description"], | |
info["boost"], | |
info["recommended_for"] | |
) | |
console.print(table) | |
def select_preset(): | |
"""Let user select a preset.""" | |
list_presets() | |
valid_choices = list(PRESETS.keys()) + [key.split('_')[0] for key in PRESETS.keys()] | |
while True: | |
choice = Prompt.ask( | |
"\n[bold]Select a preset[/bold]", | |
choices=valid_choices, | |
show_choices=False | |
) | |
# Handle both full name (like "1_Lossless") and short name (like "1") | |
if choice in PRESETS: | |
return choice | |
else: | |
# Find by number/letter | |
for preset_id in PRESETS: | |
if preset_id.split('_')[0] == choice: | |
return preset_id | |
def install_preset(mod_dir: Path, preset_id: str, config_paths: list[Path]): | |
"""Install the selected preset.""" | |
preset_path = mod_dir / preset_id | |
if not preset_path.exists(): | |
console.print(f"[red]Preset directory not found: {preset_path}[/red]") | |
return False | |
# Find the config directory | |
config_dir = None | |
for path in config_paths: | |
if path.exists(): | |
config_dir = path | |
break | |
if not config_dir: | |
console.print("[red]Config directory not found![/red]") | |
return False | |
# Ensure config directory exists using pathlib | |
try: | |
config_dir.mkdir(parents=True, exist_ok=True) | |
except OSError as e: | |
console.print(f"[red]Failed to create config directory: {e}[/red]") | |
return False | |
# Copy preset files | |
ini_files = ["Engine.ini", "Scalability.ini", "GameUserSettings.ini"] | |
copied_files = [] | |
for ini_file in ini_files: | |
source = preset_path / ini_file | |
dest = config_dir / ini_file | |
if source.exists(): | |
# Backup existing file using pathlib | |
if dest.exists(): | |
backup_path = dest.with_suffix(dest.suffix + ".backup") | |
try: | |
backup_path.write_bytes(dest.read_bytes()) | |
console.print(f"[yellow]Backed up existing {ini_file} to {backup_path.name}[/yellow]") | |
except OSError as e: | |
console.print(f"[yellow]Failed to backup {ini_file}: {e}[/yellow]") | |
# Copy new file using pathlib | |
try: | |
dest.write_bytes(source.read_bytes()) | |
copied_files.append(ini_file) | |
console.print(f"[green]Installed: {ini_file}[/green]") | |
except OSError as e: | |
console.print(f"[red]Failed to copy {ini_file}: {e}[/red]") | |
else: | |
console.print(f"[yellow]Skipped: {ini_file} (not found in preset)[/yellow]") | |
if copied_files: | |
preset_info = PRESETS[preset_id] | |
console.print(f"\n[bold green]Successfully installed {preset_info['name']} preset![/bold green]") | |
console.print(f"[blue]Expected performance boost: {preset_info['boost']}[/blue]") | |
console.print(f"[blue]Files installed: {', '.join(copied_files)}[/blue]") | |
# Show additional recommendations | |
rprint(Panel.fit( | |
f"[bold]Next Steps:[/bold]\n\n" | |
f"1. Launch Clair Obscur: Expedition 33\n" | |
f"2. Go to Graphics settings\n" | |
f"3. {preset_info['recommended_for']}: Use recommended in-game settings\n" | |
f"4. Scaling priority: DLSS > XESS > TSR\n\n" | |
f"[yellow]Enjoy your improved performance![/yellow]", | |
title="Installation Complete" | |
)) | |
return True | |
else: | |
console.print("[red]No files were installed![/red]") | |
return False | |
def install_mod(): | |
"""Main installation process.""" | |
console.print("[bold blue]Clair Obscur: Expedition 33 - Performance Mod Installer[/bold blue]") | |
console.print(f"[dim]Original mod by ru-bem: https://github.com/{GITHUB_REPO}[/dim]\n") | |
# Check prerequisites | |
prereq_ok, steam_prefix, config_paths = check_prerequisites() | |
if not prereq_ok: | |
return False | |
# Get latest release | |
console.print("[blue]Fetching latest mod version...[/blue]") | |
release_info = get_latest_release() | |
if not release_info: | |
return False | |
console.print(f"[green]Found version: {release_info['tag_name']}[/green]") | |
# Find download URL | |
download_url = None | |
for asset in release_info.get('assets', []): | |
if asset['name'].endswith('.zip'): | |
download_url = asset['browser_download_url'] | |
break | |
if not download_url: | |
console.print("[red]No zip file found in latest release![/red]") | |
return False | |
# Create temporary directory | |
with tempfile.TemporaryDirectory() as temp_dir: | |
temp_path = Path(temp_dir) | |
# Download mod | |
zip_path = download_mod(download_url, temp_path) | |
if not zip_path: | |
return False | |
# Extract mod | |
extract_dir = temp_path / "extracted" | |
if not extract_mod(zip_path, extract_dir): | |
return False | |
# Select preset | |
preset_id = select_preset() | |
# Install preset | |
return install_preset(extract_dir, preset_id, config_paths) | |
def main(): | |
"""Main entry point.""" | |
command = sys.argv[1] if len(sys.argv) > 1 else "help" | |
match command: | |
case "install": | |
success = install_mod() | |
sys.exit(0 if success else 1) | |
case "list": | |
list_presets() | |
case "help" | _: | |
console.print(__doc__.strip()) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment