Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions core/bin/openrom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ ArgParser buildParser() {
'rename-roms',
help: 'rename ROMs in a folder to canonical DAT names',
)
// Plugin Management
..addOption('install-plugin', help: 'install a plugin from a .orplug file')
..addFlag('list-plugins',
negatable: false, help: 'list all installed plugins')
..addOption('remove-plugin', help: 'uninstall a plugin by ID')
// Dreamcast DCP patch
..addOption(
'dcp',
Expand Down Expand Up @@ -191,6 +196,9 @@ Future<int> main(List<String> args) async {
return 0;
}

// Auto-load installed plugins
await PluginManager.instance.loadInstalledPlugins();

final parser = buildParser();
final ArgResults results;
try {
Expand All @@ -212,6 +220,57 @@ Future<int> main(List<String> args) async {

final isJson = results['json'] == true;

if (results['install-plugin'] != null) {
final pluginPath = results['install-plugin'] as String;
final ok = await PluginManager.instance.installPlugin(pluginPath);
if (isJson) {
_jsonPrint({'type': 'done', 'success': ok});
} else {
print(ok
? '✅ Plugin installed successfully.'
: '❌ Failed to install plugin.');
}
return ok ? 0 : 1;
}

if (results['list-plugins'] == true) {
final plugins = PluginManager.instance.installedPlugins;
if (isJson) {
_jsonPrint({
'type': 'done',
'success': true,
'plugins': plugins.map((p) => p.toJson()).toList(),
});
} else {
if (plugins.isEmpty) {
print('No plugins installed.');
} else {
print('Installed Plugins:');
for (final plug in plugins) {
print(
' - ${plug.name} (${plug.id}) v${plug.version} by ${plug.author}');
if (plug.description.isNotEmpty) {
print(' ${plug.description}');
}
}
}
}
return 0;
}

if (results['remove-plugin'] != null) {
final pluginId = results['remove-plugin'] as String;
final ok = await PluginManager.instance.uninstallPlugin(pluginId);
if (isJson) {
_jsonPrint({'type': 'done', 'success': ok});
} else {
print(ok
? '✅ Plugin "$pluginId" removed.'
: '❌ Failed to remove plugin "$pluginId".');
}
return ok ? 0 : 1;
}

if (results['tool-path'] != null) {
final pStr = getToolPath(results['tool-path'] as String);
if (isJson) {
Expand Down
3 changes: 3 additions & 0 deletions core/lib/openrom_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export 'src/logger.dart';
export 'src/online_prefs.dart';
export 'src/version_reader.dart';
export 'src/one_g_one_r.dart';
export 'src/plugin/plugin_manifest.dart';
export 'src/plugin/plugin_registry.dart';
export 'src/plugin/plugin_manager.dart';
71 changes: 59 additions & 12 deletions core/lib/src/converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import 'config.dart' as config;
import 'cue_generator.dart';
import 'detector.dart' as detector;
import 'logger.dart' as logger;
import 'plugin/plugin_manager.dart';
import 'plugin/plugin_registry.dart';
import 'validator.dart';

const List<String> cdPlatforms = [
Expand Down Expand Up @@ -179,7 +181,7 @@ class Converter {
} catch (e) {
job.error = e.toString();
job.status = 'Failed';
_log('[ERROR] ');
_log('[ERROR] $e');
return false;
} finally {
_cleanup(job);
Expand All @@ -206,15 +208,21 @@ class Converter {
final fmt = info['format'] as String? ?? 'UNKNOWN';
final tgt = job.targetFormat.toUpperCase();

// Check for plugin-registered conversion route first
final pluginConv = PluginRegistry.instance.getConversion(fmt, tgt);
if (pluginConv != null) {
return await _pluginConvert(job, src, pluginConv);
}

if (fmt == 'ECM') {
return await _fromEcm(job, src, tgt);
}

final valid = detector.getValidTargets(fmt);
if (!valid.contains(tgt) && tgt != 'BIN/CUE' && tgt != 'FILES') {
final err = 'Cannot convert → . '
'Supported targets for : ${valid.isNotEmpty ? valid.join(", ") : "none"}';
_log('[ERROR] ');
final err = 'Cannot convert $fmt → $tgt. '
'Supported targets for $fmt: ${valid.isNotEmpty ? valid.join(", ") : "none"}';
_log('[ERROR] $err');
job.error = err;
return false;
}
Expand Down Expand Up @@ -265,12 +273,51 @@ class Converter {
return await _xboxConvert(job, src, fmt);
}

final err = 'Unhandled conversion route: → ';
_log('[ERROR] ');
final err = 'Unhandled conversion route: $fmt → $tgt';
_log('[ERROR] $err');
job.error = err;
return false;
}

Future<bool> _pluginConvert(
ConversionJob job,
String src,
RegisteredConversion conv,
) async {
final pluginsDir = PluginManager.instance.pluginsDir;
final relBin = conv.executableRelPath ?? '';
final absBin =
relBin.isNotEmpty ? p.join(pluginsDir, conv.pluginId, relBin) : '';

if (relBin.isNotEmpty && !File(absBin).existsSync()) {
final err =
'Plugin executable not found for plugin "${conv.pluginId}": $absBin';
job.error = err;
_log(' ❌ $err');
return false;
}

final outName =
detector.getOutputName(p.basename(src), conv.to.toLowerCase());
final outPath = p.join(job.outputDir, outName);

final idx = src.lastIndexOf('.');
final base = idx != -1 ? src.substring(0, idx) : src;
final cueName = '$base.cue';

final cmdLine = conv.commandTemplate
.replaceAll('{bin}', absBin)
.replaceAll('{in}', src)
.replaceAll('{out}', outPath)
.replaceAll('{cue}', cueName)
.replaceAll('{out_cue}', cueName);

final cmdArgs = splitCommandArgs(cmdLine);
_log(
'[PLUGIN ${conv.pluginId}] ${p.basename(src)} → ${p.basename(outPath)}');
return await _run(cmdArgs, job);
}

Future<bool> _toChd(
ConversionJob job,
String src,
Expand Down Expand Up @@ -306,7 +353,7 @@ class Converter {
} else {
final err =
"Unsupported or ambiguous media type for platform '$platform'. Please specify --media cd or --media dvd.";
_log('[ERROR] ');
_log('[ERROR] $err');
job.error = err;
return false;
}
Expand Down Expand Up @@ -399,7 +446,7 @@ class Converter {
}
}

_log('[CHD EXTRACT] ${p.basename(src)} → ');
_log('[CHD EXTRACT] ${p.basename(src)} → ${p.basename(cmd.last)}');
return await _run(cmd, job);
}

Expand Down Expand Up @@ -452,7 +499,7 @@ class Converter {
File(out).renameSync(newOut);
_log('[UNECM AUTO-DETECT] Renamed output to: ${p.basename(newOut)}');
} catch (e) {
_log('[WARN] Failed to rename to : ');
_log('[WARN] Failed to rename $out to $newOut: $e');
}
}
}
Expand All @@ -472,7 +519,7 @@ class Converter {
final outDir = p.join(job.outputDir, '${base}_extracted');
Directory(outDir).createSync(recursive: true);
final cmd = [xiso, '-x', src, '-d', outDir];
_log('[XISO→FILES] Extracting ${p.basename(src)} → /');
_log('[XISO→FILES] Extracting ${p.basename(src)} → $outDir');
_log(
'[XISO→FILES] Output is a folder of extracted files, not a single ISO.',
);
Expand Down Expand Up @@ -530,7 +577,7 @@ class Converter {
'Tool not found: "$toolName" is missing from the assets folder. '
'Please reinstall OpenROM or set the tool path manually in Settings.';
job.error = err;
_log(' ❌ ');
_log(' ❌ $err');
return false;
}

Expand All @@ -551,7 +598,7 @@ class Converter {
final trimmed = line.trimRight();
if (trimmed.isNotEmpty) {
lastLine = trimmed;
_log(' ');
_log(' $trimmed');
if (isStderr) {
if (stderrBuffer.isNotEmpty) stderrBuffer.writeln();
stderrBuffer.write(trimmed);
Expand Down
86 changes: 69 additions & 17 deletions core/lib/src/detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import 'dart:typed_data';
import 'package:path/path.dart' as p;

import 'config.dart' as config;
import 'plugin/plugin_registry.dart';

const Map<String, String> supportedInput = {
const Map<String, String> builtinSupportedInput = {
'.iso': 'ISO',
'.bin': 'BIN',
'.cue': 'CUE',
Expand All @@ -31,7 +32,7 @@ const Map<String, String> supportedInput = {
'.zar': 'ZAR',
};

const Map<String, String> platformMap = {
const Map<String, String> builtinPlatformMap = {
'ISO': 'ISO Image',
'BIN': 'CD Image',
'CUE': 'CD Cue Sheet',
Expand Down Expand Up @@ -59,7 +60,7 @@ const Map<String, String> platformMap = {
'Neo Geo CD': 'Neo Geo CD',
};

const Map<String, String> formatColors = {
const Map<String, String> builtinFormatColors = {
'ISO': '#e94560',
'BIN': '#f9a825',
'CUE': '#f9a825',
Expand Down Expand Up @@ -88,7 +89,7 @@ const Map<String, String> formatColors = {
'Neo Geo CD': '#b71c1c',
};

const Map<String, List<String>> conversionMap = {
const Map<String, List<String>> builtinConversionMap = {
'ISO': ['CHD', 'CSO', 'ECM', 'XISO', 'RVZ', 'NKIT'],
'BIN': ['CHD', 'ECM'],
'CUE': ['CHD'],
Expand All @@ -112,7 +113,7 @@ const Map<String, List<String>> conversionMap = {
'ZAR': ['ISO'],
};

const Map<String, String> commandTemplates = {
const Map<String, String> builtinCommandTemplates = {
'ISO->CHD': 'chdman createdvd -i "{in}" -o "{out}"',
'ISO->CSO': 'maxcso "{in}" -o "{out}"',
'ISO->ECM': 'ecm "{in}" "{out}"',
Expand Down Expand Up @@ -148,6 +149,50 @@ const Map<String, String> commandTemplates = {
'ZAR->ISO': 'xgdtool --xiso "{in}" "{outdir}"',
};

// Merged dynamic getters
Map<String, String> get supportedInput {
final map = Map<String, String>.from(builtinSupportedInput);
map.addAll(PluginRegistry.instance.supportedInput);
return map;
}

Map<String, String> get platformMap {
final map = Map<String, String>.from(builtinPlatformMap);
map.addAll(PluginRegistry.instance.platformMap);
return map;
}

Map<String, String> get formatColors {
final map = Map<String, String>.from(builtinFormatColors);
map.addAll(PluginRegistry.instance.formatColors);
return map;
}

Map<String, List<String>> get conversionMap {
final map = Map<String, List<String>>.from(
builtinConversionMap.map((k, v) => MapEntry(k, List<String>.from(v))),
);
final pluginConvs = PluginRegistry.instance.conversionMap;
pluginConvs.forEach((from, targets) {
final list = map.putIfAbsent(from, () => []);
for (final t in targets) {
if (!list.contains(t)) list.add(t);
}
});
return map;
}

Map<String, String> get commandTemplates {
final map = Map<String, String>.from(builtinCommandTemplates);
for (final plugin in PluginRegistry.instance.activePlugins) {
for (final conv in plugin.conversions) {
final routeKey = '${conv.from.toUpperCase()}->${conv.to.toUpperCase()}';
map[routeKey] = conv.command;
}
}
return map;
}

// Magic bytes
final Uint8List chdMagic = Uint8List.fromList([
0x4D,
Expand Down Expand Up @@ -181,18 +226,29 @@ const int xboxOffset2 = 0x2090000;

String getExtension(String filename) {
final nameLower = filename.toLowerCase();
if (nameLower.endsWith('.nkit.iso')) {
return 'nkit.iso';

// Check known longer extensions first (both built-in and plugin registered)
final allKnownExts = supportedInput.keys.toList()
..sort((a, b) => b.length.compareTo(a.length));

for (final extKey in allKnownExts) {
if (nameLower.endsWith(extKey)) {
return extKey.startsWith('.') ? extKey.substring(1) : extKey;
}
}

final parts = nameLower.split('.');
return parts.length > 1 ? parts.last : '';
}

String getOutputName(String inputFile, String newExt) {
final cleanExt = newExt.startsWith('.') ? newExt.substring(1) : newExt;
final String base;
if (inputFile.toLowerCase().endsWith('.nkit.iso')) {
base = inputFile.substring(0, inputFile.length - 9);
final currentExt = getExtension(inputFile);
final extWithDot = '.$currentExt';

if (inputFile.toLowerCase().endsWith(extWithDot.toLowerCase())) {
base = inputFile.substring(0, inputFile.length - extWithDot.length);
} else {
final idx = inputFile.lastIndexOf('.');
base = idx != -1 ? inputFile.substring(0, idx) : inputFile;
Expand Down Expand Up @@ -243,13 +299,8 @@ Map<String, dynamic> detectFile(String filepath) {
}

final name = p.basename(filepath);
final nameLower = name.toLowerCase();
final String ext;
if (nameLower.endsWith('.nkit.iso')) {
ext = '.nkit.iso';
} else {
ext = p.extension(nameLower);
}
final rawExt = getExtension(name);
final ext = rawExt.startsWith('.') ? rawExt : '.$rawExt';

final size = file.lengthSync();
final fmt = supportedInput[ext] ?? 'UNKNOWN';
Expand Down Expand Up @@ -290,7 +341,8 @@ List<Map<String, dynamic>> detectFolder(String folderPath) {
for (final entry in entries) {
if (entry is File) {
final fname = p.basename(entry.path);
final ext = '.${getExtension(fname)}';
final rawExt = getExtension(fname);
final ext = rawExt.startsWith('.') ? rawExt : '.$rawExt';
if (supportedInput.containsKey(ext)) {
final info = detectFile(entry.path);
info['filepath'] = entry.path;
Expand Down
Loading
Loading