Files
flutter-rimworld-modman/lib/modloader.dart

1015 lines
31 KiB
Dart

import 'dart:io';
import 'dart:async';
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';
// Logger class for writing logs to console and file
class Logger {
static final Logger _instance = Logger._internal();
static Logger get instance => _instance;
File? _logFile;
IOSink? _logSink;
Logger._internal() {
_initLogFile();
}
void _initLogFile() {
try {
// Use system temp directory
final tempDir = Directory.systemTemp;
final logFileName = 'rimworld_modman.log';
_logFile = File('${tempDir.path}${Platform.pathSeparator}$logFileName');
_logSink = _logFile!.openWrite(mode: FileMode.writeOnly);
info('Logging initialized. Log file: ${_logFile!.path}');
} catch (e) {
print('Failed to initialize log file: $e');
}
}
void _log(String message, String level) {
final timestamp = DateTime.now().toIso8601String();
final formattedMessage = '[$timestamp] [$level] $message';
// Always print to console
print(formattedMessage);
// Write to file if initialized
if (_logSink != null) {
try {
_logSink!.writeln(formattedMessage);
} catch (e) {
print('Error writing to log file: $e');
}
}
}
void info(String message) {
_log(message, 'INFO');
}
void warning(String message) {
_log(message, 'WARN');
}
void error(String message) {
_log(message, 'ERROR');
}
void close() {
if (_logSink != null) {
try {
_logSink!.flush();
_logSink!.close();
} catch (e) {
print('Error closing log file: $e');
}
_logSink = null;
}
}
}
XmlElement findCaseInsensitive(XmlElement element, String name) {
return element.childElements.firstWhere(
(e) => e.name.local.toLowerCase() == name,
);
}
XmlElement findCaseInsensitiveDoc(XmlDocument document, String name) {
name = name.toLowerCase();
return document.childElements.firstWhere(
(e) => e.name.local.toLowerCase() == name,
);
}
class Mod {
final String name; // ModMetaData.name
final String id; // ModMetaData.packageId
final String path; // figure it out
final List<String> versions; // ModMetaData.supportedVersions
final String description; // ModMetaData.description
final List<String> hardDependencies; // ModMetaData.modDependencies
final List<String> loadAfter; // ModMetaData.loadAfter
final List<String> loadBefore; // ModMetaData.loadBefore
final List<String> incompatabilities; // ModMetaData.incompatibleWith
final bool
enabled; // ConfigFile.mods.firstWhere((mod) => mod.id == id).enabled
final int size; // Count of files in the mod directory
final bool isBaseGame; // Is this the base RimWorld game
final bool isExpansion; // Is this a RimWorld expansion
Mod({
required this.name,
required this.id,
required this.path,
required this.versions,
required this.description,
required this.hardDependencies,
required this.loadAfter,
required this.loadBefore,
required this.incompatabilities,
required this.enabled,
required this.size,
this.isBaseGame = false,
this.isExpansion = false,
});
static Mod fromDirectory(String path, {bool skipFileCount = false}) {
final logger = Logger.instance;
final stopwatch = Stopwatch()..start();
logger.info('Attempting to load mod from directory: $path');
final aboutFile = File('$path/About/About.xml');
if (!aboutFile.existsSync()) {
logger.error('About.xml file does not exist in $aboutFile');
throw Exception('About.xml file does not exist in $aboutFile');
}
logger.info('Parsing About.xml file...');
final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync());
final xmlTime = stopwatch.elapsedMilliseconds;
late final XmlElement metadata;
try {
metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData');
logger.info('Successfully found ModMetaData in About.xml');
} catch (e) {
logger.error(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
);
throw Exception(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
);
}
late final String name;
try {
name = metadata.findElements('name').first.innerText;
logger.info('Mod name found: $name');
} catch (e) {
logger.error(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
);
throw Exception(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
);
}
late final String id;
try {
id = metadata.findElements('packageId').first.innerText.toLowerCase();
logger.info('Mod ID found: $id');
} catch (e) {
logger.error(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
);
throw Exception(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
);
}
late final List<String> versions;
try {
versions =
metadata
.findElements('supportedVersions')
.first
.findElements('li')
.map((e) => e.innerText)
.toList();
logger.info('Supported versions found: ${versions.join(", ")}');
} catch (e) {
logger.error(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
);
throw Exception(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
);
}
String description = '';
try {
description = metadata.findElements('description').first.innerText;
logger.info('Mod description found: $description');
} catch (e) {
logger.warning(
'Description element is missing in ModMetaData ($aboutFile).',
);
}
List<String> hardDependencies = [];
try {
hardDependencies =
metadata
.findElements('modDependenciesByVersion')
.first
.children
.whereType<XmlElement>()
.last
.findElements('li')
.map(
(e) =>
e.findElements("packageId").first.innerText.toLowerCase(),
)
.toList();
logger.info('Hard dependencies found: ${hardDependencies.join(", ")}');
} catch (e) {
logger.warning(
'Hard dependencies element is missing in ModMetaData ($aboutFile).',
);
}
List<String> loadAfter = [];
try {
loadAfter =
metadata
.findElements('loadAfter')
.first
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Load after dependencies found: ${loadAfter.join(", ")}');
} catch (e) {
logger.warning(
'Load after element is missing in ModMetaData ($aboutFile).',
);
}
List<String> loadBefore = [];
try {
loadBefore =
metadata
.findElements('loadBefore')
.first
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Load before dependencies found: ${loadBefore.join(", ")}');
} catch (e) {
logger.warning(
'Load before element is missing in ModMetaData ($aboutFile).',
);
}
List<String> incompatabilities = [];
try {
incompatabilities =
metadata
.findElements('incompatibleWith')
.first
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
logger.info('Incompatibilities found: ${incompatabilities.join(", ")}');
} catch (e) {
logger.warning(
'Incompatibilities element is missing in ModMetaData ($aboutFile).',
);
}
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;
logger.info('File count in mod directory: $size');
}
// Check if this is RimWorld base game or expansion
bool isBaseGame = id == 'ludeon.rimworld';
bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.');
// If this is an expansion, ensure it depends on the base game
if (isExpansion && !loadAfter.contains('ludeon.rimworld')) {
loadAfter.add('ludeon.rimworld');
logger.info(
'Added base game dependency for expansion mod: ludeon.rimworld',
);
}
final fileCountTime =
stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
final totalTime = stopwatch.elapsedMilliseconds;
// Log detailed timing information
logger.info(
'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
);
return Mod(
name: name,
id: id,
path: path,
versions: versions,
description: description,
hardDependencies: hardDependencies,
loadAfter: loadAfter,
loadBefore: loadBefore,
incompatabilities: incompatabilities,
enabled: false,
size: size,
isBaseGame: isBaseGame,
isExpansion: isExpansion,
);
}
}
class ModList {
final String path;
Map<String, Mod> mods = {};
bool modsLoaded = false;
String loadingStatus = '';
int totalModsFound = 0;
int loadedModsCount = 0;
ModList({required this.path});
// Simplified loading with config file first
Future<void> 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<FileSystemEntity> entities = directory.listSync();
final List<String> modDirectories =
entities.whereType<Directory>().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 (${stopwatch.elapsedMilliseconds}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<String, Set<String>> buildDependencyGraph() {
// Graph where graph[A] contains B if A depends on B (B must load before A)
final Map<String, Set<String>> graph = {};
// Initialize the graph with empty dependency sets for all mods
for (final mod in mods.values) {
graph[mod.id] = Set<String>();
}
// 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<String, Set<String>> buildSoftDependencyGraph() {
final Map<String, Set<String>> graph = {};
// Initialize the graph with empty sets
for (final mod in mods.values) {
graph[mod.id] = Set<String>();
}
// 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<String>? detectCycle(Map<String, Set<String>> graph) {
// Track visited nodes and the current path
Set<String> visited = {};
Set<String> currentPath = {};
List<String> cycleNodes = [];
bool dfs(String node, List<String> 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<String> topologicalSort(Map<String, Set<String>> graph) {
// Create a copy of the graph to work with
final Map<String, Set<String>> graphCopy = {};
for (final entry in graph.entries) {
graphCopy[entry.key] = Set<String>.from(entry.value);
}
// Calculate in-degree of each node (number of edges coming in)
Map<String, int> 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<List<String>> layers = [];
// Process until all nodes are assigned to layers
while (inDegree.isNotEmpty) {
// Find all nodes with in-degree 0 in this iteration
List<String> 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<String> 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<String> adjustForSoftDependencies(
List<String> hardOrder,
Map<String, Set<String>> softGraph,
) {
// Create a map of positions in the hard dependency order
Map<String, int> 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<List<String>> findIncompatibilities() {
List<List<String>> 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<String> 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<Mod> 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<Mod> mods;
ConfigFile({required this.path, this.mods = const []});
Future<void> 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()
: <String>[];
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<Mod> orderedMods = [];
Set<String> 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.",
);
}
}