Files
flutter-rimworld-modman/lib/mod_list.dart
PhatPhuckDave 2e6bfb84de Remove all log statements
Because THEY were causing the lag?????????
2025-03-22 00:14:14 +01:00

798 lines
24 KiB
Dart

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<Mod> order = [];
final List<String> errors = [];
List<String> get loadOrder {
return order.map((mod) => mod.id).toList();
}
LoadOrder([List<Mod>? order]) {
this.order = order ?? [];
}
bool get hasErrors => errors.isNotEmpty;
}
var specialMods = {
'ludeon.rimworld': Mod(
id: 'ludeon.rimworld',
name: 'RimWorld',
path: '',
versions: [],
description: 'RimWorld base game',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
isBaseGame: true,
size: 0,
isExpansion: false,
enabled: true,
),
'ludeon.rimworld.royalty': Mod(
id: 'ludeon.rimworld.royalty',
name: 'Royalty',
path: '',
versions: [],
description: 'RimWorld expansion - Royalty',
dependencies: ['ludeon.rimworld'],
loadAfter: [],
loadBefore: [
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.ideology': Mod(
id: 'ludeon.rimworld.ideology',
name: 'Ideology',
path: '',
versions: [],
description: 'RimWorld expansion - Ideology',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly', 'ludeon.rimworld.biotech'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.biotech': Mod(
id: 'ludeon.rimworld.biotech',
name: 'Biotech',
path: '',
versions: [],
description: 'RimWorld expansion - Biotech',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.ideology', 'ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.anomaly': Mod(
id: 'ludeon.rimworld.anomaly',
name: 'Anomaly',
path: '',
versions: [],
description: 'RimWorld expansion - Anomaly',
dependencies: ['ludeon.rimworld'],
loadAfter: [
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
],
loadBefore: [],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
};
class ModList {
String configPath = '';
String modsPath = '';
// O(1) lookup
Map<String, Mod> activeMods = {};
Map<String, Mod> mods = {};
ModList({this.configPath = '', this.modsPath = ''});
ModList copyWith({
String? configPath,
String? modsPath,
Map<String, Mod>? mods,
Map<String, bool>? 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<Mod> loadAvailable({bool skipExistingSizes = false}) 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<FileSystemEntity> entities = directory.listSync();
final List<String> modDirectories =
entities.whereType<Directory>().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, skipFileCount: skipExistingSizes);
// 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<Mod> 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()
: <String>[];
logger.info('Found ${knownExpansionIds.length} known expansions.');
// Clear and recreate the mods list
for (final modElement in modElements) {
final modId = modElement.innerText.toLowerCase();
if (specialMods.containsKey(modId)) {
logger.info('Loading special mod: $modId');
mods[modId] = specialMods[modId]!.copyWith();
setEnabled(modId, true);
logger.info('Enabled special mod: $modId');
yield mods[modId]!;
continue;
}
final existingMod = mods[modId];
final mod = Mod(
name: existingMod?.name ?? modId,
id: existingMod?.id ?? modId,
path: existingMod?.path ?? '',
versions: existingMod?.versions ?? [],
description: existingMod?.description ?? '',
dependencies: existingMod?.dependencies ?? [],
loadAfter: existingMod?.loadAfter ?? [],
loadBefore: existingMod?.loadBefore ?? [],
incompatibilities: existingMod?.incompatibilities ?? [],
enabled: existingMod?.enabled ?? false,
size: existingMod?.size ?? 0,
isBaseGame: false,
isExpansion: false,
);
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 saveToConfig(LoadOrder loadOrder) {
final file = File(configPath);
final logger = Logger.instance;
try {
// Create XML builder
final builder = XmlBuilder();
// Add XML declaration
builder.declaration(encoding: 'utf-8');
// Add root element
builder.element(
'ModsConfigData',
nest: () {
// Add version element
builder.element('version', nest: '1.5.4297 rev994');
// Add active mods element
builder.element(
'activeMods',
nest: () {
// Add each mod as a list item
for (final mod in loadOrder.order) {
builder.element('li', nest: mod.id);
logger.info('Adding mod to config: ${mod.name} (${mod.id})');
}
},
);
// Add known expansions element
final expansions = mods.values.where((m) => m.isExpansion).toList();
if (expansions.isNotEmpty) {
builder.element(
'knownExpansions',
nest: () {
for (final mod in expansions) {
builder.element('li', nest: mod.id);
logger.info(
'Adding expansion to config: ${mod.name} (${mod.id})',
);
}
},
);
}
},
);
// Build the XML document
final xmlDocument = builder.buildDocument();
// Convert to string with 2-space indentation
final prettyXml = xmlDocument.toXmlString(
pretty: true,
indent: ' ', // 2 spaces
newLine: '\n',
);
// Write the formatted XML document to file
file.writeAsStringSync(prettyXml);
logger.info('Successfully saved mod configuration to: $configPath');
} catch (e) {
logger.error('Error saving configuration file: $e');
throw Exception('Failed to save 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<String> modIds) {
for (final modId in modIds) {
setEnabled(modId, true);
}
}
void disableMods(List<String> 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}');
if (specialMods.containsKey(mod.id)) {
logger.info('Special mod: ${mod.id}');
// Replace our fake base game mod with the chad one
// This is a bit of a hack, but it works
activeMods[mod.id] = specialMods[mod.id]!.copyWith();
mods[mod.id] = specialMods[mod.id]!.copyWith();
}
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 = <String, Set<String>>{};
final inDegree = <String, int>{};
// Step 1: Initialize graph and inDegree
for (final mod in loadOrder.order) {
graph[mod.id] = <String>{};
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 = <Mod, int>{};
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<Mod>((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 = <Mod>[];
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<String, List<Mod>> relations, [
Map<String, bool>? seen,
]) {
final logger = Logger.instance;
logger.info('Starting shuffleMod for mod: ${mod.id}');
// Prevent infinite loops
seen ??= <String, bool>{};
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<List<String>> checkIncompatibilities(List<String> modIds) {
final incompatibilities = <List<String>>[];
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<String>? toEnable,
Map<String, bool>? seen,
List<String>? cyclePath,
]) {
final mod = mods[modId]!;
loadOrder ??= LoadOrder();
toEnable ??= <String>[];
seen ??= <String, bool>{};
cyclePath ??= <String>[];
// 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<String> 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 = <String>[];
final logger = Logger.instance;
// First, identify all base game and expansion mods
final baseGameIds = <String>{};
final expansionIds = <String>{};
for (final entry in mods.entries) {
if (entry.value.isBaseGame) {
baseGameIds.add(entry.key);
} else if (entry.value.isExpansion) {
expansionIds.add(entry.key);
}
}
logger.info("Base game mods: ${baseGameIds.join(', ')}");
logger.info("Expansion mods: ${expansionIds.join(', ')}");
// Load dependencies for all active mods
for (final modid in activeMods.keys) {
loadDependencies(modid, loadOrder, toEnable);
}
// Enable all required dependencies
for (final modid in toEnable) {
setEnabled(modid, true);
}
// Generate the load order
final newLoadOrder = generateLoadOrder(loadOrder);
// Filter out any error messages related to incompatibilities between base game and expansions
if (newLoadOrder.hasErrors) {
final filteredErrors = <String>[];
for (final error in newLoadOrder.errors) {
// Check if the error is about incompatibility
if (error.contains('Incompatibility detected:')) {
// Extract the mod IDs from the error message
final parts = error.split(' is incompatible with ');
if (parts.length == 2) {
final firstModId = parts[0].replaceAll('Incompatibility detected: ', '');
final secondModId = parts[1];
// Check if either mod is a base game or expansion
final isBaseGameOrExpansion =
baseGameIds.contains(firstModId) || baseGameIds.contains(secondModId) ||
expansionIds.contains(firstModId) || expansionIds.contains(secondModId);
// Only keep the error if it's not between base game/expansions
if (!isBaseGameOrExpansion) {
filteredErrors.add(error);
} else {
logger.info("Ignoring incompatibility between base game or expansion mods: $error");
}
} else {
// If we can't parse the error, keep it
filteredErrors.add(error);
}
} else {
// Keep non-incompatibility errors
filteredErrors.add(error);
}
}
// Replace the errors with the filtered list
newLoadOrder.errors.clear();
newLoadOrder.errors.addAll(filteredErrors);
}
return newLoadOrder;
}
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);
}
}