Something went wrong. Try again.
Steam Deck Doom modding setup automation for Doom Runner, UZDoom, Brutal Doom, and Project Brutality.
brutal-doom moddb python doom-runner linux-gaming project-brutality uzdoom doom steam-deck
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082#!/usr/bin/env python3"""DoomDeck CLI
Steam Deck Doom modding setup automation for:- Steam DOOM + DOOM II app 2280 IWAD discovery/copy- Windows Doom Runner installation for Proton- Windows UZDoom installation for Proton- Brutal Doom manual-drop validation- Steam non-Steam shortcut integration for Doom Runner- UZDoom launch metadata for vanilla-style, UZDoom, Brutal Doom, and Project Brutality presets- Doom Runner options.json generation with Proton-visible Windows paths
Design goals:- User-space only by default- Safe, idempotent, rerunnable- No root requirement- Back up Steam shortcuts.vdf before modification- Back up Doom Runner options.json before modification
Tested for syntax on Python 3.11. Should work on Python 3.10+."""from __future__ import annotations
import argparseimport datetime as _dtimport jsonimport loggingimport osimport reimport shutilimport subprocessimport sysimport tarfileimport tempfileimport urllib.errorimport urllib.parsefrom pathlib import Pathfrom typing import Any, Iterable, Optional
from doomdeck import __version__from doomdeck.application.doomrunner import ( DoomRunnerLiveConfigSettings, doomrunner_options_paths, write_doomrunner_live_config,)from doomdeck.application.docs import write_docsfrom doomdeck.application.install import build_install_planfrom doomdeck.application.launchers import ( read_existing_preset_manifest, refresh_existing_doomrunner_content_groups, write_content_group_metadata, write_launchers_and_manifest,)from doomdeck.application.managed_mods import ( install_brutal_doom_archive, install_project_brutality_archive, installed_payload_matches, metadata_matches,)from doomdeck.application.moddb_wads import install_moddb_wad_urlsfrom doomdeck.application.release_assets import fetch_windows_release_assetfrom doomdeck.application.restore import restore_backup_archivefrom doomdeck.application.self_update import ( DEFAULT_SELF_UPDATE_REF, DEFAULT_SELF_UPDATE_REPO_URL, build_github_commit_api_url, build_self_update_archive_url, build_self_update_plan, find_extracted_self_update_source_dir, infer_source_install_dir, prepare_self_update_runtime, replace_self_update_install_dir, validate_self_update_source_dir,)from doomdeck.application.steam import ( SteamShortcutSettings, add_or_update_doomrunner_shortcut, doomrunner_proton_options_path,)from doomdeck.application.steam_discovery import ( DOOM_PLUS_DOOM_II_APP_ID as APPID_DOOM_PLUS_DOOM_II, SteamDiscoverySettings, detect_steamos, discover_steam,)from doomdeck.application.steam_input import deploy_steam_input_profile, write_managed_steam_input_profilefrom doomdeck.application.uzdoom import write_uzdoom_configsfrom doomdeck.application.validation import InstallationValidator, format_validation_report, validation_has_failuresfrom doomdeck.application.wads import copy_addon_wads, copy_iwads, find_wads_in_installfrom doomdeck.domain.models import DoomDeckError, Dirs, GitHubAsset, SteamInfo, ValidationItemfrom doomdeck.domain.mods import BRUTAL_DOOM_MOD, PROJECT_BRUTALITY_MOD, ModSourcefrom doomdeck.domain.paths import all_managed_dirs, build_dirs, expand_pathfrom doomdeck.infrastructure.archives import ( safe_extract_tar, safe_extract_zip, write_tree_tar_gz,)from doomdeck.infrastructure.downloads import download_url as _download_urlfrom doomdeck.infrastructure.files import ( atomic_write_text, backup_path,)from doomdeck.infrastructure.github_api import ( request_github_json, validate_github_commit_payload, validate_github_release_payload, validate_github_repository_payload,)from doomdeck.infrastructure.moddb import ( BRUTAL_DOOM_MODDB_URL, safe_download_name, select_brutal_doom_download,)
DEFAULT_ROOT = Path.home() / "Games" / "Doom"SCRIPT_VERSION = "2026.05.31"
BRUTAL_DOOM_ALIAS = BRUTAL_DOOM_MOD.aliasPROJECT_BRUTALITY_REPO = "pa1nki113r/Project_Brutality"GITHUB_USER_AGENT = f"doomdeck/{SCRIPT_VERSION}"
def now_stamp() -> str: return _dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
def configure_logging(dirs: Dirs, verbose: bool, dry_run: bool) -> logging.Logger: logger = logging.getLogger("doom_deck_setup") logger.setLevel(logging.DEBUG) logger.handlers.clear()
console = logging.StreamHandler() console.setLevel(logging.DEBUG if verbose else logging.INFO) console.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger.addHandler(console)
if not dry_run: dirs.logs.mkdir(parents=True, exist_ok=True) logfile = dirs.logs / f"doom-deck-setup-{now_stamp()}.log" file_handler = logging.FileHandler(logfile, encoding="utf-8") file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) logger.addHandler(file_handler) logger.info("Logging to %s", logfile) else: logger.info("Dry run: no log file will be written") return logger
def configure_console_logging(verbose: bool) -> logging.Logger: logger = logging.getLogger("doom_deck_setup") logger.setLevel(logging.DEBUG) logger.handlers.clear()
console = logging.StreamHandler() console.setLevel(logging.DEBUG if verbose else logging.INFO) console.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger.addHandler(console) return logger
def ensure_dir(path: Path, dry_run: bool, logger: logging.Logger) -> None: if path.exists(): return logger.info("Create directory: %s", path) if not dry_run: path.mkdir(parents=True, exist_ok=True)
def run_command( cmd: list[str], logger: logging.Logger, dry_run: bool = False, check: bool = False, timeout: Optional[int] = None,) -> subprocess.CompletedProcess[str]: logger.debug("Run command: %s", " ".join(cmd)) if dry_run: return subprocess.CompletedProcess(cmd, 0, "", "") result = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) if check and result.returncode != 0: raise DoomDeckError( f"Command failed ({result.returncode}): {' '.join(cmd)}\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" ) return result
def print_plan(title: str, actions: Iterable[str]) -> None: print(f"\n{title}") print("=" * len(title)) for action in actions: print(f"- {action}") print()
def download_url( url: str, dest: Path, dry_run: bool, logger: logging.Logger, force: bool = False, headers: Optional[dict[str, str]] = None, allowed_hosts: Optional[set[str]] = None, expected_size: Optional[int] = None, expected_sha256: Optional[str] = None, expected_md5: Optional[str] = None,) -> Path: return _download_url( url, dest, dry_run, logger, force=force, headers=headers, allowed_hosts=allowed_hosts, expected_size=expected_size, expected_sha256=expected_sha256, expected_md5=expected_md5, user_agent=GITHUB_USER_AGENT, )
def install_windows_app_from_github( repo: str, target_dir: Path, expected_exe: str, dirs: Dirs, args: argparse.Namespace, logger: logging.Logger, explicit_url: Optional[str] = None,) -> Optional[Path]: exe_path = target_dir / expected_exe if args.skip_downloads and not explicit_url: if exe_path.exists(): logger.info("Skipping download; using existing Windows app %s", exe_path) return exe_path logger.warning("Skipping download and target does not exist: %s", exe_path) return None if explicit_url: asset_name = safe_download_name(explicit_url, f"{repo.split('/')[-1]}.zip") url = explicit_url expected_size = None expected_sha256 = None allowed_hosts = None else: asset = fetch_windows_release_asset(repo, logger, GITHUB_USER_AGENT) if not asset.sha256: raise DoomDeckError(f"GitHub release asset for {repo} did not include a SHA-256 digest: {asset.name}") asset_name = safe_download_name(asset.name, f"{repo.split('/')[-1]}.zip") url = asset.url expected_size = asset.size expected_sha256 = asset.sha256 allowed_hosts = {"github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"} downloaded = download_url( url, dirs.downloads / asset_name, args.dry_run, logger, force=args.force_download, allowed_hosts=allowed_hosts, expected_size=expected_size, expected_sha256=expected_sha256, ) if args.dry_run: return exe_path install_windows_app_archive(downloaded, target_dir, expected_exe, dirs.backups, logger) return exe_path
def install_windows_app_archive( archive_path: Path, target_dir: Path, expected_exe: str, backups_dir: Path, logger: logging.Logger,) -> None: temp_dir = target_dir.parent / f".{target_dir.name}.extract-{now_stamp()}" if temp_dir.exists(): shutil.rmtree(temp_dir) logger.info("Extract Windows app archive: %s -> %s", archive_path, temp_dir) try: safe_extract_zip(archive_path, temp_dir) exe_path = temp_dir / expected_exe if not exe_path.exists(): raise DoomDeckError(f"Windows archive did not contain expected executable {expected_exe}: {archive_path}") if target_dir.exists() or target_dir.is_symlink(): backup_path(target_dir, backups_dir, dry_run=False, logger=logger, label=target_dir.name) if target_dir.is_dir() and not target_dir.is_symlink(): shutil.rmtree(target_dir) else: target_dir.unlink() target_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(temp_dir), str(target_dir)) except Exception: if temp_dir.exists(): shutil.rmtree(temp_dir) raise
def select_project_brutality_download(logger: logging.Logger) -> GitHubAsset: try: release_payload = request_github_json( f"https://api.github.com/repos/{PROJECT_BRUTALITY_REPO}/releases/latest", GITHUB_USER_AGENT, ) except urllib.error.HTTPError as exc: if exc.code != 404: raise DoomDeckError(f"Could not read Project Brutality release metadata: {exc}") from exc release_payload = {} release = validate_github_release_payload(release_payload, PROJECT_BRUTALITY_REPO) tag_name = release.label assets = release.assets
def score(asset: Any) -> int: name = asset.name lower = name.lower() value = 0 if lower.endswith(".pk3"): value += 120 if lower.endswith(".zip"): value += 80 if lower.endswith(".wad"): value += 60 compact = lower.replace("_", "").replace("-", "") if "projectbrutality" in compact: value += 50 if re.search(r"\bpb\b", lower): value += 20 if any(token in lower for token in ["source", "src", "windows", ".exe", ".txt"]): value -= 50 return value
ranked = sorted(assets, key=score, reverse=True) if ranked and score(ranked[0]) > 0 and ranked[0].digest: chosen = ranked[0] logger.info("Selected Project Brutality release asset: %s", chosen.name) return GitHubAsset( name=chosen.name, url=chosen.browser_download_url, size=chosen.size, tag_name=tag_name, sha256=chosen.digest.removeprefix("sha256:") if chosen.digest else None, )
ref = release.label if release_payload else "" if not ref: repo_meta = validate_github_repository_payload( request_github_json( f"https://api.github.com/repos/{PROJECT_BRUTALITY_REPO}", GITHUB_USER_AGENT, ), PROJECT_BRUTALITY_REPO, ) ref = repo_meta.default_branch or "master" tag_name = ref commit = validate_github_commit_payload( request_github_json( f"https://api.github.com/repos/{PROJECT_BRUTALITY_REPO}/commits/{urllib.parse.quote(ref, safe='')}", GITHUB_USER_AGENT, ), PROJECT_BRUTALITY_REPO, ) zipball_url = f"https://api.github.com/repos/{PROJECT_BRUTALITY_REPO}/zipball/{commit.sha}" fallback_name = f"Project_Brutality-{safe_download_name(tag_name, 'latest')}-{commit.sha}.zip" logger.info("Using Project Brutality source zipball: %s", tag_name) return GitHubAsset(name=fallback_name, url=str(zipball_url), size=None, tag_name=tag_name)
def resolve_project_brutality(args: argparse.Namespace, dirs: Dirs, dry_run: bool, logger: logging.Logger) -> Optional[Path]: canonical = PROJECT_BRUTALITY_MOD.alias_path(dirs.project_brutality) metadata_path = PROJECT_BRUTALITY_MOD.metadata_path(dirs.project_brutality) if args.project_brutality_file: src = expand_path(args.project_brutality_file) if not src.exists(): raise DoomDeckError(f"--project-brutality-file does not exist: {src}") if src.suffix.lower() not in {".pk3", ".wad", ".zip"}: raise DoomDeckError(f"Project Brutality file should be a .pk3/.wad/.zip, got: {src}") return install_project_brutality_archive( src, canonical, dirs.backups, metadata_path, dry_run, logger, ModSource.local_file(src), force=True, )
if args.skip_project_brutality: if canonical.exists(): logger.info("Skipping Project Brutality download; using existing %s", canonical) return canonical logger.info("Skipping Project Brutality download") return None
if args.skip_downloads and not args.project_brutality_url: if canonical.exists(): logger.info("Skipping downloads; using existing Project Brutality file %s", canonical) return canonical logger.warning("Skipping downloads and Project Brutality is not installed: %s", canonical) return None
if args.project_brutality_url: url = args.project_brutality_url asset_name = safe_download_name(url, "Project_Brutality.zip") tag_name = "explicit-url" expected_size = None expected_sha256 = None allowed_hosts = None else: asset = select_project_brutality_download(logger) url = asset.url asset_name = safe_download_name(asset.name, "Project_Brutality.zip") tag_name = asset.tag_name expected_size = asset.size expected_sha256 = asset.sha256 allowed_hosts = { "api.github.com", "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", } downloaded = download_url( url, dirs.downloads / asset_name, dry_run, logger, force=args.force_download, allowed_hosts=allowed_hosts, expected_size=expected_size, expected_sha256=expected_sha256, ) return install_project_brutality_archive( downloaded, canonical, dirs.backups, metadata_path, dry_run, logger, ModSource.github(url=url, tag=tag_name), force=args.force_download, )
def resolve_brutal_doom(args: argparse.Namespace, dirs: Dirs, dry_run: bool, logger: logging.Logger) -> Optional[Path]: canonical = BRUTAL_DOOM_MOD.alias_path(dirs.brutal) metadata_path = BRUTAL_DOOM_MOD.metadata_path(dirs.brutal)
def alias_existing_candidate() -> Optional[Path]: candidates = sorted( [ p for p in dirs.brutal.glob("*") if p.is_file() and p.resolve() != canonical.resolve() and p.suffix.lower() in {".pk3", ".wad", ".zip"} ], key=lambda p: ("brutal" in p.name.lower(), p.stat().st_mtime), reverse=True, ) if not candidates: return None install_brutal_doom_archive( candidates[0], canonical, dirs.backups, metadata_path, dry_run, logger, ModSource.local_existing(candidates[0]), force=False, ) return canonical
if args.brutal_doom_file: src = expand_path(args.brutal_doom_file) if not src.exists(): raise DoomDeckError(f"--brutal-doom-file does not exist: {src}") if src.suffix.lower() not in {".pk3", ".wad", ".zip"}: raise DoomDeckError(f"Brutal Doom file should be a .pk3/.wad/.zip, got: {src}") return install_brutal_doom_archive( src, canonical, dirs.backups, metadata_path, dry_run, logger, ModSource.local_file(src), force=True, )
if args.skip_brutal_doom: if canonical.exists(): logger.info("Skipping Brutal Doom update; using existing %s", canonical) return canonical logger.info("Skipping Brutal Doom update") return None
if args.skip_downloads and not args.brutal_doom_url: if canonical.exists(): logger.info("Skipping downloads; using existing Brutal Doom file %s", canonical) return canonical candidate = alias_existing_candidate() if candidate: logger.info("Skipping downloads; aliased existing Brutal Doom file %s", candidate) return candidate logger.warning("Skipping downloads and Brutal Doom is not installed: %s", canonical) return None
if args.brutal_doom_url: url = args.brutal_doom_url asset_name = safe_download_name(url, "brutal-doom.zip") source = ModSource.explicit_url(url, asset_name) downloaded = download_url(url, dirs.downloads / asset_name, dry_run, logger, force=args.force_download) return install_brutal_doom_archive(downloaded, canonical, dirs.backups, metadata_path, dry_run, logger, source, force=args.force_download)
try: selected = select_brutal_doom_download(args.brutal_doom_channel, logger, user_agent=GITHUB_USER_AGENT) except DoomDeckError as exc: if canonical.exists(): logger.warning("Could not check Brutal Doom updates automatically; using existing alias: %s", exc) return canonical logger.warning("Could not install Brutal Doom automatically: %s", exc) return None
source = ModSource.moddb( channel=args.brutal_doom_channel, title=selected.title, page_url=selected.page_url, filename=selected.filename, updated=selected.updated, md5=selected.md5, ) compare_keys = ["source_type", "source_channel", "source_page_url", "source_filename", "source_updated", "source_md5"] already_current = ( canonical.exists() and metadata_matches(metadata_path, source.as_metadata(), compare_keys) and installed_payload_matches(canonical, metadata_path) ) if already_current and not args.force_download: logger.info("Brutal Doom already current from ModDB: %s", canonical) return canonical
download_dest = dirs.downloads / selected.filename downloaded = download_url( selected.download_url, download_dest, dry_run, logger, force=args.force_download or not already_current, headers={"Referer": selected.page_url}, allowed_hosts={"moddb.com"}, expected_md5=selected.md5 or None, ) return install_brutal_doom_archive( downloaded, canonical, dirs.backups, metadata_path, dry_run, logger, ModSource.moddb( channel=args.brutal_doom_channel, title=selected.title, page_url=selected.page_url, filename=selected.filename, updated=selected.updated, md5=selected.md5, download_url=selected.download_url, ), force=args.force_download or not already_current, )
def create_or_replace_symlink_or_copy(src: Path, dest: Path, dry_run: bool, logger: logging.Logger) -> None: if dest.exists() or dest.is_symlink(): try: if dest.is_symlink() and dest.resolve() == src.resolve(): logger.info("Symlink already current: %s -> %s", dest, src) return except FileNotFoundError: pass logger.info("Replace existing alias: %s", dest) if not dry_run: dest.unlink() logger.info("Create symlink: %s -> %s", dest, src) if dry_run: return try: dest.symlink_to(src) except OSError: shutil.copy2(src, dest)
def write_experimental_doomrunner_note(dirs: Dirs, args: argparse.Namespace, dry_run: bool, logger: logging.Logger) -> None: note: dict[str, object] = { "note": "Generated Doom Runner options.json copies are enabled by default.", "managed_options_json": str(doomrunner_options_paths(dirs)[0]), "managed_compatibility_options_json": str(doomrunner_options_paths(dirs)[1]), "backup_directory": str(dirs.backups), "generated_from": [ str(dirs.doomrunner_config / "preset-manifest.json"), str(dirs.docs / "DOOM_RUNNER_SETUP.md"), ], } if args.experimental_doomrunner_config: note["experimental_requested"] = True note["result"] = "This flag is kept for compatibility; generated options.json copies are now written by default." atomic_write_text(dirs.doomrunner_config / "options-json-policy.json", json.dumps(note, indent=2) + "\n", dry_run, logger)
def install(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) steamos_ok, steamos_msg = detect_steamos() steam = discover_steam(SteamDiscoverySettings(args.steam_root, args.steam_user_id), logger) plan = build_install_plan( dirs=dirs, steam=steam, appid=APPID_DOOM_PLUS_DOOM_II, steamos_msg=steamos_msg, skip_steam_shortcut=args.skip_steam_shortcut, ) print_plan("Planned install actions", plan.render_actions())
if not steamos_ok: logger.warning("Environment warning: %s", steamos_msg) for directory in all_managed_dirs(dirs): ensure_dir(directory, args.dry_run, logger)
install_windows_app_from_github( "Youda008/DoomRunner", dirs.doomrunner, "DoomRunner.exe", dirs, args, logger, explicit_url=args.doomrunner_asset_url, ) install_windows_app_from_github( "UZDoom/UZDoom", dirs.uzdoom, "uzdoom.exe", dirs, args, logger, explicit_url=args.uzdoom_asset_url, ) write_uzdoom_configs(dirs, args.dry_run, logger) write_managed_steam_input_profile(dirs, args.dry_run, logger)
if steam.app_install_dir: iwads, addon_wads = find_wads_in_install(steam.app_install_dir, logger) if iwads: copy_iwads(iwads, dirs, args.dry_run, logger) else: logger.warning("No expected IWADs found under %s", steam.app_install_dir) if addon_wads: copy_addon_wads(addon_wads, dirs, args.dry_run, logger) else: logger.warning("No add-on WADs found under %s", steam.app_install_dir) else: logger.warning("Steam app %s not found. Install DOOM + DOOM II in Steam, then rerun install.", APPID_DOOM_PLUS_DOOM_II)
brutal_path = resolve_brutal_doom(args, dirs, args.dry_run, logger) if brutal_path: logger.info("Brutal Doom alias: %s", brutal_path) else: logger.warning("Brutal Doom is not installed. Rerun without --skip-downloads/--skip-brutal-doom or use --brutal-doom-file.")
project_brutality_path = resolve_project_brutality(args, dirs, args.dry_run, logger) if project_brutality_path: logger.info("Project Brutality alias: %s", project_brutality_path) else: logger.warning("Project Brutality is not installed. Rerun without --skip-downloads/--skip-project-brutality or use --project-brutality-file.")
manifest = write_launchers_and_manifest(dirs, brutal_path, project_brutality_path, args.dry_run, logger) write_docs( dirs, manifest, brutal_path, project_brutality_path, args.dry_run, logger, appid=APPID_DOOM_PLUS_DOOM_II, brutal_doom_source_url=BRUTAL_DOOM_MODDB_URL, project_brutality_repo=PROJECT_BRUTALITY_REPO, ) write_experimental_doomrunner_note(dirs, args, args.dry_run, logger)
extra_doomrunner_options_paths: list[Path] = [] if not args.skip_steam_shortcut: if not steam.shortcuts_vdf: raise DoomDeckError("Could not locate Steam userdata/config/shortcuts.vdf path. Use --steam-root and/or --steam-user-id.") shortcut_settings = SteamShortcutSettings( dry_run=args.dry_run, allow_steam_running=args.allow_steam_running, shutdown_steam=args.shutdown_steam, proton_compat_tool=args.proton_compat_tool, ) doomrunner_appid = add_or_update_doomrunner_shortcut( steam.shortcuts_vdf, dirs, shortcut_settings, logger, ) proton_options = doomrunner_proton_options_path(steam, doomrunner_appid) if proton_options: extra_doomrunner_options_paths.append(proton_options) deploy_steam_input_profile(dirs, steam, doomrunner_appid, args.dry_run, logger) logger.info("Restart Steam before expecting the non-Steam shortcut to appear in Gaming Mode")
write_doomrunner_live_config( dirs, manifest, DoomRunnerLiveConfigSettings( dry_run=args.dry_run, skip=args.skip_doomrunner_live_config, extra_options_paths=tuple(extra_doomrunner_options_paths), ), logger, )
report = validate_internal(args, dirs, steam, logger, print_report=True) return 1 if validation_has_failures(report) else 0
def install_wads(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) print_plan( "Planned WAD install actions", [ f"Create/update WAD directories under {dirs.root}", "Download requested ModDB WAD archives into the PWAD map directory", ], ) for directory in [dirs.root, dirs.configs, dirs.doomrunner_config, dirs.downloads, dirs.pwads, dirs.backups, dirs.logs]: ensure_dir(directory, args.dry_run, logger)
moddb_wads = install_moddb_wad_urls( args.moddb_wad_urls, dirs.downloads, dirs.pwads, dirs.backups, args.dry_run, logger, force_download=args.force_download, user_agent=GITHUB_USER_AGENT, ) if moddb_wads: logger.info("Installed ModDB WAD payloads: %s", ", ".join(str(path) for path in moddb_wads)) else: logger.info("No ModDB WAD payloads installed") manifest = read_existing_preset_manifest(dirs) document = write_content_group_metadata(dirs, manifest, args.dry_run, logger) manifest_path = dirs.doomrunner_config / "preset-manifest.json" if manifest_path.exists(): atomic_write_text(manifest_path, json.dumps(manifest, indent=2) + "\n", args.dry_run, logger) refresh_existing_doomrunner_content_groups(dirs, document["content_groups"], args.dry_run, logger) return 0
def validate(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) steam = discover_steam(SteamDiscoverySettings(args.steam_root, args.steam_user_id), logger) report = validate_internal(args, dirs, steam, logger, print_report=True) return 1 if validation_has_failures(report) else 0
def print_validation_report(items: Iterable[ValidationItem]) -> None: sys.stdout.write(format_validation_report(items))
def validate_internal( args: argparse.Namespace, dirs: Dirs, steam: SteamInfo, logger: logging.Logger, print_report: bool = False,) -> list[ValidationItem]: def shell_syntax_checker(script: Path) -> bool: return run_command(["bash", "-n", str(script)], logger, dry_run=args.dry_run, timeout=10).returncode == 0
validator = InstallationValidator( steamos_detector=detect_steamos, shell_syntax_checker=shell_syntax_checker, steam_appid=APPID_DOOM_PLUS_DOOM_II, ) items = validator.validate(dirs, steam)
if print_report: print_validation_report(items) return items
def create_backup_archive(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) if not dirs.root.exists(): raise DoomDeckError(f"Nothing to back up; root does not exist: {dirs.root}") archive = dirs.backups / f"doom-deck-backup-{now_stamp()}.tar.gz" print_plan("Planned backup actions", [f"Create archive {archive}", "Exclude nested backup archives to avoid recursion"]) if args.dry_run: return 0 write_tree_tar_gz(archive, dirs.root, exclude_dirs=[dirs.backups]) logger.info("Backup archive written: %s", archive) return 0
def clean(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) if not dirs.root.exists(): logger.info("Nothing to clean; root does not exist: %s", dirs.root) return 0 external_backups = dirs.root.parent / f"{dirs.root.name}.backups" archive = external_backups / f"doom-deck-clean-backup-{now_stamp()}.tar.gz" if args.yes_delete: actions = [f"Create external backup archive {archive}", f"Permanently delete {dirs.root}"] else: moved = dirs.root.parent / f"{dirs.root.name}.removed-{now_stamp()}" actions = [f"Move {dirs.root} to {moved} instead of deleting it"] print_plan("Planned clean actions", actions) if args.dry_run: return 0 external_backups.mkdir(parents=True, exist_ok=True) if args.yes_delete: write_tree_tar_gz(archive, dirs.root, include_root=True) shutil.rmtree(dirs.root) logger.info("Deleted %s after writing backup archive %s", dirs.root, archive) else: moved = dirs.root.parent / f"{dirs.root.name}.removed-{now_stamp()}" shutil.move(str(dirs.root), str(moved)) logger.info("Moved %s -> %s", dirs.root, moved) return 0
def restore(args: argparse.Namespace) -> int: dirs = build_dirs(expand_path(args.root)) logger = configure_logging(dirs, args.verbose, args.dry_run) archive = expand_path(args.backup_archive) if not archive.exists(): raise DoomDeckError(f"Backup archive does not exist: {archive}") replaced = dirs.root.parent / f"{dirs.root.name}.pre-restore-{now_stamp()}" staging = dirs.root.parent / f".{dirs.root.name}.restore-{now_stamp()}" actions = [f"Extract and validate {archive} under staging path {staging}", f"Activate restored root at {dirs.root}"] if dirs.root.exists(): actions.insert(0, f"Move existing {dirs.root} to {replaced}") print_plan("Planned restore actions", actions) if args.dry_run: return 0 restore_backup_archive(archive, dirs.root, replaced, staging, logger) logger.info("Restore completed from %s", archive) return 0
def resolve_self_update_install_dir(explicit_install_dir: Optional[str]) -> Path: if explicit_install_dir: return expand_path(explicit_install_dir) env_install_dir = os.environ.get("DOOMDECK_INSTALL_DIR") if env_install_dir: return expand_path(env_install_dir) return infer_source_install_dir(Path(__file__))
def self_update_archive_allowed_hosts(archive_url: str, explicit_archive_url: Optional[str]) -> Optional[set[str]]: if explicit_archive_url: return None hostname = (urllib.parse.urlparse(archive_url).hostname or "").lower() return {hostname} if hostname else None
def smoke_test_self_update_source( source_dir: Path, logger: logging.Logger, python_executable: Path | None = None,) -> None: env = os.environ.copy() src_path = str(source_dir / "src") env["PYTHONPATH"] = f"{src_path}{os.pathsep}{env['PYTHONPATH']}" if env.get("PYTHONPATH") else src_path cmd = [str(python_executable or sys.executable), "-m", "doomdeck", "--help"] logger.debug("Run self-update smoke test: %s", " ".join(cmd)) result = subprocess.run( cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, env=env, ) if result.returncode != 0: raise DoomDeckError( "Downloaded DoomDeck source failed its import smoke test " f"({result.returncode}).\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" )
def self_update(args: argparse.Namespace) -> int: logger = configure_console_logging(args.verbose) install_dir = resolve_self_update_install_dir(args.install_dir) archive_url = build_self_update_archive_url(args.repo_url, args.ref, args.archive_url) if not args.archive_url: commit = validate_github_commit_payload( request_github_json( build_github_commit_api_url(args.repo_url, args.ref), GITHUB_USER_AGENT, ), args.repo_url, ) archive_url = build_self_update_archive_url(args.repo_url, commit.sha) plan = build_self_update_plan(install_dir, archive_url) validate_self_update_source_dir(install_dir)
if args.check: print_plan( "DoomDeck self-update check", [ f"Current version: {__version__}", f"Current source: {install_dir}", f"Update archive: {archive_url}", "Current source directory looks like a managed source-archive install", ], ) return 0
print_plan("Planned self-update actions", plan.render_actions()) if args.dry_run: return 0
with tempfile.TemporaryDirectory(prefix="doomdeck-self-update.") as temp_dir: temp_root = Path(temp_dir) archive_path = temp_root / "doomdeck.tar.gz" extract_dir = temp_root / "extract" staged_install_dir = temp_root / "source" extract_dir.mkdir(parents=True, exist_ok=True)
download_url( archive_url, archive_path, False, logger, force=True, allowed_hosts=self_update_archive_allowed_hosts(archive_url, args.archive_url), ) with tarfile.open(archive_path, "r:gz") as archive: safe_extract_tar(archive, extract_dir) source_dir = find_extracted_self_update_source_dir(extract_dir) shutil.copytree(source_dir, staged_install_dir) venv_python = prepare_self_update_runtime(staged_install_dir, Path(sys.executable), logger) smoke_test_self_update_source(staged_install_dir, logger, venv_python) replace_self_update_install_dir(staged_install_dir, install_dir)
logger.info("Updated DoomDeck source install at %s", install_dir) return 0
def add_common_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--root", default=str(DEFAULT_ROOT), help=f"Managed Doom root directory (default: {DEFAULT_ROOT})") parser.add_argument("--steam-root", help="Steam root override, e.g. ~/.local/share/Steam") parser.add_argument("--steam-user-id", help="Steam userdata numeric ID override") parser.add_argument("--dry-run", action="store_true", help="Print and log intended changes without writing files") parser.add_argument("--verbose", action="store_true", help="Enable debug logging to console")
def add_moddb_wad_url_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( dest="moddb_wad_urls", metavar="moddb_url", nargs="+", help="ModDB add-on/file page URL to download and extract .wad/.pk3 map payloads into pwads/", )
def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Set up Doom Runner, UZDoom, IWADs, launchers, and Steam integration on Steam Deck.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", required=True)
install_parser = subparsers.add_parser("install", help="Install/update the managed Doom setup") add_common_args(install_parser) install_parser.add_argument("--skip-downloads", action="store_true", help="Do not download Windows tools or managed mod archives; reuse files already in place") install_parser.add_argument("--force-download", action="store_true", help="Redownload release assets and managed mod archives even if present in downloads/") install_parser.add_argument("--doomrunner-asset-url", help="Explicit Doom Runner Windows ZIP URL override") install_parser.add_argument("--uzdoom-asset-url", help="Explicit UZDoom Windows ZIP URL override") install_parser.add_argument("--brutal-doom-url", help="Explicit Brutal Doom .pk3/.zip download URL override") install_parser.add_argument("--project-brutality-url", help="Explicit Project Brutality .pk3/.zip download URL override") install_parser.add_argument("--prefer-legacy-appimage", action="store_true", help="Deprecated compatibility flag; Windows ZIP assets are now used") install_parser.add_argument( "--proton-compat-tool", default="proton_10", help="Steam compatibility tool name to force for the generated Doom Runner shortcut", ) install_parser.add_argument( "--brutal-doom-channel", choices=["latest", "stable"], default="latest", help="Brutal Doom ModDB channel: latest can include beta/test builds; stable prefers non-beta candidates", ) install_parser.add_argument("--brutal-doom-file", help="Path to a manually downloaded Brutal Doom .pk3/.wad/.zip") install_parser.add_argument("--project-brutality-file", help="Path to a manually downloaded Project Brutality .pk3/.wad/.zip") install_parser.add_argument("--skip-brutal-doom", action="store_true", help="Do not download, update, or install Brutal Doom") install_parser.add_argument("--skip-project-brutality", action="store_true", help="Do not download or install Project Brutality") install_parser.add_argument("--skip-steam-shortcut", action="store_true", help="Do not modify Steam shortcuts.vdf or Proton compatibility mapping") install_parser.add_argument("--skip-doomrunner-live-config", action="store_true", help="Do not write Doom Runner generated options.json copies") install_parser.add_argument("--shutdown-steam", action="store_true", help="Attempt to shut down Steam before modifying shortcut and compatibility files") install_parser.add_argument("--allow-steam-running", action="store_true", help="Modify Steam shortcut and compatibility files even if Steam appears to be running") install_parser.add_argument( "--experimental-doomrunner-config", action="store_true", help="Deprecated compatibility flag; generated Doom Runner options.json copies are now written by default", ) install_parser.set_defaults(func=install)
install_wads_parser = subparsers.add_parser("install-wads", help="Install/update ModDB WAD archives only") add_common_args(install_wads_parser) install_wads_parser.add_argument("--force-download", action="store_true", help="Redownload WAD archives even if present in downloads/") add_moddb_wad_url_args(install_wads_parser) install_wads_parser.set_defaults(func=install_wads)
validate_parser = subparsers.add_parser("validate", help="Validate the setup") add_common_args(validate_parser) validate_parser.set_defaults(func=validate)
backup_parser = subparsers.add_parser("backup", help="Create a tar.gz backup of the managed Doom root") add_common_args(backup_parser) backup_parser.set_defaults(func=create_backup_archive)
clean_parser = subparsers.add_parser("clean", help="Safely clean the managed Doom root") add_common_args(clean_parser) clean_parser.add_argument("--yes-delete", action="store_true", help="Actually delete the managed root after creating an external backup archive") clean_parser.set_defaults(func=clean)
restore_parser = subparsers.add_parser("restore", help="Restore a backup archive") add_common_args(restore_parser) restore_parser.add_argument("backup_archive", help="Path to a backup tar.gz archive") restore_parser.set_defaults(func=restore)
self_update_parser = subparsers.add_parser("self-update", help="Update the DoomDeck command installed by install.sh") self_update_parser.add_argument("--check", action="store_true", help="Show the current source path and update archive without downloading") self_update_parser.add_argument("--dry-run", action="store_true", help="Print intended update actions without downloading or writing files") self_update_parser.add_argument("--verbose", action="store_true", help="Enable debug logging to console") self_update_parser.add_argument( "--repo-url", default=DEFAULT_SELF_UPDATE_REPO_URL, help="GitHub repository URL used to build the default source archive URL", ) self_update_parser.add_argument( "--ref", default=DEFAULT_SELF_UPDATE_REF, help="Branch name used to build the default source archive URL", ) self_update_parser.add_argument("--archive-url", help="Explicit DoomDeck source archive URL override") self_update_parser.add_argument("--install-dir", help="DoomDeck source install directory override") self_update_parser.set_defaults(func=self_update)
return parser
def main(argv: Optional[list[str]] = None) -> int: parser = build_arg_parser() args = parser.parse_args(argv) try: return int(args.func(args)) except KeyboardInterrupt: print("Interrupted", file=sys.stderr) return 130 except DoomDeckError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 except Exception as exc: # Defensive: still surface unexpected stack details for automation logs. print(f"UNEXPECTED ERROR: {exc}", file=sys.stderr) if getattr(args, "verbose", False): raise return 3
if __name__ == "__main__": raise SystemExit(main())