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.
26 kB · 503 lines
Python
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504"""Installation validation rules and reporting helpers."""from __future__ import annotations
import dataclassesimport jsonfrom pathlib import Pathfrom typing import Any, Callable, Iterable, Optional, cast
from doomdeck.application.doomrunner import doomrunner_options_pathsfrom doomdeck.application.doomrunner_validation import inspect_doomrunner_optionsfrom doomdeck.application.control_mapping import build_default_control_schemefrom doomdeck.application.steam import doomrunner_proton_options_pathfrom doomdeck.application.steam_input import managed_steam_input_profile_pathfrom doomdeck.domain.deck import STEAM_DECK_HEIGHT, STEAM_DECK_WIDTHfrom doomdeck.domain.control_mapping import ControlActionfrom doomdeck.domain.models import Dirs, DoomDeckError, SteamInfo, ValidationItem, ValidationLevelfrom doomdeck.domain.mods import BRUTAL_DOOM_MOD, PROJECT_BRUTALITY_MODfrom doomdeck.domain.presets import PresetManifestfrom doomdeck.domain.wads import IWAD_CANONICAL_NAMESfrom doomdeck.infrastructure.archives import zip_contains_markersfrom doomdeck.infrastructure.binary_vdf import BKV_OBJECTfrom doomdeck.infrastructure.steam_input_vdf import render_steam_input_layoutfrom doomdeck.infrastructure.steam_compat import compat_mapping_key, load_text_vdffrom doomdeck.infrastructure.steam_shortcuts import get_bkv_str, load_shortcuts, shortcut_entries
SteamOSDetector = Callable[[], tuple[bool, str]]ShellSyntaxChecker = Callable[[Path], bool]
APPID_DOOM_PLUS_DOOM_II = "2280"BRUTAL_DOOM_ALIAS = BRUTAL_DOOM_MOD.aliasPROJECT_BRUTALITY_ALIAS = PROJECT_BRUTALITY_MOD.alias
def add_validation_item(items: list[ValidationItem], level: ValidationLevel | str, message: str) -> None: items.append(ValidationItem(ValidationLevel.from_value(level), message))
def validation_has_failures(items: Iterable[ValidationItem]) -> bool: return any(item.level == ValidationLevel.FAIL for item in items)
def _vdf_named_blocks(text: str, name: str) -> list[str]: marker = f'"{name}"' blocks: list[str] = [] search_start = 0 while (marker_start := text.find(marker, search_start)) >= 0: block_start = text.find("{", marker_start + len(marker)) if block_start < 0: break depth = 0 for index in range(block_start, len(text)): if text[index] == "{": depth += 1 elif text[index] == "}": depth -= 1 if depth == 0: blocks.append(text[marker_start : index + 1]) search_start = index + 1 break else: break return blocks
def _vdf_group_block(text: str, group_id: str) -> str | None: id_entry = f'"id"\t\t"{group_id}"' matches = [block for block in _vdf_named_blocks(text, "group") if id_entry in block] return matches[0] if len(matches) == 1 else None
def format_validation_report(items: Iterable[ValidationItem]) -> str: lines = ["", "Validation report", "================="] lines.extend(f"[{item.level.value}] {item.message}" for item in items) lines.append("") return "\n".join(lines)
def script_has_execve_shebang(path: Path) -> bool: try: with path.open("rb") as handle: return handle.read(2) == b"#!" except OSError: return False
@dataclasses.dataclass(frozen=True)class InstallationValidator: """Validate a managed DoomDeck installation without depending on the CLI."""
steamos_detector: SteamOSDetector shell_syntax_checker: ShellSyntaxChecker steam_appid: str = APPID_DOOM_PLUS_DOOM_II
def validate(self, dirs: Dirs, steam: SteamInfo) -> list[ValidationItem]: items: list[ValidationItem] = [] self._validate_environment(items, dirs) self._validate_tool_executables(items, dirs) self._validate_wads(items, dirs) self._validate_managed_mods(items, dirs) manifest = self._validate_preset_manifest(items, dirs) if manifest is not None: self._validate_preset_references(items, manifest, dirs) self._validate_uzdoom_configs(items, dirs) self._validate_steam_input_profile(items, dirs) self._validate_doomrunner_live_options(items, dirs) self._validate_shell_scripts(items, dirs) self._validate_steam(items, dirs, steam) self._validate_backups(items, dirs) return items
def _validate_environment(self, items: list[ValidationItem], dirs: Dirs) -> None: steamos_ok, steamos_msg = self.steamos_detector() add_validation_item(items, "PASS" if steamos_ok else "WARN", steamos_msg)
for path in [dirs.root, dirs.iwads, dirs.launchers, dirs.configs, dirs.docs]: add_validation_item(items, "PASS" if path.exists() else "FAIL", f"Required path exists: {path}")
def _validate_tool_executables(self, items: list[ValidationItem], dirs: Dirs) -> None: doomrunner_app = dirs.doomrunner / "DoomRunner.exe" uzdoom_app = dirs.uzdoom / "uzdoom.exe" for label, path in [ ("Windows Doom Runner executable", doomrunner_app), ("Windows UZDoom executable", uzdoom_app), ]: exists = path.exists() add_validation_item(items, "PASS" if exists else "FAIL", f"{label} exists: {path}")
def _validate_wads(self, items: list[ValidationItem], dirs: Dirs) -> None: copied_iwads = sorted( p.name for pattern in ["*.WAD", "*.wad"] for p in dirs.iwads.glob(pattern) if p.name.lower() in IWAD_CANONICAL_NAMES ) if copied_iwads: add_validation_item(items, "PASS", f"IWADs present: {', '.join(copied_iwads)}") else: add_validation_item(items, "FAIL", f"No IWADs found in {dirs.iwads}") for required in ["DOOM.WAD", "DOOM2.WAD"]: add_validation_item(items, "PASS" if (dirs.iwads / required).exists() else "WARN", f"Expected common IWAD: {dirs.iwads / required}")
addon_wads = sorted(p.name for pattern in ["*.WAD", "*.wad"] for p in dirs.pwads.glob(pattern)) if addon_wads: sample = ", ".join(addon_wads[:8]) suffix = "" if len(addon_wads) <= 8 else f", ... ({len(addon_wads)} total)" add_validation_item(items, "PASS", f"Add-on WADs present in {dirs.pwads}: {sample}{suffix}") else: add_validation_item(items, "WARN", f"No add-on WADs found in {dirs.pwads}")
def _validate_managed_mods(self, items: list[ValidationItem], dirs: Dirs) -> None: brutal_alias = dirs.brutal / BRUTAL_DOOM_ALIAS add_validation_item( items, "PASS" if brutal_alias.exists() else "WARN", f"Brutal Doom alias exists for Brutal presets: {brutal_alias}", ) if brutal_alias.exists(): brutal_metadata = dirs.brutal / "brutal-doom.json" add_validation_item( items, "PASS" if brutal_metadata.exists() else "WARN", f"Brutal Doom managed update metadata exists: {brutal_metadata}", ) project_brutality_alias = dirs.project_brutality / PROJECT_BRUTALITY_ALIAS add_validation_item( items, "PASS" if project_brutality_alias.exists() else "WARN", f"Project Brutality alias exists for Project Brutality preset: {project_brutality_alias}", ) if project_brutality_alias.exists(): project_brutality_metadata = dirs.project_brutality / "project-brutality.json" add_validation_item( items, "PASS" if project_brutality_metadata.exists() else "WARN", f"Project Brutality managed update metadata exists: {project_brutality_metadata}", ) markers_ok = zip_contains_markers(project_brutality_alias, {"zscript.zc", "gameinfo.txt"}) add_validation_item( items, "PASS" if markers_ok else "WARN", f"Project Brutality archive has expected UZDoom root files: {project_brutality_alias}", )
def _read_json_object_for_validation( self, items: list[ValidationItem], path: Path, label: str, ) -> Optional[dict[str, Any]]: if not path.is_file(): add_validation_item(items, "FAIL", f"{label} is not a file: {path}") return None try: parsed = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: add_validation_item(items, "FAIL", f"{label} is invalid: {path}: {exc}") return None except (OSError, UnicodeError) as exc: add_validation_item(items, "FAIL", f"{label} could not be read: {path}: {exc}") return None if not isinstance(parsed, dict): add_validation_item(items, "FAIL", f"{label} must be an object: {path}") return None add_validation_item(items, "PASS", f"{label} is valid: {path}") return parsed
def _validate_preset_manifest(self, items: list[ValidationItem], dirs: Dirs) -> Optional[PresetManifest]: manifest_path = dirs.doomrunner_config / "preset-manifest.json" if not manifest_path.exists(): add_validation_item(items, "FAIL", f"Preset manifest missing: {manifest_path}") return None parsed = self._read_json_object_for_validation(items, manifest_path, "Preset manifest JSON") if parsed is None: return None try: return PresetManifest.from_json_object(parsed) except DoomDeckError as exc: add_validation_item(items, "FAIL", f"Preset manifest structure is invalid: {manifest_path}: {exc}") return None
def _validate_preset_references(self, items: list[ValidationItem], manifest: PresetManifest, dirs: Dirs) -> None: manifest_path = dirs.doomrunner_config / "preset-manifest.json" self._validate_optional_mod_preset(items, manifest, manifest_path, "Brutal Doom", dirs.brutal / BRUTAL_DOOM_ALIAS) self._validate_optional_mod_preset( items, manifest, manifest_path, "Project Brutality", dirs.project_brutality / PROJECT_BRUTALITY_ALIAS, ) for preset in manifest.presets: for key, path in [ ("iwad", preset.iwad), ("config", preset.config), ("autoexec", preset.autoexec), ("launcher", preset.launcher), ]: add_validation_item(items, "PASS" if path.exists() else "FAIL", f"Preset {preset.name} references existing {key}: {path}") for p in preset.files: level = "PASS" if p.exists() else "FAIL" add_validation_item(items, level, f"Preset {preset.name} mod file reference: {p}")
def _validate_optional_mod_preset( self, items: list[ValidationItem], manifest: PresetManifest, manifest_path: Path, preset_name: str, mod_alias: Path, ) -> None: has_preset = any(preset.name == preset_name for preset in manifest.presets) if mod_alias.exists(): add_validation_item(items, "PASS" if has_preset else "FAIL", f"Preset manifest includes {preset_name} preset: {manifest_path}") elif has_preset: add_validation_item(items, "FAIL", f"Preset manifest includes {preset_name} preset but mod alias is missing: {mod_alias}") else: add_validation_item(items, "WARN", f"Optional {preset_name} preset omitted because mod alias is missing: {mod_alias}")
def _validate_uzdoom_configs(self, items: list[ValidationItem], dirs: Dirs) -> None: profiles = [ ("classic", "bind leftctrl +deck_menu_back", "bind space +deck_use_select"), ("modern", "bind leftctrl +deck_crouch_back", "bind space +jump"), ("brutal", "bind leftctrl +deck_crouch_back", "bind space +jump"), ("project-brutality", "bind leftctrl +deck_crouch_back", "bind space +jump"), ] for profile, back_binding, jump_or_use_binding in profiles: autoexec_path = dirs.uzdoom_config / profile / "autoexec.cfg" if autoexec_path.exists(): text = autoexec_path.read_text(encoding="utf-8").lower() controller_ok = all( needle in text for needle in [ "use_joystick true", "bind e +deck_use_select", 'bind capslock "toggle cl_run"', jump_or_use_binding, back_binding, "bind f6 quicksave", "bind f9 quickload", "bind mouse1 +attack", "bind mouse2 +altattack", "bind mwheelup weapnext", "bind mwheeldown weapprev", ] ) no_direct_gamepad_quickload = "pad_rthumb quickload" not in text and "pad_lthumb quicksave" not in text add_validation_item(items, "PASS" if controller_ok else "FAIL", f"{profile} UZDoom Steam Deck keyboard/mouse bindings: {autoexec_path}") add_validation_item(items, "PASS" if no_direct_gamepad_quickload else "FAIL", f"{profile} UZDoom avoids unsafe direct stick-click quick load: {autoexec_path}") else: add_validation_item(items, "FAIL", f"{profile} UZDoom autoexec missing: {autoexec_path}")
ini_path = dirs.uzdoom_config / profile / "uzdoom.ini" if ini_path.exists(): text = ini_path.read_text(encoding="utf-8").lower() display_ok = all( needle in text for needle in [ f"vid_defwidth={STEAM_DECK_WIDTH}", f"vid_defheight={STEAM_DECK_HEIGHT}", "vid_fullscreen=true", "use_joystick=true", ] ) add_validation_item(items, "PASS" if display_ok else "FAIL", f"{profile} UZDoom Steam Deck display settings: {ini_path}") else: add_validation_item(items, "FAIL", f"{profile} UZDoom ini missing: {ini_path}")
def _validate_steam_input_profile(self, items: list[ValidationItem], dirs: Dirs) -> None: path = managed_steam_input_profile_path(dirs) if not path.exists(): add_validation_item(items, "FAIL", f"DoomDeck Steam Input profile missing: {path}") return text = path.read_text(encoding="utf-8", errors="ignore") lower = text.lower() layout = build_default_control_scheme() serialized_bindings = [ binding.output.steam_input_binding() for binding in layout.bindings if binding.input_slot ] serialized_slots = [f'"{binding.input_slot}"' for binding in layout.bindings if binding.input_slot] core_bindings_ok = all( needle in text for needle in [ '"controller_type"\t\t"controller_neptune"', layout.name, *serialized_bindings, *serialized_slots, ] ) gyro_disabled = not layout.gyro_enabled and "gyro active" not in lower and '"gyro"' not in lower quickload_binding = next( (binding for binding in layout.bindings if binding.action == ControlAction.QUICK_LOAD), None, ) expected_profile = render_steam_input_layout(layout) expected_switches_group = _vdf_group_block(expected_profile, "6") installed_switches_group = _vdf_group_block(text, "6") quickload_safe = ( quickload_binding is not None and quickload_binding.activation == "Long_Press" and quickload_binding.long_press_ms is not None and text.count(f'"{quickload_binding.input_slot}"') == expected_profile.count(f'"{quickload_binding.input_slot}"') and expected_switches_group is not None and installed_switches_group == expected_switches_group ) add_validation_item(items, "PASS" if core_bindings_ok else "FAIL", f"DoomDeck Steam Input keyboard/mouse bindings: {path}") add_validation_item(items, "PASS" if gyro_disabled else "FAIL", f"DoomDeck Steam Input gyro is disabled: {path}") add_validation_item(items, "PASS" if quickload_safe else "FAIL", f"DoomDeck Steam Input quick load requires long press: {path}")
def _validate_doomrunner_live_options(self, items: list[ValidationItem], dirs: Dirs) -> None: live_options_path = doomrunner_options_paths(dirs)[0] self._validate_doomrunner_options_path(items, dirs, live_options_path, "Doom Runner generated", missing_level="FAIL")
def _validate_doomrunner_options_path( self, items: list[ValidationItem], dirs: Dirs, live_options_path: Path, label: str, *, missing_level: ValidationLevel | str, ) -> None: if live_options_path.exists(): live_options = self._read_json_object_for_validation(items, live_options_path, f"{label} options JSON") if live_options is None: return inspection = inspect_doomrunner_options(live_options) add_validation_item(items, "PASS" if inspection.engine_ok else "FAIL", f"{label} config has usable UZDoom engine: {live_options_path}") add_validation_item(items, "PASS" if inspection.iwad_ok else "FAIL", f"{label} config has IWAD entries: {live_options_path}") add_validation_item(items, "PASS" if inspection.launchable_preset_ok else "FAIL", f"{label} config has launchable presets: {live_options_path}") resolved_presets = inspection.resolved_preset_names add_validation_item( items, "PASS" if resolved_presets else "FAIL", f"{label} config resolves UZDoom launch paths for presets: {', '.join(resolved_presets) if resolved_presets else live_options_path}", ) add_validation_item(items, "PASS" if inspection.steam_deck_resolution_ok else "FAIL", f"{label} config uses Steam Deck resolution: {live_options_path}") else: add_validation_item(items, missing_level, f"{label} options missing: {live_options_path}")
def _validate_shell_scripts(self, items: list[ValidationItem], dirs: Dirs) -> None: shell_scripts = sorted(dirs.launchers.glob("*.sh")) seen_scripts: set[Path] = set() for script in shell_scripts: if script in seen_scripts: continue seen_scripts.add(script) if script.exists(): shebang_ok = script_has_execve_shebang(script) add_validation_item( items, "PASS" if shebang_ok else "FAIL", f"Shell script has execve-compatible shebang on first line: {script}", ) syntax_ok = self.shell_syntax_checker(script) add_validation_item(items, "PASS" if syntax_ok else "FAIL", f"Shell syntax valid for {script}")
def _validate_steam(self, items: list[ValidationItem], dirs: Dirs, steam: SteamInfo) -> None: doomrunner_exe = dirs.doomrunner / "DoomRunner.exe" if steam.steam_root: add_validation_item(items, "PASS", f"Steam root detected: {steam.steam_root}") else: add_validation_item(items, "WARN", "Steam root not detected") if steam.app_install_dir: add_validation_item(items, "PASS", f"Steam app {self.steam_appid} install detected: {steam.app_install_dir}") else: add_validation_item(items, "WARN", f"Steam app {self.steam_appid} install not detected")
if steam.shortcuts_vdf and steam.shortcuts_vdf.exists(): try: root = load_shortcuts(steam.shortcuts_vdf) shortcuts_obj = shortcut_entries(root) doomrunner_shortcuts: list[tuple[int | None, str]] = [] extra_preset_shortcuts: list[str] = [] for value in shortcuts_obj.values(): if value.type_code != BKV_OBJECT: continue appname = get_bkv_str(value.value, "appname", "AppName") if appname == "Doom Runner": exe = get_bkv_str(value.value, "exe", "Exe") appid_value = value.value.get("appid") appid = int(appid_value.value) if appid_value is not None else None doomrunner_shortcuts.append((appid, exe)) elif appname.startswith("DoomDeck - "): extra_preset_shortcuts.append(appname) expected_doomrunner_shortcuts = [ (appid, exe) for appid, exe in doomrunner_shortcuts if str(doomrunner_exe) in exe ] if len(doomrunner_shortcuts) == 1 and expected_doomrunner_shortcuts: add_validation_item(items, "PASS", "Exactly one Steam shortcut exists for Windows Doom Runner with expected executable path") elif not doomrunner_shortcuts: add_validation_item(items, "WARN", f"No Doom Runner shortcut found in {steam.shortcuts_vdf}") elif len(doomrunner_shortcuts) == 1: add_validation_item(items, "WARN", f"Steam shortcut named Doom Runner exists but exe differs: {doomrunner_shortcuts[0][1]}") else: add_validation_item(items, "FAIL", f"Multiple Doom Runner Steam shortcuts exist: {len(doomrunner_shortcuts)}") if extra_preset_shortcuts: add_validation_item( items, "FAIL", f"No extra DoomDeck preset Steam shortcuts remain: {', '.join(sorted(extra_preset_shortcuts))}", ) else: add_validation_item(items, "PASS", "No extra DoomDeck preset Steam shortcuts remain") doomdeck_appids = [appid for appid, _exe in expected_doomrunner_shortcuts if appid is not None] add_validation_item( items, "PASS" if doomdeck_appids else "WARN", "Steam shortcut appid is available for Doom Runner Proton mapping", ) self._validate_steam_compat_mapping(items, steam, doomdeck_appids) self._validate_doomrunner_proton_options(items, dirs, steam, doomdeck_appids) except DoomDeckError as exc: add_validation_item(items, "FAIL", f"Could not parse shortcuts.vdf: {exc}") else: add_validation_item(items, "WARN", "Steam shortcuts.vdf does not exist yet or Steam user was not detected")
def _validate_doomrunner_proton_options(self, items: list[ValidationItem], dirs: Dirs, steam: SteamInfo, appids: list[int]) -> None: for appid in appids: proton_options = doomrunner_proton_options_path(steam, appid) if proton_options is not None and proton_options.exists(): self._validate_doomrunner_options_path( items, dirs, proton_options, "Doom Runner Proton-prefix", missing_level="WARN", ) elif proton_options is not None: add_validation_item(items, "WARN", f"Doom Runner Proton-prefix options missing: {proton_options}")
def _validate_steam_compat_mapping(self, items: list[ValidationItem], steam: SteamInfo, appids: list[int]) -> None: if not appids: return localconfig = steam.localconfig_vdf or (steam.shortcuts_vdf.parent / "localconfig.vdf" if steam.shortcuts_vdf else None) if not localconfig or not localconfig.exists(): add_validation_item(items, "WARN", "Steam localconfig.vdf does not exist yet; Proton compatibility mapping cannot be confirmed") return root = load_text_vdf(localconfig) mapping: object = root for key in ["UserLocalConfigStore", "Software", "Valve", "Steam", "CompatToolMapping"]: if not isinstance(mapping, dict): mapping = {} break mapping = cast(dict[str, object], mapping).get(key, {}) if not isinstance(mapping, dict): add_validation_item(items, "WARN", f"Steam Proton compatibility mapping is missing or malformed: {localconfig}") return mapping_dict = cast(dict[str, object], mapping) mapped = [appid for appid in appids if isinstance(mapping_dict.get(compat_mapping_key(appid)), dict)] add_validation_item( items, "PASS" if mapped else "WARN", f"Steam Proton compatibility mapping exists for DoomDeck shortcuts: {localconfig}", )
def _validate_backups(self, items: list[ValidationItem], dirs: Dirs) -> None: if not any(dirs.backups.glob("*")): add_validation_item(items, "WARN", f"No backups found yet in {dirs.backups}; this is normal before the first replacement or Steam shortcut update") else: add_validation_item(items, "PASS", f"Backups directory contains backup files: {dirs.backups}")