635 lines
18 KiB
Dart
635 lines
18 KiB
Dart
import 'dart:io';
|
|
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';
|
|
|
|
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 - this is a li with packageId, displayName, steamWorkshopUrl and downloadUrl
|
|
final List<String> softDependencies; // ModMetaData.loadAfter
|
|
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
|
|
|
|
Mod({
|
|
required this.name,
|
|
required this.id,
|
|
required this.path,
|
|
required this.versions,
|
|
required this.description,
|
|
required this.hardDependencies,
|
|
required this.softDependencies,
|
|
required this.incompatabilities,
|
|
required this.enabled,
|
|
required this.size,
|
|
});
|
|
|
|
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;
|
|
|
|
late final XmlElement metadata;
|
|
try {
|
|
metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData');
|
|
} catch (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;
|
|
} catch (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();
|
|
} catch (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();
|
|
} catch (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;
|
|
} catch (e) {
|
|
// Silent error for optional element
|
|
}
|
|
|
|
List<String> hardDependencies = [];
|
|
try {
|
|
hardDependencies =
|
|
metadata
|
|
.findElements('modDependenciesByVersion')
|
|
.last
|
|
.findElements('li')
|
|
.map(
|
|
(e) =>
|
|
e.findElements('packageId').first.innerText.toLowerCase(),
|
|
)
|
|
.toList();
|
|
} catch (e) {
|
|
// Silent error for optional element
|
|
}
|
|
|
|
List<String> softDependencies = [];
|
|
try {
|
|
softDependencies =
|
|
metadata
|
|
.findElements('loadAfter')
|
|
.first
|
|
.findElements('li')
|
|
.map((e) => e.innerText.toLowerCase())
|
|
.toList();
|
|
} catch (e) {
|
|
// Silent error for optional element
|
|
}
|
|
|
|
List<String> incompatabilities = [];
|
|
try {
|
|
incompatabilities =
|
|
metadata
|
|
.findElements('incompatibleWith')
|
|
.first
|
|
.findElements('li')
|
|
.map((e) => e.innerText.toLowerCase())
|
|
.toList();
|
|
} 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;
|
|
}
|
|
|
|
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',
|
|
);
|
|
|
|
return Mod(
|
|
name: name,
|
|
id: id,
|
|
path: path,
|
|
versions: versions,
|
|
description: description,
|
|
hardDependencies: hardDependencies,
|
|
softDependencies: softDependencies,
|
|
incompatabilities: incompatabilities,
|
|
enabled: false,
|
|
size: size,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ModList {
|
|
final String path;
|
|
Map<String, Mod> mods = {};
|
|
|
|
ModList({required this.path});
|
|
|
|
Stream<Mod> load({bool skipFileCount = false}) async* {
|
|
final stopwatch = Stopwatch()..start();
|
|
final directory = Directory(path);
|
|
print('Loading configuration from: $path');
|
|
|
|
if (directory.existsSync()) {
|
|
final List<FileSystemEntity> entities = directory.listSync();
|
|
final List<String> modDirectories =
|
|
entities.whereType<Directory>().map((dir) => dir.path).toList();
|
|
|
|
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)',
|
|
);
|
|
}
|
|
|
|
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)',
|
|
);
|
|
} else {
|
|
print('Mods root directory does not exist: $path');
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
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<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
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Start with nodes that have no dependencies (in-degree = 0)
|
|
List<String> nodesWithNoDependencies = [];
|
|
for (final node in inDegree.keys) {
|
|
if (inDegree[node] == 0) {
|
|
nodesWithNoDependencies.add(node);
|
|
}
|
|
}
|
|
|
|
// Result will store the topological order
|
|
List<String> 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<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() {
|
|
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<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 []});
|
|
|
|
void load() {
|
|
final file = File(path);
|
|
print('Loading configuration from: $path');
|
|
|
|
final xmlString = file.readAsStringSync();
|
|
print('XML content read successfully.');
|
|
|
|
final xmlDocument = XmlDocument.parse(xmlString);
|
|
print('XML document parsed successfully.');
|
|
|
|
final modConfigData = xmlDocument.findElements("ModsConfigData").first;
|
|
print('Found ModsConfigData element.');
|
|
|
|
final modsElement = modConfigData.findElements("activeMods").first;
|
|
print('Found activeMods element.');
|
|
|
|
final modElements = modsElement.findElements("li");
|
|
print('Found ${modElements.length} active mods.');
|
|
|
|
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<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;
|
|
|
|
print(
|
|
"Load order fixed. ${mods.length} mods are now in dependency-sorted order.",
|
|
);
|
|
}
|
|
}
|