from __future__ import annotations import io import xml.etree.ElementTree as ET from collections.abc import Iterable from dataclasses import dataclass DLC = { "royalty": ("ludeon.rimworld.royalty", "Royalty"), "ideology": ("ludeon.rimworld.ideology", "Ideology"), "biotech": ("ludeon.rimworld.biotech", "Biotech"), "anomaly": ("ludeon.rimworld.anomaly", "Anomaly"), "odyssey": ("ludeon.rimworld.odyssey", "Odyssey"), } BASE_GAME = ("ludeon.rimworld", "Core") @dataclass(frozen=True) class ModListItem: package_id: str workshop_id: str name: str def core_items(selected_dlc: Iterable[str]) -> list[ModListItem]: result = [ModListItem(BASE_GAME[0], "0", BASE_GAME[1])] normalized = {value.strip().lower() for value in selected_dlc} for key, (package_id, name) in DLC.items(): if key in normalized: result.append(ModListItem(package_id, "0", name)) return result def workshop_items(rows: Iterable[dict]) -> list[ModListItem]: result: list[ModListItem] = [] for row in rows: if row.get("status") != "ready" or not row.get("package_id"): continue # The Workshop id is the correct RimWorld modSteamIds value. The old loader # sometimes substituted About.xml's steamAppId, which can be the game app id. result.append( ModListItem( package_id=str(row["package_id"]), workshop_id=str(row["workshop_id"]), name=str( row.get("display_name") or row.get("title") or row["package_id"] ), ) ) return result def deduplicate(items: Iterable[ModListItem]) -> list[ModListItem]: result: list[ModListItem] = [] seen_packages: set[str] = set() seen_workshop_ids: set[str] = set() for item in items: package_key = item.package_id.lower() if package_key in seen_packages: continue if item.workshop_id != "0" and item.workshop_id in seen_workshop_ids: continue seen_packages.add(package_key) if item.workshop_id != "0": seen_workshop_ids.add(item.workshop_id) result.append(item) return result def parse_active_mods(xml_bytes: bytes) -> list[ModListItem]: if len(xml_bytes) > 5 * 1024 * 1024: raise ValueError("ModsConfig.xml is larger than 5 MB") try: root = ET.fromstring(xml_bytes) except ET.ParseError as error: raise ValueError(f"ModsConfig.xml is invalid: {error}") from error active = root.find("activeMods") if active is None: raise ValueError("ModsConfig.xml has no activeMods section") items: list[ModListItem] = [] for entry in active.findall("li"): package_id = (entry.text or "").strip() if package_id: items.append(ModListItem(package_id, "0", package_id)) return items def _append_list(parent: ET.Element, name: str, values: Iterable[str]) -> None: element = ET.SubElement(parent, name) for value in values: ET.SubElement(element, "li").text = value def generate_rml(items: Iterable[ModListItem], game_version: str) -> bytes: normalized = deduplicate(items) root = ET.Element("savedModList") meta = ET.SubElement(root, "meta") ET.SubElement(meta, "gameVersion").text = game_version _append_list(meta, "modIds", (item.package_id for item in normalized)) _append_list(meta, "modSteamIds", (item.workshop_id for item in normalized)) _append_list(meta, "modNames", (item.name for item in normalized)) mod_list = ET.SubElement(root, "modList") _append_list(mod_list, "ids", (item.package_id for item in normalized)) _append_list(mod_list, "names", (item.name for item in normalized)) ET.indent(root, space="\t") output = io.BytesIO() ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True) return output.getvalue()