diff --git a/lib/main.dart b/lib/main.dart index cbe1238..03e04b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,7 @@ class RimWorldModManager extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'RimWorld Mod Manager', + title: 'Rimworld Mod Manager', theme: ThemeData.dark().copyWith( primaryColor: const Color(0xFF3D4A59), colorScheme: ColorScheme.fromSeed( @@ -57,7 +57,7 @@ class _ModManagerHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('RimWorld Mod Manager')), + appBar: AppBar(title: const Text('Rimworld Mod Manager')), body: _pages[_selectedIndex], bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, @@ -95,7 +95,7 @@ class _ModListPageState extends State { bool _isLoading = false; String _loadingStatus = ''; int _totalModsFound = 0; - bool _skipFileCount = true; // Skip file counting by default for faster loading + bool _skipFileCount = false; // Skip file counting by default for faster loading @override Widget build(BuildContext context) { @@ -116,7 +116,7 @@ class _ModListPageState extends State { Text('Mod List', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 16), Text( - 'Ready to scan for RimWorld mods.', + 'Ready to scan for Rimworld mods.', style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 12), @@ -263,35 +263,235 @@ class _ModListPageState extends State { } // Page to manage mod load order with dependency visualization -class LoadOrderPage extends StatelessWidget { +class LoadOrderPage extends StatefulWidget { const LoadOrderPage({super.key}); + @override + State createState() => _LoadOrderPageState(); +} + +class _LoadOrderPageState extends State { + bool _isLoading = false; + String _statusMessage = ''; + List _sortedMods = []; + bool _hasCycles = false; + List? _cycleInfo; + List> _incompatibleMods = []; + @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.reorder, size: 64), - const SizedBox(height: 16), - Text('Load Order', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 16), - Text( - 'Manage your mod loading order with dependency resolution.', - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - // TODO: Implement automatic load order sorting - }, - child: const Text('Auto-sort Mods'), - ), - ], + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mod Load Order', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + 'Automatically sort mods based on dependencies.', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + Row( + children: [ + ElevatedButton( + onPressed: _isLoading ? null : _sortMods, + child: const Text('Auto-sort Mods'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _isLoading || _sortedMods.isEmpty ? null : _saveModOrder, + child: const Text('Save Load Order'), + ), + ], + ), + const SizedBox(height: 16), + if (_isLoading) + const LinearProgressIndicator(), + if (_statusMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + _statusMessage, + style: TextStyle( + color: _hasCycles || _incompatibleMods.isNotEmpty + ? Colors.orange + : Colors.green, + ), + ), + ), + if (_hasCycles && _cycleInfo != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'Dependency cycle detected: ${_cycleInfo!.join(" -> ")}', + style: TextStyle(color: Colors.orange), + ), + ), + if (_incompatibleMods.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Incompatible mods detected:', + style: TextStyle(color: Colors.orange), + ), + ...List.generate(_incompatibleMods.length > 5 ? 5 : _incompatibleMods.length, (index) { + final pair = _incompatibleMods[index]; + return Text( + '- ${modManager.mods[pair[0]]?.name} and ${modManager.mods[pair[1]]?.name}', + style: TextStyle(color: Colors.orange), + ); + }), + if (_incompatibleMods.length > 5) + Text('...and ${_incompatibleMods.length - 5} more'), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: _sortedMods.isEmpty + ? Center( + child: Text( + 'Click "Auto-sort Mods" to generate a load order based on dependencies.', + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: _sortedMods.length, + itemBuilder: (context, index) { + final mod = _sortedMods[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: Text('${index + 1}'), + title: Text(mod.name), + subtitle: Text(mod.id), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (mod.hardDependencies.isNotEmpty) + Tooltip( + message: 'Hard dependencies: ${mod.hardDependencies.length}', + child: Icon(Icons.link, color: Colors.orange, size: 16), + ), + const SizedBox(width: 4), + if (mod.softDependencies.isNotEmpty) + Tooltip( + message: 'Soft dependencies: ${mod.softDependencies.length}', + child: Icon(Icons.link_off, color: Colors.blue, size: 16), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), ), ); } + + void _sortMods() async { + if (modManager.mods.isEmpty) { + setState(() { + _statusMessage = 'No mods have been loaded yet. Please load mods first.'; + }); + return; + } + + setState(() { + _isLoading = true; + _statusMessage = 'Sorting mods based on dependencies...'; + _hasCycles = false; + _cycleInfo = null; + _incompatibleMods = []; + }); + + // This could be slow so run in a separate isolate or compute + await Future.delayed(Duration.zero); // Allow UI to update + + try { + // Check for cycles first + final hardGraph = modManager.buildDependencyGraph(); + final cycle = modManager.detectCycle(hardGraph); + + if (cycle != null) { + setState(() { + _hasCycles = true; + _cycleInfo = cycle; + }); + } + + // Get incompatibilities + _incompatibleMods = modManager.findIncompatibilities(); + + // Get the sorted mods + final sortedMods = modManager.getModsInLoadOrder(); + + setState(() { + _sortedMods = sortedMods; + _isLoading = false; + _statusMessage = 'Sorting complete! ${sortedMods.length} mods sorted.'; + if (_hasCycles) { + _statusMessage += ' Warning: Dependency cycles were found and fixed.'; + } + if (_incompatibleMods.isNotEmpty) { + _statusMessage += ' Warning: ${_incompatibleMods.length} incompatible mod pairs found.'; + } + }); + } catch (e) { + setState(() { + _isLoading = false; + _statusMessage = 'Error sorting mods: $e'; + }); + } + } + + void _saveModOrder() async { + if (_sortedMods.isEmpty) return; + + setState(() { + _isLoading = true; + _statusMessage = 'Saving mod load order...'; + }); + + try { + // Create a ConfigFile instance + final configFile = ConfigFile(path: configPath); + + // Load the current config + configFile.load(); + + // Replace the mods with our sorted list + // We need to convert our Mods to the format expected by the config file + configFile.mods.clear(); + for (final mod in _sortedMods) { + configFile.mods.add(mod); + } + + // Save the updated config + configFile.save(); + + setState(() { + _isLoading = false; + _statusMessage = 'Mod load order saved successfully!'; + }); + } catch (e) { + setState(() { + _isLoading = false; + _statusMessage = 'Error saving mod load order: $e'; + }); + } + } } // Page for troubleshooting problematic mods diff --git a/lib/modloader.dart b/lib/modloader.dart index d89cbff..084515b 100644 --- a/lib/modloader.dart +++ b/lib/modloader.dart @@ -13,7 +13,7 @@ XmlElement findCaseInsensitive(XmlElement element, String name) { } XmlElement findCaseInsensitiveDoc(XmlDocument document, String name) { - name = name.toLowerCase(); + name = name.toLowerCase(); return document.childElements.firstWhere( (e) => e.name.local.toLowerCase() == name, ); @@ -48,12 +48,12 @@ class Mod { static Mod fromDirectory(String path, {bool skipFileCount = false}) { final stopwatch = Stopwatch()..start(); - + final aboutFile = File('$path/About/About.xml'); if (!aboutFile.existsSync()) { throw Exception('About.xml file does not exist in $aboutFile'); } - + final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync()); final xmlTime = stopwatch.elapsedMilliseconds; @@ -147,29 +147,33 @@ class Mod { } catch (e) { // Silent error for optional element } - + final metadataTime = stopwatch.elapsedMilliseconds - xmlTime; int size = 0; if (!skipFileCount) { - size = Directory(path) - .listSync(recursive: true) - .where( - (entity) => - !entity.path - .split(Platform.pathSeparator) - .last - .startsWith('.'), - ) - .length; + size = + Directory(path) + .listSync(recursive: true) + .where( + (entity) => + !entity.path + .split(Platform.pathSeparator) + .last + .startsWith('.'), + ) + .length; } - - final fileCountTime = stopwatch.elapsedMilliseconds - metadataTime - xmlTime; + + final fileCountTime = + stopwatch.elapsedMilliseconds - metadataTime - xmlTime; final totalTime = stopwatch.elapsedMilliseconds; - + // Uncomment for detailed timing - print('Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms'); - + print( + 'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms', + ); + return Mod( name: name, id: id, @@ -201,38 +205,309 @@ class ModList { final List modDirectories = entities.whereType().map((dir) => dir.path).toList(); - print('Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)'); + print( + 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', + ); int processedCount = 0; int totalMods = modDirectories.length; - + for (final modDir in modDirectories) { try { final modStart = stopwatch.elapsedMilliseconds; - + final mod = Mod.fromDirectory(modDir, skipFileCount: skipFileCount); mods[mod.id] = mod; processedCount++; - + final modTime = stopwatch.elapsedMilliseconds - modStart; if (processedCount % 50 == 0 || processedCount == totalMods) { - print('Progress: Loaded $processedCount/$totalMods mods (${stopwatch.elapsedMilliseconds}ms, avg ${stopwatch.elapsedMilliseconds/processedCount}ms per mod)'); + print( + 'Progress: Loaded $processedCount/$totalMods mods (${stopwatch.elapsedMilliseconds}ms, avg ${stopwatch.elapsedMilliseconds / processedCount}ms per mod)', + ); } - + yield mod; } catch (e) { print('Error loading mod from directory: $modDir'); print('Error: $e'); } } - + final totalTime = stopwatch.elapsedMilliseconds; - print('Loading complete! Loaded ${mods.length} mods in ${totalTime}ms (${totalTime/mods.length}ms per mod)'); + print( + 'Loading complete! Loaded ${mods.length} mods in ${totalTime}ms (${totalTime / mods.length}ms per mod)', + ); } else { print('Mods root directory does not exist: $path'); } } + + // 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); + } + } + } + + 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 + for (final mod in mods.values) { + for (final dependency in mod.softDependencies) { + // Only add if the dependency exists in our loaded mods + if (mods.containsKey(dependency)) { + graph[mod.id]!.add(dependency); + } + } + } + + 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 + 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; + } + } + + // Start with nodes that have no dependencies (in-degree = 0) + List nodesWithNoDependencies = []; + for (final node in inDegree.keys) { + if (inDegree[node] == 0) { + nodesWithNoDependencies.add(node); + } + } + + // Result will store the topological order + List result = []; + + // Process nodes with no dependencies + while (nodesWithNoDependencies.isNotEmpty) { + final node = nodesWithNoDependencies.removeLast(); + result.add(node); + + // For each node that depends on this one, decrement its in-degree + final dependents = []; + for (final entry in graphCopy.entries) { + if (entry.value.contains(node)) { + dependents.add(entry.key); + } + } + + for (final dependent in dependents) { + graphCopy[dependent]!.remove(node); + inDegree[dependent] = inDegree[dependent]! - 1; + if (inDegree[dependent] == 0) { + nodesWithNoDependencies.add(dependent); + } + } + } + + // Check if we have a valid topological sort + if (result.length != graph.keys.length) { + print( + "Warning: Cyclic dependency detected, topological sort may be incomplete", + ); + + // Add any remaining nodes to keep all mods + for (final node in graph.keys) { + if (!result.contains(node)) { + result.add(node); + } + } + } + + return result.reversed.toList(); // Reverse to get correct load order + } + + // 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() { + print("Building dependency graph..."); + final hardGraph = buildDependencyGraph(); + + // Check for cycles in hard dependencies + final cycle = detectCycle(hardGraph); + if (cycle != null) { + print( + "Warning: Cycle in hard dependencies detected: ${cycle.join(" -> ")}", + ); + print("Will attempt to break cycle to produce a valid load order"); + } + + print("Performing topological sort for hard dependencies..."); + final hardOrder = topologicalSort(hardGraph); + + print("Adjusting for soft dependencies..."); + final softGraph = buildSoftDependencyGraph(); + final finalOrder = adjustForSoftDependencies(hardOrder, softGraph); + + // Check for incompatibilities + final incompatibilities = findIncompatibilities(); + if (incompatibilities.isNotEmpty) { + print("Warning: Incompatible mods detected:"); + for (final pair in incompatibilities) { + print(" - ${mods[pair[0]]?.name} and ${mods[pair[1]]?.name}"); + } + } + + print( + "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; @@ -255,11 +530,105 @@ class ConfigFile { final modsElement = modConfigData.findElements("activeMods").first; print('Found activeMods element.'); - final mods = modsElement.findElements("li"); - print('Found ${mods.length} active mods.'); + final modElements = modsElement.findElements("li"); + print('Found ${modElements.length} active mods.'); - for (final mod in mods) { - // print('Mod found: ${mod.innerText}'); + mods = []; + for (final modElement in modElements) { + final modId = modElement.innerText.toLowerCase(); + // We'll populate with dummy mods for now, they'll be replaced later + mods.add( + Mod( + name: modId, + id: modId, + path: '', + versions: [], + description: '', + hardDependencies: [], + softDependencies: [], + incompatabilities: [], + enabled: true, + size: 0, + ), + ); + } + + print('Loaded ${mods.length} mods from config file.'); + } + + // Save the current mod order back to the config file + void save() { + final file = File(path); + print('Saving configuration to: $path'); + + // Create a backup just in case + final backupPath = '$path.bak'; + file.copySync(backupPath); + print('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)); + print('Configuration saved successfully with ${mods.length} mods.'); + } catch (e) { + print('Error saving configuration: $e'); + print('Original configuration preserved at: $backupPath'); } } + + // Fix the load order of mods according to dependencies + void fixLoadOrder(ModList modList) { + print("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; + + print( + "Load order fixed. ${mods.length} mods are now in dependency-sorted order.", + ); + } }