import 'dart:io'; import 'dart:async'; import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:xml/xml.dart'; const root = r'C:/Users/Administrator/Seafile/Games-RimWorld'; const modsRoot = '$root/294100'; const configRoot = '$root/AppData/RimWorld by Ludeon Studios/Config'; const configPath = '$configRoot/ModsConfig.xml'; const logsPath = '$root/ModManager'; class ModList { final String path; Map mods = {}; bool modsLoaded = false; String loadingStatus = ''; int totalModsFound = 0; int loadedModsCount = 0; ModList({required this.path}); Future loadWithConfig({bool skipFileCount = false}) async { final logger = Logger.instance; // Clear existing state if reloading if (modsLoaded) { logger.info('Clearing existing mods state for reload.'); mods.clear(); } modsLoaded = false; loadedModsCount = 0; loadingStatus = 'Loading active mods from config...'; final stopwatch = Stopwatch()..start(); logger.info('Loading configuration from config file: $configPath'); try { // First, load the config file to get the list of active mods final configFile = ConfigFile(path: configPath); await configFile.load(); logger.info('Config file loaded successfully.'); // Create a Set of active mod IDs for quick lookups final activeModIds = configFile.mods.map((m) => m.id).toSet(); logger.info('Active mod IDs created: ${activeModIds.join(', ')}'); // Special handling for Ludeon mods that might not exist as directories for (final configMod in configFile.mods) { if (configMod.id.startsWith('ludeon.')) { final isBaseGame = configMod.id == 'ludeon.rimworld'; final isExpansion = configMod.id.startsWith('ludeon.rimworld.') && !isBaseGame; // Create a placeholder mod for the Ludeon mods that might not have directories final mod = Mod( name: isBaseGame ? "RimWorld" : isExpansion ? "RimWorld ${_expansionNameFromId(configMod.id)}" : configMod.id, id: configMod.id, path: '', versions: [], description: isBaseGame ? "RimWorld base game" : isExpansion ? "RimWorld expansion" : "", hardDependencies: [], loadAfter: isExpansion ? ['ludeon.rimworld'] : [], loadBefore: [], incompatabilities: [], enabled: true, size: 0, isBaseGame: isBaseGame, isExpansion: isExpansion, ); mods[configMod.id] = mod; loadedModsCount++; logger.info('Added mod from config: ${mod.name} (ID: ${mod.id})'); } } // Now scan the directory for mod metadata loadingStatus = 'Scanning mod directories...'; final directory = Directory(path); if (!directory.existsSync()) { loadingStatus = 'Error: Mods root directory does not exist: $path'; logger.error(loadingStatus); return; } final List entities = directory.listSync(); final List modDirectories = entities.whereType().map((dir) => dir.path).toList(); totalModsFound = modDirectories.length; loadingStatus = 'Found $totalModsFound mod directories. Loading...'; 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, skipFileCount: skipFileCount); logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); // If we already have this mod from the config (like Ludeon mods), update its data if (mods.containsKey(mod.id)) { final existingMod = mods[mod.id]!; mods[mod.id] = Mod( name: mod.name, id: mod.id, path: mod.path, versions: mod.versions, description: mod.description, hardDependencies: mod.hardDependencies, loadAfter: mod.loadAfter, loadBefore: mod.loadBefore, incompatabilities: mod.incompatabilities, enabled: activeModIds.contains( mod.id, ), // Set enabled based on config size: mod.size, isBaseGame: existingMod.isBaseGame, isExpansion: existingMod.isExpansion, ); logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); } else { // Otherwise add as a new mod mods[mod.id] = Mod( name: mod.name, id: mod.id, path: mod.path, versions: mod.versions, description: mod.description, hardDependencies: mod.hardDependencies, loadAfter: mod.loadAfter, loadBefore: mod.loadBefore, incompatabilities: mod.incompatabilities, enabled: activeModIds.contains( mod.id, ), // Set enabled based on config size: mod.size, isBaseGame: mod.isBaseGame, isExpansion: mod.isExpansion, ); loadedModsCount++; logger.info('Added new mod: ${mod.name} (ID: ${mod.id})'); } final modTime = stopwatch.elapsedMilliseconds - modStart; loadingStatus = 'Loaded $loadedModsCount/$totalModsFound mods...'; if (loadedModsCount % 50 == 0 || loadedModsCount == totalModsFound) { logger.info( 'Progress: Loaded $loadedModsCount mods (${modTime}ms)', ); } } catch (e) { logger.error('Error loading mod from directory: $modDir'); logger.error('Error: $e'); } } modsLoaded = true; final totalTime = stopwatch.elapsedMilliseconds; loadingStatus = 'Completed! Loaded $loadedModsCount mods in ${totalTime}ms.'; logger.info( 'Loading complete! Loaded ${mods.length} mods in ${totalTime}ms', ); } catch (e) { loadingStatus = 'Error loading mods: $e'; logger.error(loadingStatus); } } // Helper function to get a nice expansion name from ID 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); } // Build a directed graph of mod dependencies Map> buildDependencyGraph() { // Graph where graph[A] contains B if A depends on B (B must load before A) final Map> graph = {}; // Initialize the graph with empty dependency sets for all mods for (final mod in mods.values) { graph[mod.id] = Set(); } // Add hard dependencies to the graph for (final mod in mods.values) { for (final dependency in mod.hardDependencies) { // Only add if the dependency exists in our loaded mods if (mods.containsKey(dependency)) { graph[mod.id]!.add(dependency); } } } // Handle base game and expansions: // 1. Add the base game as a dependency of all mods except those who have loadBefore for it // 2. Add expansions as dependencies of mods that load after them // First identify the base game and expansions final baseGameId = mods.values.where((m) => m.isBaseGame).map((m) => m.id).firstOrNull; if (baseGameId != null) { for (final mod in mods.values) { // Skip the base game itself and mods that explicitly load before it if (mod.id != baseGameId && !mod.loadBefore.contains(baseGameId)) { graph[mod.id]!.add(baseGameId); } } } return graph; } // Build a graph for soft dependencies Map> buildSoftDependencyGraph() { final Map> graph = {}; // Initialize the graph with empty sets for (final mod in mods.values) { graph[mod.id] = Set(); } // Add soft dependencies (loadAfter) for (final mod in mods.values) { for (final dependency in mod.loadAfter) { // Only add if the dependency exists in our loaded mods if (mods.containsKey(dependency)) { graph[mod.id]!.add(dependency); } } } // Handle loadBefore - invert the relationship for the graph // If A loadBefore B, then B softDepends on A for (final mod in mods.values) { for (final loadBeforeId in mod.loadBefore) { if (mods.containsKey(loadBeforeId)) { graph[loadBeforeId]!.add(mod.id); } } } return graph; } // Detect cycles in the dependency graph (which would make a valid loading order impossible) List? detectCycle(Map> graph) { // Track visited nodes and the current path Set visited = {}; Set currentPath = {}; List cycleNodes = []; bool dfs(String node, List path) { if (currentPath.contains(node)) { // Found a cycle int cycleStart = path.indexOf(node); cycleNodes = path.sublist(cycleStart); cycleNodes.add(node); // Close the cycle return true; } if (visited.contains(node)) { return false; } visited.add(node); currentPath.add(node); path.add(node); for (final dependency in graph[node] ?? {}) { if (dfs(dependency, path)) { return true; } } currentPath.remove(node); return false; } for (final node in graph.keys) { if (!visited.contains(node)) { if (dfs(node, [])) { return cycleNodes; } } } return null; // No cycle found } // Perform a topological sort using Kahn's algorithm with size prioritization List topologicalSort(Map> graph) { // Create a copy of the graph to work with final Map> graphCopy = {}; for (final entry in graph.entries) { graphCopy[entry.key] = Set.from(entry.value); } // Calculate in-degree of each node (number of edges coming in) Map inDegree = {}; for (final node in graphCopy.keys) { inDegree[node] = 0; } for (final dependencies in graphCopy.values) { for (final dep in dependencies) { inDegree[dep] = (inDegree[dep] ?? 0) + 1; } } // Separate nodes by "layers" (nodes that can be processed at the same time) List> layers = []; // Process until all nodes are assigned to layers while (inDegree.isNotEmpty) { // Find all nodes with in-degree 0 in this iteration List currentLayer = []; inDegree.forEach((node, degree) { if (degree == 0) { currentLayer.add(node); } }); if (currentLayer.isEmpty && inDegree.isNotEmpty) { // We have a cycle - add all remaining nodes to a final layer currentLayer = inDegree.keys.toList(); print( "Warning: Cycle detected in dependency graph. Adding all remaining nodes to final layer.", ); } // Sort this layer by mod size (descending) currentLayer.sort((a, b) { final modA = mods[a]; final modB = mods[b]; if (modA == null || modB == null) return 0; return modB.size.compareTo(modA.size); // Larger mods first }); // Add the layer to our layers list layers.add(currentLayer); // Remove processed nodes from inDegree for (final node in currentLayer) { inDegree.remove(node); // Update in-degrees for remaining nodes for (final entry in graphCopy.entries) { if (entry.value.contains(node)) { if (inDegree.containsKey(entry.key)) { inDegree[entry.key] = inDegree[entry.key]! - 1; } } } } } // Flatten the layers to get the final order (first layer first) List result = []; for (final layer in layers) { result.addAll(layer); } // Final sanity check to make sure all nodes are included if (result.length != graph.keys.length) { // Add any missing nodes for (final node in graph.keys) { if (!result.contains(node)) { result.add(node); } } } return result; } // Adjust the order to respect soft dependencies where possible List adjustForSoftDependencies( List hardOrder, Map> softGraph, ) { // Create a map of positions in the hard dependency order Map positions = {}; for (int i = 0; i < hardOrder.length; i++) { positions[hardOrder[i]] = i; } // For each mod, try to move its soft dependencies earlier in the order bool changed = true; while (changed) { changed = false; for (final modId in hardOrder) { final softDeps = softGraph[modId] ?? {}; for (final softDep in softDeps) { // If the soft dependency is loaded after the mod, try to move it earlier if (positions.containsKey(softDep) && positions[softDep]! > positions[modId]!) { // Find where we can move the soft dependency to int targetPos = positions[modId]!; // Move the soft dependency just before the mod hardOrder.removeAt(positions[softDep]!); hardOrder.insert(targetPos, softDep); // Update positions for (int i = 0; i < hardOrder.length; i++) { positions[hardOrder[i]] = i; } changed = true; break; } } if (changed) break; } } return hardOrder; } // Check for incompatibilities in the current mod list List> findIncompatibilities() { List> incompatiblePairs = []; for (final mod in mods.values) { for (final incompatibility in mod.incompatabilities) { if (mods.containsKey(incompatibility)) { incompatiblePairs.add([mod.id, incompatibility]); } } } return incompatiblePairs; } // Sort mods based on dependencies and return the sorted list List sortMods() { final logger = Logger.instance; logger.info("Building dependency graph..."); final hardGraph = buildDependencyGraph(); // Check for cycles in hard dependencies final cycle = detectCycle(hardGraph); if (cycle != null) { logger.warning( "Cycle in hard dependencies detected: ${cycle.join(" -> ")}", ); logger.info("Will attempt to break cycle to produce a valid load order"); } logger.info( "Performing topological sort for hard dependencies (prioritizing larger mods)...", ); final hardOrder = topologicalSort(hardGraph); logger.info("Adjusting for soft dependencies..."); final softGraph = buildSoftDependencyGraph(); final finalOrder = adjustForSoftDependencies(hardOrder, softGraph); // Check for incompatibilities final incompatibilities = findIncompatibilities(); if (incompatibilities.isNotEmpty) { logger.warning("Incompatible mods detected:"); for (final pair in incompatibilities) { logger.warning(" - ${mods[pair[0]]?.name} and ${mods[pair[1]]?.name}"); } } logger.info( "Sorting complete. Final mod order contains ${finalOrder.length} mods.", ); return finalOrder; } // Get a list of mods in the proper load order List getModsInLoadOrder() { final orderedIds = sortMods(); return orderedIds.map((id) => mods[id]!).toList(); } } // Add a method to ConfigFile to fix the mod order class ConfigFile { final String path; List mods; ConfigFile({required this.path, this.mods = const []}); Future load() async { final logger = Logger.instance; final file = File(path); logger.info('Loading configuration from: $path'); 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 mods = []; 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); // We'll populate with dummy mods for now, they'll be replaced later mods.add( Mod( name: isBaseGame ? "RimWorld" : isExpansion ? "RimWorld ${_expansionNameFromId(modId)}" : modId, id: modId, path: '', versions: [], description: isBaseGame ? "RimWorld base game" : isExpansion ? "RimWorld expansion" : "", hardDependencies: [], loadAfter: isExpansion ? ['ludeon.rimworld'] : [], loadBefore: [], incompatabilities: [], enabled: true, size: 0, isBaseGame: isBaseGame, isExpansion: isExpansion, ), ); } logger.info('Loaded ${mods.length} mods from config file.'); } catch (e) { logger.error('Error loading configuration file: $e'); throw Exception('Failed to load config file: $e'); } } // Helper function to get a nice expansion name from ID 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); } // Save the current mod order back to the config file void save() { final logger = Logger.instance; final file = File(path); logger.info('Saving configuration to: $path'); // Create a backup just in case final backupPath = '$path.bak'; file.copySync(backupPath); logger.info('Created backup at: $backupPath'); try { // Load the existing XML final xmlString = file.readAsStringSync(); final xmlDocument = XmlDocument.parse(xmlString); // Get the ModsConfigData element final modConfigData = xmlDocument.findElements("ModsConfigData").first; // Get the activeMods element final modsElement = modConfigData.findElements("activeMods").first; // Clear existing mod entries modsElement.children.clear(); // Add mods in the new order for (final mod in mods) { final liElement = XmlElement(XmlName('li')); liElement.innerText = mod.id; modsElement.children.add(liElement); } // Write the updated XML back to the file file.writeAsStringSync(xmlDocument.toXmlString(pretty: true)); logger.info('Configuration saved successfully with ${mods.length} mods.'); } catch (e) { logger.error('Error saving configuration: $e'); logger.info('Original configuration preserved at: $backupPath'); } } // Fix the load order of mods according to dependencies void fixLoadOrder(ModList modList) { final logger = Logger.instance; logger.info("Fixing mod load order..."); // Get the ordered mod IDs from the mod list final orderedIds = modList.sortMods(); // Reorder the current mods list according to the dependency-sorted order // We only modify mods that exist in both the configFile and the modList List orderedMods = []; Set addedIds = {}; // First add mods in the sorted order for (final id in orderedIds) { final modIndex = mods.indexWhere((m) => m.id == id); if (modIndex >= 0) { orderedMods.add(mods[modIndex]); addedIds.add(id); } } // Then add any mods that weren't in the sorted list for (final mod in mods) { if (!addedIds.contains(mod.id)) { orderedMods.add(mod); } } // Replace the current mods list with the ordered one mods = orderedMods; logger.info( "Load order fixed. ${mods.length} mods are now in dependency-sorted order.", ); } }