import 'dart:io'; import 'package:collection/collection.dart'; import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:xml/xml.dart'; class LoadOrder { List order = []; final List errors = []; List get loadOrder { return order.map((mod) => mod.id).toList(); } LoadOrder(); bool get hasErrors => errors.isNotEmpty; } class ModList { String configPath = ''; String modsPath = ''; // O(1) lookup Map activeMods = {}; Map mods = {}; ModList({this.configPath = '', this.modsPath = ''}); ModList copyWith({ String? configPath, String? modsPath, Map? mods, Map? activeMods, }) { final newModlist = ModList( configPath: configPath ?? this.configPath, modsPath: modsPath ?? this.modsPath, ); newModlist.mods = Map.from(mods ?? this.mods); newModlist.activeMods = Map.from(activeMods ?? this.activeMods); return newModlist; } Stream loadAvailable() async* { final logger = Logger.instance; final stopwatch = Stopwatch()..start(); final directory = Directory(modsPath); if (!directory.existsSync()) { logger.error('Error: Mods root directory does not exist: $modsPath'); return; } final List entities = directory.listSync(); // TODO: Count only the latest version of each mod and not all versions final List modDirectories = entities.whereType().map((dir) => dir.path).toList(); logger.info( 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', ); for (final modDir in modDirectories) { try { final modStart = stopwatch.elapsedMilliseconds; // Check if this directory contains a valid mod final aboutFile = File('$modDir/About/About.xml'); if (!aboutFile.existsSync()) { logger.warning('No About.xml found in directory: $modDir'); continue; } final mod = Mod.fromDirectory(modDir); logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); if (mods.containsKey(mod.id)) { logger.warning( 'Mod $mod.id already exists in mods list, overwriting', ); final existingMod = mods[mod.id]!; mods[mod.id] = Mod( name: mod.name, id: mod.id, path: mod.path, versions: mod.versions, description: mod.description, dependencies: mod.dependencies, loadAfter: mod.loadAfter, loadBefore: mod.loadBefore, incompatibilities: mod.incompatibilities, size: mod.size, enabled: existingMod.enabled, isBaseGame: existingMod.isBaseGame, isExpansion: existingMod.isExpansion, ); logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); } else { mods[mod.id] = mod; logger.info('Added new mod: ${mod.name} (ID: ${mod.id})'); } final modTime = stopwatch.elapsedMilliseconds - modStart; logger.info( 'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms', ); yield mod; } catch (e) { logger.error('Error loading mod from directory: $modDir'); logger.error('Error: $e'); } } } Stream loadActive() async* { final logger = Logger.instance; final file = File(configPath); logger.info('Loading configuration from: $configPath'); try { final xmlString = file.readAsStringSync(); logger.info('XML content read successfully.'); final xmlDocument = XmlDocument.parse(xmlString); logger.info('XML document parsed successfully.'); final modConfigData = xmlDocument.findElements("ModsConfigData").first; logger.info('Found ModsConfigData element.'); final modsElement = modConfigData.findElements("activeMods").first; logger.info('Found activeMods element.'); final modElements = modsElement.findElements("li"); logger.info('Found ${modElements.length} active mods.'); // Get the list of known expansions final knownExpansionsElement = modConfigData.findElements("knownExpansions").firstOrNull; final knownExpansionIds = knownExpansionsElement != null ? knownExpansionsElement .findElements("li") .map((e) => e.innerText.toLowerCase()) .toList() : []; logger.info('Found ${knownExpansionIds.length} known expansions.'); // Clear and recreate the mods list for (final modElement in modElements) { final modId = modElement.innerText.toLowerCase(); // Check if this is a special Ludeon mod final isBaseGame = modId == 'ludeon.rimworld'; final isExpansion = !isBaseGame && modId.startsWith('ludeon.rimworld.') && knownExpansionIds.contains(modId); final existingMod = mods[modId]; final mod = Mod( name: existingMod?.name ?? (isBaseGame ? "RimWorld" : isExpansion ? "RimWorld ${_expansionNameFromId(modId)}" : modId), id: existingMod?.id ?? modId, path: existingMod?.path ?? '', versions: existingMod?.versions ?? [], description: existingMod?.description ?? (isBaseGame ? "RimWorld base game" : isExpansion ? "RimWorld expansion" : ""), dependencies: existingMod?.dependencies ?? [], loadAfter: existingMod?.loadAfter ?? (isExpansion ? ['ludeon.rimworld'] : []), loadBefore: existingMod?.loadBefore ?? [], incompatibilities: existingMod?.incompatibilities ?? [], enabled: existingMod?.enabled ?? false, size: existingMod?.size ?? 0, isBaseGame: isBaseGame, isExpansion: isExpansion, ); if (mods.containsKey(modId)) { logger.warning('Mod $modId already exists in mods list, overwriting'); } mods[modId] = mod; setEnabled(modId, true); yield mod; } logger.info('Loaded ${modElements.length} mods from config file.'); } catch (e) { logger.error('Error loading configuration file: $e'); throw Exception('Failed to load config file: $e'); } } void setEnabled(String modId, bool enabled) { if (mods.containsKey(modId)) { final mod = mods[modId]!; mod.enabled = enabled; if (enabled) { activeMods[modId] = mod; } else { activeMods.remove(modId); } } } void enableAll() { for (final mod in mods.values) { setEnabled(mod.id, true); } } void disableAll() { for (final mod in mods.values) { setEnabled(mod.id, false); } } void enableMods(List modIds) { for (final modId in modIds) { setEnabled(modId, true); } } void disableMods(List modIds) { for (final modId in modIds) { setEnabled(modId, false); } } LoadOrder generateLoadOrder([LoadOrder? loadOrder]) { loadOrder ??= LoadOrder(); final logger = Logger.instance; logger.info('Generating load order...'); for (final mod in activeMods.values) { logger.info('Checking mod: ${mod.id}'); logger.info('Mod details: ${mod.toString()}'); for (final incomp in mod.incompatibilities) { if (activeMods.containsKey(incomp)) { loadOrder.errors.add( 'Incompatibility detected: ${mod.id} is incompatible with $incomp', ); logger.warning( 'Incompatibility detected: ${mod.id} is incompatible with $incomp', ); } else { logger.info('No incompatibility found for: $incomp'); } } for (final dep in mod.dependencies) { if (!activeMods.containsKey(dep)) { loadOrder.errors.add('Missing dependency: ${mod.id} requires $dep'); logger.warning('Missing dependency: ${mod.id} requires $dep'); } else { logger.info('Dependency found: ${mod.id} requires $dep'); } } } logger.info('Adding active mods to load order...'); loadOrder.order.addAll(activeMods.values.toList()); logger.info( 'Active mods added: ${loadOrder.order.map((mod) => mod.id).join(', ')}', ); final modMap = {for (final mod in loadOrder.order) mod.id: mod}; final graph = >{}; final inDegree = {}; // Step 1: Initialize graph and inDegree for (final mod in loadOrder.order) { graph[mod.id] = {}; inDegree[mod.id] = 0; } // Step 2: Build dependency graph void addEdge(String from, String to) { final fromMod = modMap[from]; if (fromMod == null) { logger.warning('Missing dependency: $from'); return; } final toMod = modMap[to]; if (toMod == null) { logger.warning('Missing dependency: $to'); return; } if (graph[from]!.add(to)) { inDegree[to] = inDegree[to]! + 1; } } for (final mod in loadOrder.order) { for (final target in mod.loadBefore) { addEdge(mod.id, target); } for (final target in mod.loadAfter) { addEdge(target, mod.id); } for (final dep in mod.dependencies) { addEdge(dep, mod.id); } } // Step 3: Calculate tiers dynamically with cross-tier dependencies final tiers = {}; for (final mod in loadOrder.order) { int tier = 2; // Default to Tier 2 // Check if mod loads before any base game mod (Tier 0) final loadsBeforeBase = mod.loadBefore.any( (id) => modMap[id]?.isBaseGame ?? false, ); if (mod.isBaseGame || loadsBeforeBase) { tier = 0; } else { // Check if mod loads before any expansion (Tier 1) final loadsBeforeExpansion = mod.loadBefore.any( (id) => modMap[id]?.isExpansion ?? false, ); if (mod.isExpansion || loadsBeforeExpansion) { tier = 1; } } tiers[mod] = tier; } // Step 4: Global priority queue (tier ascending, size descending) final pq = PriorityQueue((a, b) { final tierA = tiers[a]!; final tierB = tiers[b]!; if (tierA != tierB) return tierA.compareTo(tierB); return b.size.compareTo(a.size); }); // Initialize queue with mods having inDegree 0 for (final mod in loadOrder.order) { if (inDegree[mod.id] == 0) { pq.add(mod); } } final orderedMods = []; while (pq.isNotEmpty) { final current = pq.removeFirst(); orderedMods.add(current); for (final neighborId in graph[current.id]!) { inDegree[neighborId] = inDegree[neighborId]! - 1; if (inDegree[neighborId] == 0) { final neighbor = modMap[neighborId]!; pq.add(neighbor); } } } if (orderedMods.length != loadOrder.order.length) { loadOrder.errors.add('Cycle detected in dependencies'); logger.warning( 'Cycle detected in dependencies: expected ${loadOrder.order.length}, got ${orderedMods.length}.', ); } loadOrder.order = orderedMods; logger.info( 'Load order generated successfully with ${loadOrder.order.length} mods.', ); for (final mod in loadOrder.order) { logger.info('Mod: ${mod.toString()}'); } return loadOrder; } // The point of relations and the recursive call is to handle the case where // A mod depends on a mod that depends on another mod // So we move our first mod A to after B // But then we move B after C and A is no longer guranteed to be after B // So we update it too just in case // To make sure we have A B C // Now it opens us to a stack overflow... LoadOrder shuffleMod( Mod mod, LoadOrder loadOrder, Map> relations, [ Map? seen, ]) { final logger = Logger.instance; logger.info('Starting shuffleMod for mod: ${mod.id}'); // Prevent infinite loops seen ??= {}; if (seen[mod.id] == true) { logger.info('Mod ${mod.id} has already been seen, skipping.'); return loadOrder; } seen[mod.id] = true; logger.info('Marking mod ${mod.id} as seen.'); for (final dependency in mod.dependencies) { logger.info('Checking dependency: $dependency for mod ${mod.id}'); final depMod = mods[dependency]; if (depMod == null) { loadOrder.errors.add( 'Missing dependency: ${mod.id} requires mod with ID $dependency', ); logger.warning( 'Missing dependency: ${mod.id} requires mod with ID $dependency', ); continue; } if (loadOrder.order.indexOf(mod) < loadOrder.order.indexOf(depMod)) { logger.info('Reordering: ${mod.id} should come after ${depMod.id}'); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(depMod) + 1, mod); relations[mod.id] = [...relations[mod.id] ?? [], depMod]; } } for (final loadAfter in mod.loadAfter) { logger.info('Checking loadAfter: $loadAfter for mod ${mod.id}'); final loadAfterMod = mods[loadAfter]; if (loadAfterMod != null && loadOrder.order.indexOf(mod) < loadOrder.order.indexOf(loadAfterMod)) { final loadAfterIndex = loadOrder.order.indexOf(loadAfterMod); // Mod is not loaded, we don't care about it if (loadAfterIndex == -1) { logger.warning( 'Missing loadAfter: ${mod.id} requires mod with ID $loadAfter', ); continue; } logger.info( 'Reordering: ${mod.id} should come after ${loadAfterMod.id}', ); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(loadAfterMod) + 1, mod); relations[mod.id] = [...relations[mod.id] ?? [], loadAfterMod]; } } for (final loadBefore in mod.loadBefore) { logger.info('Checking loadBefore: $loadBefore for mod ${mod.id}'); final loadBeforeMod = mods[loadBefore]; if (loadBeforeMod != null && loadOrder.order.indexOf(mod) > loadOrder.order.indexOf(loadBeforeMod)) { final loadBeforeIndex = loadOrder.order.indexOf(loadBeforeMod); // Mod is not loaded, we don't care about it if (loadBeforeIndex == -1) { logger.warning( 'Missing loadBefore: ${mod.id} requires mod with ID $loadBefore', ); continue; } logger.info( 'Reordering: ${mod.id} should come before ${loadBeforeMod.id}', ); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(loadBeforeMod), mod); relations[mod.id] = [...relations[mod.id] ?? [], loadBeforeMod]; } } for (final relatedMod in relations[mod.id] ?? []) { logger.info('Recursively shuffling related mod: ${relatedMod.id}'); loadOrder = shuffleMod(relatedMod, loadOrder, relations, seen); } logger.info('Completed shuffleMod for mod: ${mod.id}'); return loadOrder; } List> checkIncompatibilities(List modIds) { final incompatibilities = >[]; for (final modId in modIds) { final mod = mods[modId]!; for (final incomp in mod.incompatibilities) { if (modIds.contains(incomp)) { incompatibilities.add([mod.id, incomp]); } } } return incompatibilities; } LoadOrder loadDependencies( String modId, [ LoadOrder? loadOrder, List? toEnable, Map? seen, List? cyclePath, ]) { final mod = mods[modId]!; loadOrder ??= LoadOrder(); toEnable ??= []; seen ??= {}; cyclePath ??= []; // Add current mod to cycle path cyclePath.add(modId); for (final dep in mod.dependencies) { if (!mods.containsKey(dep)) { loadOrder.errors.add( 'Missing dependency: ${mod.name} requires mod with ID $dep', ); continue; } final depMod = mods[dep]!; if (seen[dep] == true) { // Find the start of the cycle int cycleStart = cyclePath.indexOf(dep); if (cycleStart >= 0) { // Extract the cycle part List cycleIds = [...cyclePath.sublist(cycleStart), modId]; loadOrder.errors.add( 'Cyclic dependency detected: ${cycleIds.join(' -> ')}', ); } else { loadOrder.errors.add('Cyclic dependency detected: $modId -> $dep'); } continue; } seen[dep] = true; toEnable.add(depMod.id); loadDependencies( depMod.id, loadOrder, toEnable, seen, List.from(cyclePath), ); } return loadOrder; } LoadOrder loadRequired([LoadOrder? loadOrder]) { loadOrder ??= LoadOrder(); final toEnable = []; for (final modid in activeMods.keys) { loadDependencies(modid, loadOrder, toEnable); } for (final modid in toEnable) { setEnabled(modid, true); } return generateLoadOrder(loadOrder); } LoadOrder loadRequiredBaseGame([LoadOrder? loadOrder]) { loadOrder ??= LoadOrder(); final baseGameMods = mods.values.where((mod) => mod.isBaseGame || mod.isExpansion).toList(); // You would probably want to load these too if you had them final specialMods = mods.values .where( (mod) => mod.id.contains("harmony") || mod.id.contains("prepatcher") || mod.id.contains("betterlog"), ) .toList(); enableMods(baseGameMods.map((mod) => mod.id).toList()); enableMods(specialMods.map((mod) => mod.id).toList()); return loadRequired(loadOrder); } } String _expansionNameFromId(String id) { final parts = id.split('.'); if (parts.length < 3) return id; final expansionPart = parts[2]; return expansionPart.substring(0, 1).toUpperCase() + expansionPart.substring(1); }