Skip to content

Instantly share code, notes, and snippets.

@pythoninthegrass
Created August 19, 2025 05:47
Show Gist options
  • Save pythoninthegrass/73113ebd9ff1ebffbe674fd4a56659c6 to your computer and use it in GitHub Desktop.
Save pythoninthegrass/73113ebd9ff1ebffbe674fd4a56659c6 to your computer and use it in GitHub Desktop.
Clair Obscur Expedition 33 performance mod installer
#!/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