111 lines
3.9 KiB
Python
111 lines
3.9 KiB
Python
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()
|