Refactor mod
This commit is contained in:
259
lib/mod.dart
Normal file
259
lib/mod.dart
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:rimworld_modman/logger.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:rimworld_modman/logger.dart';
|
import 'package:rimworld_modman/logger.dart';
|
||||||
|
import 'package:rimworld_modman/mod.dart';
|
||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
const root = r'C:/Users/Administrator/Seafile/Games-Rimworld';
|
const root = r'C:/Users/Administrator/Seafile/Games-Rimworld';
|
||||||
@@ -9,261 +10,6 @@ const configRoot = '$root/AppData/RimWorld by Ludeon Studios/Config';
|
|||||||
const configPath = '$configRoot/ModsConfig.xml';
|
const configPath = '$configRoot/ModsConfig.xml';
|
||||||
const logsPath = '$root/ModManager';
|
const logsPath = '$root/ModManager';
|
||||||
|
|
||||||
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 {
|
class ModList {
|
||||||
final String path;
|
final String path;
|
||||||
Map<String, Mod> mods = {};
|
Map<String, Mod> mods = {};
|
||||||
|
Reference in New Issue
Block a user