""" mlos_sort.py Python port of MLOS_sorting.lua (Mod Load Order Sorter, by REfRigERatoR). Faithful to the Lua algorithm: - preorder: ModManager, ModManagerServer, modoptions - category buckets: coreRequirement -> tweaks -> resource -> map -> vehicle -> code -> clothes -> ui -> other -> translation -> undefined - loadFirst / loadLast: on (0) | category (1) | off (2) - topological sort by `require` + `loadAfter` with cycle detection - sorting_rules.txt overrides supported (loadAfter/loadBefore/incompatibleMods/ loadFirst/loadLast/category) Limitations vs in-game Lua: - mod.info-only input. We do NOT walk /media/* folders for category detection. We rely on mod.info `category=` if present, then `frameworkKeys` name heuristic, then default "other" (or "undefined" if uncategorizable). - `loadBefore` is converted into corresponding `loadAfter` edges on other mods, matching the Lua mod's behavior. """ import argparse import json import os import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Tuple # ----------------------------------------------------------------------------- # Constants (mirrors MLOS_sorting.lua) # ----------------------------------------------------------------------------- PREORDER: Dict[str, int] = { # B41 in-game MLOS sorter (wsid 3180893708) + server companion + vanilla # mod options panel — slot at the very front by historical MLOS convention. "ModManager": 1, "ModManagerServer": 2, "modoptions": 3, # Project-specific forced order: tsarslib is a foundational lib that many # vehicle/map mods require, so it must precede them. AquatsarYachtClub + # AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose # interior overlays only render correctly in this exact order, and # RVInteriorExpansion + Part2 (wsids 3618427553 / 3622163276) chain off # PROJECTRVInterior42 — they don't declare loadAfter in their own mod.info # and their category="undefined" drifts them to the end of MODS_LINE under # category sort, so PREORDER pins them adjacent. Slots 4-9 land the cluster # immediately after the management tools, before any category-sorted content. "tsarslib": 4, "AquatsarYachtClubB42": 5, "AquatsarRVAddon": 6, "PROJECTRVInterior42": 7, "RVInteriorExpansion": 8, "RVInteriorExpansionPart2": 9, # damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib # consumed by many B42 mods. Slots after the Aquatsar/RV cluster. "damnlib": 10, } RAW_CATEGORY_ORDER: List[str] = [ "coreRequirement", "tweaks", # libraries / frameworks / APIs (matches existing 'lib' pill) "tile", # tile asset packs "debug", # error logger, cheat menus "resource", # other generic resources "map", "qol", # QOL changes "moodle", # moodles / moodlets "tweak_minor", # tiny tweaks (working aircon, ear protection, …) "music", # music + music addons (load before vehicles per user spec) "wearable", # clothing, hair, tattoos (NOT armor) "profession", # profession mods "movement", # drop-and-roll, crawl, jump, ladders "building", # building menus, barricades, light switches "farming", "zombie", # zombie behaviour mods (OccultZed, HordeNight, ReactiveZombies) "zone", # hazardous zones, spore zones "armor", # armor mods (separate from wearables) "food", "health", # first aid, medical "weapon", # weapons (load before vehicles per user spec) "crafting", "container", # backpacks, boxes, tubs "vehicle", "vehicle_spawn", # vehicle spawn zones "loot", # loot tables "code", # generic gameplay code (legacy fallback) "ui", # interface "sound", # audio (non-music) "texture", "translation", "multiplayer", # MP-specific utilities "server_only", # admin tools, server logs "fix", # bug-fix overlays (distinct from 'patch' loadLast tier) "other", "undefined", # Spec G-patch: "patch" is a category like any other, but `_initial_sort_key` # routes patches above all sub-axes via a leading is_patch tuple element so # they sort strictly LAST (after every loadLast=on map mod). "patch", ] CATEGORY_ORDER: Dict[str, int] = {c: i for i, c in enumerate(RAW_CATEGORY_ORDER)} LOAD_CATEGORIES: Dict[str, int] = {"on": 0, "category": 1, "off": 2} # from MLOS_sorting.lua: frameworkKeys for name-based tweak detection. # Lua uses string.find on lowercased name (substring match, no regex anchors). FRAMEWORK_KEYS: List[str] = [ "framework", " api", "_api", "tweak", "interface", "utilit", # matches utility, utilities "bugfix", "librar", # matches library, libraries — covers damnlib/tsarslib/StarlitLibrary/etc. # `derive_category` checks mod.maps before FRAMEWORK_KEYS, so a map # mod whose name contains "library" still classifies as `map` first. ] # Multi-key list fields in mod.info (lowercased keys) LIST_KEYS_MAP = { "require": "requirements", "loadafter": "loadAfter", "loadbefore": "loadBefore", "incompatiblemods": "incompatibleMods", "tags": "tags", } # ----------------------------------------------------------------------------- # Dataclasses # ----------------------------------------------------------------------------- @dataclass class ModInfo: id: str name: str = "" workshop_id: Optional[str] = None category: str = "undefined" requirements: List[str] = field(default_factory=list) loadAfter: List[str] = field(default_factory=list) loadBefore: List[str] = field(default_factory=list) incompatibleMods: List[str] = field(default_factory=list) loadFirst: str = "off" loadLast: str = "off" tags: List[str] = field(default_factory=list) maps: List[str] = field(default_factory=list) # map folder names from media/maps/ flags: List[str] = field(default_factory=list) is_addon: bool = False # Spec A addon: default-off in multi-mod wsids # Steam Workshop's controlled-vocab tags (workshop_meta.tags). Canonical # signal for build / multiplayer / category detection. Distinct from # `tags` which is mod.info-side (freeform). workshop_tags: List[str] = field(default_factory=list) # pzmm-style content fingerprint (Maps, Vehicles, Weapons, Traits, …) # populated by worker.build_manifest_and_types at parse time. Empty when # files_manifest_built=false (older cached rows); derive_category falls # through to the existing cascade in that case. mod_types: List[str] = field(default_factory=list) warnings: Dict[str, List[str]] = field(default_factory=dict) @dataclass class SortingRule: loadAfter: List[str] = field(default_factory=list) loadBefore: List[str] = field(default_factory=list) incompatibleMods: List[str] = field(default_factory=list) loadFirst: str = "off" loadLast: str = "off" category: Optional[str] = None # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- def _split_csv(value: str) -> List[str]: """ Mirrors Refr_Utils:splitStringBySeparator - split on commas, trim, drop empties. Defensively strips `word=` prefixes that some malformed mod.info lines include (the Lua does this too). Also strips a leading backslash on each entry: B42 mod.info files write deps as `require=\\StarlitLibrary` (path-style); we want plain modIds so dep names match for the topo sort and missing-dep warnings. Finally strips a leading "/" wsid-path prefix some authors put in front of the modId (e.g. require=2256623447/firearmmod). """ if value is None: return [] cleaned = re.sub(r"\w+\s*=", "", value) out: List[str] = [] for p in cleaned.split(","): s = p.strip().lstrip("\\") s = re.sub(r"^\d+/", "", s) if s: out.append(s) return out def _convert_load_category(value) -> str: """Mirrors convertToLoadCategoryString: normalize to 'on' | 'category' | 'off'.""" if value in (True, "true", 0, "0"): return "on" if value in (None, False, "false", 2, "2", ""): return "off" if value not in LOAD_CATEGORIES: return "off" return str(value) def _str_contains_any(haystack: str, needles: List[str]) -> bool: if not haystack: return False h = haystack.lower() return any(n and n in h for n in needles) # ----------------------------------------------------------------------------- # Parsers # ----------------------------------------------------------------------------- def parse_mod_info(text: str, workshop_id: Optional[str] = None) -> Optional[ModInfo]: """ Parse a mod.info file body. Returns None if no `id=` line found. Lines are `key=value`; keys lowercased; list-fields comma-separated. """ fields: Dict[str, object] = {} for raw in text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue m = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line) if not m: continue key = m.group(1).strip().lower() value = m.group(2).strip() if key in LIST_KEYS_MAP: fields[LIST_KEYS_MAP[key]] = _split_csv(value) elif key == "loadfirst": fields["loadFirst"] = _convert_load_category(value) elif key == "loadlast": fields["loadLast"] = _convert_load_category(value) elif key == "category": fields["category"] = value if value in CATEGORY_ORDER else "undefined" elif key == "name": fields["name"] = value elif key == "id": # Some authors prefix the wsid into the id (e.g. id=2256623447/firearmmod). # Strip a leading "/" so the canonical mod_id is the clean form. fields["id"] = re.sub(r"^\d+/", "", value) if "id" not in fields: return None return ModInfo( id=fields["id"], name=fields.get("name", ""), workshop_id=workshop_id, category=fields.get("category", "undefined"), requirements=fields.get("requirements", []), loadAfter=fields.get("loadAfter", []), loadBefore=fields.get("loadBefore", []), incompatibleMods=fields.get("incompatibleMods", []), loadFirst=fields.get("loadFirst", "off"), loadLast=fields.get("loadLast", "off"), tags=fields.get("tags", []), ) def parse_sorting_rules(text: str) -> Dict[str, SortingRule]: """ Parse a sorting_rules.txt file. Format: [modId] loadAfter=mod1,mod2 loadBefore=mod3 incompatibleMods=mod4 loadFirst=on loadLast=off category=tweaks """ rules: Dict[str, SortingRule] = {} current: Optional[str] = None for raw in text.splitlines(): line = raw.strip() if not line: continue m = re.match(r"^\s*\[\s*(.+?)\s*\]\s*$", line) if m: current = m.group(1) rules.setdefault(current, SortingRule()) continue if current is None: continue kv = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line) if not kv: continue key, value = kv.group(1).lower(), kv.group(2) rule = rules[current] if key in ("loadafter", "loadmodafter"): rule.loadAfter = list(dict.fromkeys(rule.loadAfter + _split_csv(value))) elif key in ("loadbefore", "loadmodbefore"): rule.loadBefore = list(dict.fromkeys(rule.loadBefore + _split_csv(value))) elif key in ("incompatiblemods", "incompatible"): rule.incompatibleMods = list(dict.fromkeys(rule.incompatibleMods + _split_csv(value))) elif key == "loadfirst": rule.loadFirst = _convert_load_category(value) elif key == "loadlast": rule.loadLast = _convert_load_category(value) elif key == "category": rule.category = value if value in CATEGORY_ORDER else None return rules # ----------------------------------------------------------------------------- # Filesystem ingestion (DepotDownloader output layout) # ----------------------------------------------------------------------------- def load_mods_from_dir(root: Path) -> List[ModInfo]: """ Walk `//mods//mod.info` (DepotDownloader output). Also: `//mods//media/maps//map.info` populates `maps` for that mod. """ mods: List[ModInfo] = [] if not root.exists(): raise FileNotFoundError(f"Mods root does not exist: {root}") for workshop_dir in sorted(root.iterdir()): if not workshop_dir.is_dir(): continue workshop_id = workshop_dir.name if workshop_dir.name.isdigit() else None mods_root = workshop_dir / "mods" if not mods_root.exists(): # also support: some layouts put mods/ at root level directly mods_root = workshop_dir if not mods_root.exists() or not mods_root.is_dir(): continue for mod_dir in sorted(mods_root.iterdir()): if not mod_dir.is_dir(): continue mod_info_path = mod_dir / "mod.info" if not mod_info_path.exists(): continue try: text = mod_info_path.read_text(encoding="utf-8", errors="replace") except OSError as e: print(f"WARN: cannot read {mod_info_path}: {e}", file=sys.stderr) continue mod = parse_mod_info(text, workshop_id=workshop_id) if mod is None: print(f"WARN: no `id=` in {mod_info_path}, skipping", file=sys.stderr) continue # Collect map folders maps_dir = mod_dir / "media" / "maps" if maps_dir.exists() and maps_dir.is_dir(): for map_folder in sorted(maps_dir.iterdir()): if map_folder.is_dir() and (map_folder / "map.info").exists(): mod.maps.append(map_folder.name) mods.append(mod) return mods # ----------------------------------------------------------------------------- # Category derivation (degraded vs in-game; no folder walk) # ----------------------------------------------------------------------------- _PATCH_NAME_RE = re.compile(r"\b(patch|compat|compatibility)\b", re.IGNORECASE) # Substring lists used for derive_category name heuristics. Plain substring # matching (vs. \b regex) survives PZ's mishmash of camelCase + underscore # + version-suffix mod names (TrueActions_1.09, TrueMusic, TMMumble, …) # that strict word boundaries fail on. False positives are accepted in # exchange — names containing "music" without being music-related are rare # in PZ. _LIB_NAME_HINTS = [ "library", "libraries", "framework", ] _LIB_NAME_RE = re.compile( r'(? bool: if not name: return False n = name.lower() return any(h in n for h in hints) # pzmm content-type → sortof CATEGORY_ORDER mapping. "skip" entries fall # through to the existing derive_category cascade. Items/Animations/Lua/Unknown # are too generic; Maps/Sounds/Patch/Vehicles/Clothing duplicate signals already # captured by the cascade but stay here as fallbacks for poorly-tagged mods. _TYPE_TO_CAT: Dict[str, str] = { "Maps": "map", "Vehicles": "vehicle", "Weapons": "weapon", "Clothing": "wearable", "Traits": "code", "Professions": "profession", "Recipes": "crafting", "Tiles": "tile", "Textures": "texture", "Sounds": "sound", "UI": "ui", "Translations": "translation", "Patch": "patch", "Dependency": "tweaks", "Framework": "tweaks", } def _types_to_category(mod_types: List[str], name: str) -> Optional[str]: """First mod_type that maps to a sortof CATEGORY_ORDER bucket wins. Returns None if mod_types contains only skip-types (Items/Animations/Lua/ Unknown), so the caller can fall through to the existing cascade.""" for t in mod_types: cat = _TYPE_TO_CAT.get(t) if cat: # vehicle_spawn refinement matches the downstream ws_tag check. if cat == "vehicle" and name and "spawn zone" in name.lower(): return "vehicle_spawn" return cat return None def derive_category(mod: ModInfo) -> str: """Best-effort category from mod.info + workshop_meta.tags + name. Mirrors api/mlos_sort.py; keep both copies in sync. """ if mod.category in CATEGORY_ORDER and mod.category != "undefined": return mod.category name = mod.name or "" # pzmm-style content fingerprint takes precedence over name regex when # available. Empty mod_types means files_manifest_built=false (older # cached row); fall through to existing cascade. if mod.mod_types: cat = _types_to_category(mod.mod_types, name) if cat: return cat if name and _PATCH_NAME_RE.search(name): return "patch" if _name_has(name, _LIB_NAME_HINTS) or (name and _LIB_NAME_RE.search(name)): return "tweaks" if mod.maps: return "map" if _name_has(name, _MUSIC_NAME_HINTS): return "music" if _name_has(name, _MOVEMENT_HINTS): return "movement" if _name_has(name, _MOODLE_NAME_HINTS): return "moodle" if _name_has(name, _DEBUG_NAME_HINTS): return "debug" if _name_has(name, _TILE_NAME_HINTS): return "tile" ws_tags = set(mod.workshop_tags or []) has_audio = "Audio" in ws_tags if "Weapons" in ws_tags: return "weapon" if "Vehicles" in ws_tags: if name and "spawn zone" in name.lower(): return "vehicle_spawn" return "vehicle" if "Clothing/Armor" in ws_tags: if _name_has(name, _ARMOR_NAME_HINTS): return "armor" return "wearable" if "Food" in ws_tags: return "food" if "building" in {t.lower() for t in ws_tags}: return "building" if "Farming" in ws_tags: return "farming" if "Skills" in ws_tags: if _name_has(name, _PROFESSION_HINTS): return "profession" return "code" if "Interface" in ws_tags: return "ui" if "Textures" in ws_tags: return "texture" if "Language/Translation" in ws_tags: return "translation" if "QOL" in ws_tags: return "qol" if "Map" in ws_tags: return "map" if _name_has(name, _TILE_NAME_HINTS): return "tile" if _name_has(name, _ZOMBIE_NAME_HINTS): return "zombie" if _name_has(name, _HEALTH_NAME_HINTS): return "health" if _name_has(name, _CRAFTING_HINTS): return "crafting" if _name_has(name, _CONTAINER_HINTS): return "container" if _name_has(name, _LOOT_NAME_HINTS): return "loot" if _name_has(name, _FIX_NAME_HINTS): return "fix" if _name_has(name, _ZONE_NAME_HINTS): return "zone" if has_audio: return "sound" tags_lc = [t.lower() for t in mod.tags] if any("translation" in t for t in tags_lc): return "translation" if any("vehicle" in t for t in tags_lc): return "vehicle" if any("interface" in t or "ui" in t for t in tags_lc): return "ui" if any("clothing" in t or "skin" in t for t in tags_lc): return "wearable" if any("armor" in t for t in tags_lc): return "armor" if any("map" in t for t in tags_lc): return "map" if _str_contains_any(name, FRAMEWORK_KEYS): return "tweaks" if "Multiplayer" in ws_tags: return "multiplayer" return "other" # ----------------------------------------------------------------------------- # Sort # ----------------------------------------------------------------------------- def _apply_overrides(mods: List[ModInfo], rules: Dict[str, SortingRule]) -> None: """In-place: merge rules into mods, then propagate loadBefore -> reverse loadAfter.""" by_id = {m.id: m for m in mods} for mod in mods: rule = rules.get(mod.id) if rule: mod.loadAfter = list(dict.fromkeys(mod.loadAfter + rule.loadAfter)) mod.loadBefore = list(dict.fromkeys(mod.loadBefore + rule.loadBefore)) mod.incompatibleMods = list(dict.fromkeys(mod.incompatibleMods + rule.incompatibleMods)) mod.loadFirst = _convert_load_category(rule.loadFirst if rule.loadFirst != "off" else mod.loadFirst) mod.loadLast = _convert_load_category(rule.loadLast if rule.loadLast != "off" else mod.loadLast) if rule.category: mod.category = rule.category # Derive category if still undefined if mod.category not in CATEGORY_ORDER or mod.category == "undefined": mod.category = derive_category(mod) # Translate loadBefore into reverse loadAfter on the target mod # (mirrors updateSortingRulesLoadAfter) for mod in mods: for target in mod.loadBefore: target_mod = by_id.get(target) if target_mod and mod.id not in target_mod.loadAfter: target_mod.loadAfter.append(mod.id) def _initial_sort_key(mod: ModInfo): """Mirrors initialSortMods comparator. Returns sortable tuple. Spec G-patch: index 0 is `is_patch` so patches sort strictly last - they have to override loadLast=on map mods at runtime. Within the patch tier the existing sub-axes still apply (PREORDER, alpha, etc.). """ is_patch = 1 if mod.category == "patch" else 0 pre = PREORDER.get(mod.id, 10000) return ( is_patch, pre, LOAD_CATEGORIES[mod.loadFirst], # global loadFirst (on first) -LOAD_CATEGORIES[mod.loadLast] + 100, # global loadLast (on last) -- keep parity by # sorting "on" last; we invert so smaller=earlier CATEGORY_ORDER.get(mod.category, CATEGORY_ORDER["undefined"]), LOAD_CATEGORIES[mod.loadFirst], # in-category loadFirst -LOAD_CATEGORIES[mod.loadLast] + 100, # in-category loadLast mod.id.lower(), ) def _topological_sort(mods: List[ModInfo]) -> Tuple[List[str], List[List[str]]]: """DFS topo sort on (requirements + loadAfter). Returns (order, cycles).""" by_id = {m.id: m for m in mods} visited: Dict[str, bool] = {} visiting: Dict[str, bool] = {} order: List[str] = [] cycles: List[List[str]] = [] def visit(mod: ModInfo, path: List[str]): if visiting.get(mod.id): cycles.append(path + [mod.id]) return if visited.get(mod.id): return visiting[mod.id] = True for dep in mod.requirements: target = by_id.get(dep) if target: visit(target, path + [mod.id]) for dep in mod.loadAfter: target = by_id.get(dep) if target: visit(target, path + [mod.id]) visiting[mod.id] = False visited[mod.id] = True order.append(mod.id) for mod in mods: visit(mod, []) return order, cycles def sort_mods( mods: List[ModInfo], rules: Optional[Dict[str, SortingRule]] = None, ) -> Dict[str, object]: """ Top-level entry: returns dict with ordered IDs + warnings. """ rules = rules or {} _apply_overrides(mods, rules) # Initial deterministic sort (preorder, loadFirst, category, loadLast, alpha) mods.sort(key=_initial_sort_key) order, cycles = _topological_sort(mods) by_id = {m.id: m for m in mods} enabled = set(by_id.keys()) missing: Dict[str, List[str]] = {} incompat: Dict[str, List[str]] = {} for mod in mods: miss = [r for r in mod.requirements if r not in enabled] if miss: missing[mod.id] = miss inc = [r for r in mod.incompatibleMods if r in enabled] if inc: incompat[mod.id] = inc # Output blocks for the server's .ini file mods_line = order # already mod IDs in load order workshop_seen: List[str] = [] workshop_set = set() for mod_id in order: wid = by_id[mod_id].workshop_id if wid and wid not in workshop_set: workshop_seen.append(wid) workshop_set.add(wid) # MAP_LINE convention: dependencies first (leftmost), dependents next. # Vanilla Muldraugh, KY is ALWAYS appended at the very end by # adapters.build_response. `order` is already topo-sorted by mod-level # deps (require= / loadAfter= / loadBefore=), so dependencies appear # before their dependents — walk it forward. map_folders: List[str] = [] for mod_id in order: for mf in by_id[mod_id].maps: if mf not in map_folders: map_folders.append(mf) return { "Mods": mods_line, "WorkshopItems": workshop_seen, "Map": map_folders, "warnings": { "cycles": cycles, "missing_requirements": missing, "incompatible_enabled": incompat, }, } # ----------------------------------------------------------------------------- # CLI # ----------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser( description="Sort PZ mods by load order. Reads DepotDownloader output layout.", ) ap.add_argument("mods_root", help="Path containing /mods//mod.info trees") ap.add_argument("--rules", help="Optional sorting_rules.txt path") ap.add_argument("--json", action="store_true", help="Output JSON instead of ini blocks") args = ap.parse_args() root = Path(args.mods_root).resolve() mods = load_mods_from_dir(root) if not mods: print("ERROR: no mods found", file=sys.stderr) sys.exit(2) rules: Dict[str, SortingRule] = {} if args.rules: rules = parse_sorting_rules(Path(args.rules).read_text(encoding="utf-8")) result = sort_mods(mods, rules) if args.json: print(json.dumps(result, indent=2)) return print("WorkshopItems=" + ";".join(result["WorkshopItems"])) print("Mods=" + ";".join(result["Mods"])) if result["Map"]: print("Map=" + ";".join(result["Map"])) w = result["warnings"] if w["cycles"] or w["missing_requirements"] or w["incompatible_enabled"]: print("\n# Warnings") if w["cycles"]: print("# cycles:", w["cycles"]) if w["missing_requirements"]: print("# missing_requirements:", w["missing_requirements"]) if w["incompatible_enabled"]: print("# incompatible_enabled:", w["incompatible_enabled"]) if __name__ == "__main__": main()