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.
12 kB · 325 lines
Python
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326"""Build grouped views of installed DoomDeck content."""from __future__ import annotations
import dataclassesimport jsonimport refrom collections import defaultdictfrom pathlib import Pathfrom typing import Iterable, Mapping, cast
from doomdeck.domain.content import ContentGroup, ContentItemfrom doomdeck.domain.models import Dirsfrom doomdeck.domain.mods import BRUTAL_DOOM_MOD, PROJECT_BRUTALITY_MODfrom doomdeck.domain.wads import PWAD_DISPLAY_NAME_OVERRIDES_FILE, pwad_display_name
CONTENT_GROUP_SCHEMA = "doom-deck-setup/content-groups/v1"CONTENT_SUFFIXES = {".pk3", ".wad", ".zip"}
@dataclasses.dataclass(frozen=True)class GroupDefinition: id: str display_name: str sort_order: int
@dataclasses.dataclass(frozen=True)class GroupRule: group_id: str content_kinds: tuple[str, ...] terms: tuple[str, ...]
GROUP_DEFINITIONS: tuple[GroupDefinition, ...] = ( GroupDefinition("brutal-doom-forks", "Brutal Doom forks", 10), GroupDefinition("weapon-mods", "weapon mods", 20), GroupDefinition("total-conversions", "total conversions", 30), GroupDefinition("master-levels", "Master Levels", 40), GroupDefinition("map-packs", "map packs", 50), GroupDefinition("mutators", "mutators", 60), GroupDefinition("textures", "textures", 70), GroupDefinition("visors", "visors", 80), GroupDefinition("music", "music", 90), GroupDefinition("other", "Other", 10_000),)
GROUPS_BY_ID = {group.id: group for group in GROUP_DEFINITIONS}
# Ordered from most specific to broadest. The explicit metadata pass uses the# same rules before the path/name passes so future metadata fields can override# ambiguous filenames without changing rendering code.GROUP_RULES: tuple[GroupRule, ...] = ( GroupRule("brutal-doom-forks", ("mod", "preset"), ("brutal doom", "project brutality", "brutality")), GroupRule("weapon-mods", ("mod", "preset"), ("weapon", "weapons", "arsenal", "armory", "gun")), GroupRule("total-conversions", ("mod", "preset"), ("total conversion", "total-conversion", "tc", "aliens", "batman")), GroupRule("master-levels", ("map_pack",), ("master levels", "masterlevels", "master-levels", "master_levels")), GroupRule("map-packs", ("map_pack", "preset"), ("map pack", "mappack", "maps", "levels", "megawad", "episode", "dtwid", "sigil")), GroupRule("mutators", ("mod",), ("mutator", "mutators", "randomizer", "monsters only", "gameplay")), GroupRule("textures", ("mod",), ("texture", "textures", "brightmap", "brightmaps", "voxel", "voxels", "neural", "upscale")), GroupRule("visors", ("mod",), ("visor", "visors", "hud", "heads up", "crosshair")), GroupRule("music", ("mod",), ("music", "soundtrack", "midi", "ost")),)
def serialize_content_groups(groups: Iterable[ContentGroup]) -> list[dict[str, object]]: return [group.as_json_object() for group in groups]
def serialize_content_group_sections(sections: Mapping[str, Iterable[ContentGroup]]) -> dict[str, list[dict[str, object]]]: return {section: serialize_content_groups(groups) for section, groups in sections.items()}
def content_group_document(dirs: Dirs, sections: Mapping[str, Iterable[ContentGroup]]) -> dict[str, object]: return { "schema": CONTENT_GROUP_SCHEMA, "root": str(dirs.root), "content_groups": serialize_content_group_sections(sections), }
def build_content_group_document(dirs: Dirs, manifest: Mapping[str, object]) -> dict[str, object]: return content_group_document(dirs, build_installed_content_groups(dirs, manifest))
def build_content_groups(items: Iterable[ContentItem]) -> list[ContentGroup]: grouped: dict[str, list[ContentItem]] = defaultdict(list) for item in items: grouped[infer_group_id(item)].append(item)
groups: list[ContentGroup] = [] for group_id, items_for_group in grouped.items(): definition = GROUPS_BY_ID[group_id] groups.append( ContentGroup( id=definition.id, display_name=definition.display_name, sort_order=definition.sort_order, items=tuple(sorted(items_for_group, key=_item_sort_key)), ) ) return sorted(groups, key=lambda group: (group.sort_order, group.display_name.casefold(), group.id))
def infer_group_id(item: ContentItem) -> str: if (explicit_id := item.metadata.get("group_id")) in GROUPS_BY_ID: return str(explicit_id)
explicit_values = [ item.metadata.get("group", ""), item.metadata.get("category", ""), item.metadata.get("name", ""), item.metadata.get("source_title", ""), ] if group_id := _match_rules(item.kind, explicit_values): return group_id
path_values = _path_group_values(item.path) if group_id := _match_rules(item.kind, path_values): return group_id
name_values = [item.display_name, item.path.name if item.path else ""] return _match_rules(item.kind, name_values) or "other"
def build_installed_content_groups(dirs: Dirs, manifest: Mapping[str, object]) -> dict[str, list[ContentGroup]]: pwad_display_name_overrides = _pwad_display_name_overrides(dirs, manifest) return { "presets": build_content_groups(_preset_items(manifest)), "map_packs": build_content_groups( _file_items( dirs.pwads, "map_pack", dirs.root, display_name_overrides=pwad_display_name_overrides, pwads_dir=dirs.pwads, ) ), "mods": build_content_groups(_mod_items(dirs)), }
def content_groups_from_manifest(dirs: Dirs, manifest: Mapping[str, object]) -> dict[str, list[dict[str, object]]]: return serialize_content_group_sections(build_installed_content_groups(dirs, manifest))
def _preset_items(manifest: Mapping[str, object]) -> list[ContentItem]: presets = manifest.get("presets", []) if not isinstance(presets, list): return [] items: list[ContentItem] = [] for preset in presets: if not isinstance(preset, dict): continue preset_data = cast(Mapping[str, object], preset) name = str(preset_data.get("name", "")).strip() if not name: continue launcher_raw = preset_data.get("launcher") launcher = Path(str(launcher_raw)) if launcher_raw else None metadata = { "category": str(preset_data.get("category", "")), "engine": str(preset_data.get("engine", "")), } items.append( ContentItem( id=f"preset:{_slug_id(name)}", display_name=name, kind="preset", path=launcher, metadata={key: value for key, value in metadata.items() if value}, ) ) return items
def _file_items( directory: Path, kind: str, root: Path, *, display_name_overrides: Mapping[str, str] | None = None, pwads_dir: Path | None = None,) -> list[ContentItem]: if not directory.exists(): return [] items: list[ContentItem] = [] for path in sorted((p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in CONTENT_SUFFIXES), key=_path_sort_key): items.append( ContentItem( id=f"{kind}:{_stable_path_id(path, root)}", display_name=_display_name(path, kind, root, display_name_overrides or {}, pwads_dir), kind=kind, path=path, ) ) return items
def _mod_items(dirs: Dirs) -> list[ContentItem]: items = _file_items(dirs.mods, "mod", dirs.root) metadata_by_path = _managed_mod_metadata_by_path(dirs) enriched: list[ContentItem] = [] for item in items: metadata = dict(item.metadata) if item.path and item.path in metadata_by_path: metadata.update(metadata_by_path[item.path]) enriched.append( ContentItem( id=item.id, display_name=item.display_name, kind=item.kind, path=item.path, metadata=metadata, selectable=item.selectable, ) ) return enriched
def _managed_mod_metadata_by_path(dirs: Dirs) -> dict[Path, dict[str, str]]: metadata: dict[Path, dict[str, str]] = {} for mod, directory in [(BRUTAL_DOOM_MOD, dirs.brutal), (PROJECT_BRUTALITY_MOD, dirs.project_brutality)]: metadata_path = mod.metadata_path(directory) if not metadata_path.exists(): continue try: raw = json.loads(metadata_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue if not isinstance(raw, dict): continue installed = Path(str(raw.get("installed", mod.alias_path(directory)))) metadata[installed] = {str(key): str(value) for key, value in raw.items() if isinstance(value, str)} return metadata
def _pwad_display_name_overrides(dirs: Dirs, manifest: Mapping[str, object]) -> dict[str, str]: overrides: dict[str, str] = {} overrides.update(_display_name_mapping(manifest.get("pwad_display_names", {}))) overrides.update(_display_name_mapping(manifest.get("wad_display_names", {}))) overrides.update(_display_name_mapping(_read_json_object(dirs.doomrunner_config / PWAD_DISPLAY_NAME_OVERRIDES_FILE))) return overrides
def _read_json_object(path: Path) -> Mapping[str, object]: if not path.exists(): return {} try: raw = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return {} return raw if isinstance(raw, dict) else {}
def _display_name_mapping(value: object) -> dict[str, str]: if not isinstance(value, dict): return {} value_mapping = cast(Mapping[str, object], value) source: object = value_mapping.get("display_names") if source is None: source = value_mapping if not isinstance(source, dict): return {} return {str(key): str(display_name).strip() for key, display_name in source.items() if str(display_name).strip()}
def _match_rules(kind: str, values: Iterable[str]) -> str: texts = [value for value in values if value] for rule in GROUP_RULES: if kind not in rule.content_kinds: continue if any(_contains_term(text, term) for text in texts for term in rule.terms): return rule.group_id return ""
def _path_group_values(path: Path | None) -> list[str]: if path is None: return [] return [str(part) for part in path.parts[:-1]]
def _contains_term(text: str, term: str) -> bool: normalized_text = _normalize_text(text) normalized_term = _normalize_text(term) compact_text = normalized_text.replace(" ", "") compact_term = normalized_term.replace(" ", "") return normalized_term in normalized_text or bool(compact_term and compact_term in compact_text)
def _normalize_text(value: str) -> str: return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", value.casefold())).strip()
def _slug_id(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") return slug or "item"
def _stable_path_id(path: Path, root: Path) -> str: try: relative = path.relative_to(root) except ValueError: relative = path return _slug_id(relative.as_posix())
def _display_name( path: Path, kind: str, root: Path, display_name_overrides: Mapping[str, str], pwads_dir: Path | None,) -> str: if kind == "map_pack": return pwad_display_name(path, overrides=display_name_overrides, root=root, pwads_dir=pwads_dir) return path.stem.strip() or path.name
def _item_sort_key(item: ContentItem) -> tuple[str, str, str]: return (item.display_name.casefold(), str(item.path or "").casefold(), item.id)
def _path_sort_key(path: Path) -> str: return path.as_posix().casefold()