diff --git a/README.md b/README.md index fbd8205..d7e8f1a 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ Each release includes the **Flutter GUI** (`OpenROM`) and the **headless CLI** ( - **GDI Track Inspector** โ€” Load and inspect GDI disc image track structure โ€” track number, LBA offset, type (Audio/Data), sector size, and filename. ### ๐Ÿ—œ๏ธ Compression & Extraction -- **ZIP and 7Z support** โ€” Compress ROMs or entire folders with fast / normal / ultra presets. +- **ZIP support** โ€” Compress ROMs or entire folders with fast / normal / ultra presets. - **Smart skip** โ€” Already-compressed formats (CHD, CSO, RVZ, 7Zโ€ฆ) are automatically skipped. -- **Batch extraction** โ€” Extract ZIP and 7Z archives with progress tracking. +- **Batch extraction** โ€” Extract ZIP archives with progress tracking. - **Delete source option** โ€” Auto-clean source files after successful compression. ### ๐Ÿงน ROM Header Removal @@ -179,11 +179,11 @@ openrom-core --folder /roms/ --format CHD --compression Max openrom-core --input game.iso --format CHD --verify # Apply a patch -openrom-core --patch game.sfc --patch-file hack.ips - # Apply a Dreamcast DCP patch openrom-core --dcp patch.dcp --disc-dir /disc/ --output /out/ +# Generic ROM patching (IPS, BPS, UPS, etc.) is supported via the OpenROM GUI + # Read IP.BIN fields openrom-core --read-ipbin IP.BIN @@ -196,8 +196,8 @@ openrom-core --write-ipbin IP.BIN --set-title "MY GAME" # Inspect GDI track structure openrom-core --read-gdi disc.gdi -# Compress to 7Z -openrom-core --compress game.iso --format 7z --level ultra +# Compress to ZIP +openrom-core --compress game.iso --format zip --level ultra # Remove ROM header openrom-core --remove-header game.smc diff --git a/core/bin/openrom.dart b/core/bin/openrom.dart index 5fb34aa..2bcd3f9 100644 --- a/core/bin/openrom.dart +++ b/core/bin/openrom.dart @@ -38,12 +38,12 @@ ArgParser buildParser() { ..addOption('convert', help: 'single file to convert (alias for --input)') ..addMultiOption( 'compress', - help: 'file(s) to compress into ZIP/7Z', + help: 'file(s) to compress into ZIP', splitCommas: false, ) ..addMultiOption( 'extract', - help: 'file(s) to extract from ZIP/7Z', + help: 'file(s) to extract from ZIP', splitCommas: false, ) ..addMultiOption( @@ -104,7 +104,7 @@ ArgParser buildParser() { ..addOption( 'format', abbr: 'F', - help: 'output format: CHD, CSO, ECM, ISO, BIN, BIN/CUE, XISO, ZIP, 7Z', + help: 'output format: CHD, CSO, ECM, ISO, BIN, BIN/CUE, XISO, ZIP', ) ..addOption('force-platform', help: 'override automatic platform detection') ..addOption( diff --git a/core/lib/src/compressor.dart b/core/lib/src/compressor.dart index b89b085..5543e86 100644 --- a/core/lib/src/compressor.dart +++ b/core/lib/src/compressor.dart @@ -76,6 +76,10 @@ class Compressor { bool compress(CompressionJob job) { _stopped = false; + if (job.format == '7z' || job.format == '.7z') { + throw UnsupportedError('7z format is currently not supported; use zip'); + } + if (job.format == 'extract') { return extract(job); } @@ -127,6 +131,10 @@ class Compressor { bool extract(CompressionJob job) { _stopped = false; + if (p.extension(job.filepath).toLowerCase() == '.7z') { + throw UnsupportedError('7z format is currently not supported; use zip'); + } + if (!File(job.filepath).existsSync()) { job.status = 'Failed'; job.error = 'Archive file not found: ${job.filepath}'; @@ -186,36 +194,46 @@ class Compressor { bool _extractZip(CompressionJob job) { _log('[UNZIP] Extracting ${p.basename(job.filepath)} โ†’ ${job.outputDir}'); - final bytes = File(job.filepath).readAsBytesSync(); - final archive = ZipDecoder().decodeBytes(bytes); - - final total = archive.length == 0 ? 1 : archive.length; - final basePath = p.normalize(p.canonicalize(job.outputDir)); - - for (int i = 0; i < archive.length; i++) { - if (_stopped) return false; - final file = archive[i]; - final destPath = p.normalize(p.join(basePath, file.name)); + final inputStream = InputFileStream(job.filepath); + try { + final archive = ZipDecoder().decodeBuffer(inputStream); + + final total = archive.length == 0 ? 1 : archive.length; + final basePath = p.normalize(p.canonicalize(job.outputDir)); + + for (int i = 0; i < archive.length; i++) { + if (_stopped) return false; + final file = archive[i]; + final destPath = p.normalize(p.join(basePath, file.name)); + + // Zip-Slip security check + if (!destPath.startsWith(basePath + p.separator) && + destPath != basePath) { + throw FormatException( + 'Malicious archive entry (Zip Slip): ${file.name}', + ); + } - // Zip-Slip security check - if (!destPath.startsWith(basePath + p.separator) && - destPath != basePath) { - throw FormatException( - 'Malicious archive entry (Zip Slip): ${file.name}', - ); - } + if (file.isFile) { + final parentDir = Directory(p.dirname(destPath)); + if (!parentDir.existsSync()) { + parentDir.createSync(recursive: true); + } + final outputStream = OutputFileStream(destPath); + try { + file.writeContent(outputStream); + } finally { + outputStream.close(); + } + } else { + Directory(destPath).createSync(recursive: true); + } - if (file.isFile) { - final data = file.content as List; - File(destPath) - ..createSync(recursive: true) - ..writeAsBytesSync(data); - } else { - Directory(destPath).createSync(recursive: true); + final pct = ((i + 1) / total) * 100.0; + _updateProgress(job, pct); } - - final pct = ((i + 1) / total) * 100.0; - _updateProgress(job, pct); + } finally { + inputStream.close(); } return true; diff --git a/core/lib/src/one_g_one_r.dart b/core/lib/src/one_g_one_r.dart index c8eee25..4af7052 100644 --- a/core/lib/src/one_g_one_r.dart +++ b/core/lib/src/one_g_one_r.dart @@ -52,13 +52,67 @@ class OneGOneRSummary { }; } + +const Set _companionExtensions = { + '.cue', + '.m3u', + '.m3u8', + '.sav', + '.srm', + '.cht', + '.txt', + '.nfo', + '.jpg', + '.png', + '.jpeg', + '.xml', + '.dat', + '.bak', + '.ini', +}; + +bool isCompanionFile(String filepath) { + final fname = p.basename(filepath).toLowerCase(); + final ext = p.extension(fname); + + if (_companionExtensions.contains(ext)) { + return true; + } + + // Save states (e.g. .state, .state1, .st0, .s01) + if (ext == '.state' || + RegExp(r'^\.(?:st|state|s)\d+$', caseSensitive: false).hasMatch(ext)) { + return true; + } + + // Multi-track binary files (e.g. Track 02.bin, Track 2.bin) + if (ext == '.bin' && + RegExp(r'[\s\(\._-]track[\s\._-]*\d+', caseSensitive: false) + .hasMatch(fname)) { + return true; + } + + return false; +} + String extractCleanTitle(String filename) { // Remove file extension String title = p.basenameWithoutExtension(filename); - // Remove parenthesized ( ... ) and bracketed [ ... ] tags - title = title.replaceAll(RegExp(r'[\(\[][^\)\]]*[\)\]]'), ''); - // Trim extra spaces - return title.trim(); + + final discRegExp = RegExp( + r'^[\(\[]\s*(?:disc|cd|disk|tape|side)[\s\._-]*[a-z0-9\s_-]+\s*[\)\]]$', + caseSensitive: false, + ); + + title = title.replaceAllMapped(RegExp(r'[\(\[][^\)\]]*[\)\]]'), (match) { + final tag = match.group(0)!; + if (discRegExp.hasMatch(tag)) { + return tag; + } + return ''; + }); + + return title.replaceAll(RegExp(r'\s+'), ' ').trim(); } int _getRegionRank(String filename, List regionPriority) { @@ -135,6 +189,33 @@ int _getRevisionLevel(String filename) { return 0; } + +int _getPreReleasePenalty(String filename) { + final nameLower = filename.toLowerCase(); + final tags = RegExp( + r'[\(\[][^\)\]]*[\)\]]', + ).allMatches(nameLower).map((m) => m.group(0)!).toList(); + + const preReleaseKeywords = [ + 'beta', + 'proto', + 'prototype', + 'demo', + 'sample', + 'prerelease', + 'pre-release', + ]; + + for (final tag in tags) { + for (final kw in preReleaseKeywords) { + if (tag.contains(kw)) { + return 1000; + } + } + } + return 0; +} + int _getDumpScore(String filename) { int score = 0; if (filename.contains('[!]')) score += 10; @@ -145,7 +226,7 @@ int _getDumpScore(String filename) { OneGOneRSummary filter1G1R({ required String folderPath, List regionPriority = const ['USA', 'Europe', 'Japan'], - bool dryRun = false, + bool dryRun = true, bool moveDuplicates = true, }) { final dir = Directory(folderPath); @@ -157,6 +238,7 @@ OneGOneRSummary filter1G1R({ .listSync(recursive: false) .whereType() .where((f) => !p.basename(f.path).startsWith('.')) + .where((f) => !isCompanionFile(f.path)) .toList(); final Map> clusters = {}; @@ -182,6 +264,11 @@ OneGOneRSummary filter1G1R({ final fnameA = p.basename(a.path); final fnameB = p.basename(b.path); + // 0. Pre-release penalty (lower penalty is better) + final preA = _getPreReleasePenalty(fnameA); + final preB = _getPreReleasePenalty(fnameB); + if (preA != preB) return preA.compareTo(preB); + // 1. Region rank (lower index is better) final rankA = _getRegionRank(fnameA, regionPriority); final rankB = _getRegionRank(fnameB, regionPriority); diff --git a/core/test/compressor_test.dart b/core/test/compressor_test.dart new file mode 100644 index 0000000..2c4b313 --- /dev/null +++ b/core/test/compressor_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:openrom_core/openrom_core.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('Compressor throws UnsupportedError when 7z format is requested', () { + final tmpDir = Directory.systemTemp.createTempSync('compressor_7z_test_'); + try { + final sampleFile = p.join(tmpDir.path, 'sample.rom'); + File(sampleFile).writeAsBytesSync([1, 2, 3, 4, 5]); + + final compressor = Compressor(); + final job = CompressionJob( + filepath: sampleFile, + outputDir: tmpDir.path, + format: '7z', + ); + + expect(() => compressor.compress(job), throwsA(isA())); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('Compressor extracts zip archive using streams', () { + final tmpDir = Directory.systemTemp.createTempSync('compressor_extract_test_'); + try { + final sampleFile = p.join(tmpDir.path, 'test_game.iso'); + final testData = List.generate(1024 * 100, (i) => i % 256); + File(sampleFile).writeAsBytesSync(testData); + + final compressor = Compressor(); + final compressJob = CompressionJob( + filepath: sampleFile, + outputDir: tmpDir.path, + format: 'zip', + ); + + final compressOk = compressor.compress(compressJob); + expect(compressOk, isTrue); + + final zipPath = p.join(tmpDir.path, 'test_game.zip'); + expect(File(zipPath).existsSync(), isTrue); + + final extractDir = p.join(tmpDir.path, 'extracted'); + final extractJob = CompressionJob( + filepath: zipPath, + outputDir: extractDir, + format: 'extract', + ); + + final extractOk = compressor.extract(extractJob); + expect(extractOk, isTrue); + + final extractedFile = p.join(extractDir, 'test_game.iso'); + expect(File(extractedFile).existsSync(), isTrue); + expect(File(extractedFile).lengthSync(), equals(testData.length)); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); +} diff --git a/core/test/one_g_one_r_test.dart b/core/test/one_g_one_r_test.dart index f81ea44..45d0a37 100644 --- a/core/test/one_g_one_r_test.dart +++ b/core/test/one_g_one_r_test.dart @@ -138,4 +138,100 @@ void main() { tmpDir.deleteSync(recursive: true); } }); + + test('1G1R preserves multi-disc sets as distinct clusters', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_disc_test_'); + try { + final disc1 = p.join(tmpDir.path, 'Final Fantasy VII (USA) (Disc 1).chd'); + final disc2 = p.join(tmpDir.path, 'Final Fantasy VII (USA) (Disc 2).chd'); + + File(disc1).writeAsBytesSync([1, 2, 3]); + File(disc2).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA'], + dryRun: true, + ); + + expect(summary.totalFiles, equals(2)); + expect(summary.keptCount, equals(2)); + expect(summary.movedCount, equals(0)); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R prefers final release over pre-release builds (Beta/Proto)', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_beta_test_'); + try { + final finalFile = p.join(tmpDir.path, 'Game (USA).sfc'); + final betaFile = p.join(tmpDir.path, 'Game (USA) (Beta).sfc'); + + File(finalFile).writeAsBytesSync([1, 2, 3]); + File(betaFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA'], + dryRun: true, + ); + + expect(summary.keptCount, equals(1)); + expect(summary.movedCount, equals(1)); + + final winner = summary.results.firstWhere((r) => r.isWinner); + expect(winner.filename, equals('Game (USA).sfc')); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R ignores companion files (.cue, .m3u, .sav)', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_companion_test_'); + try { + final romFile = p.join(tmpDir.path, 'Game (USA).bin'); + final cueFile = p.join(tmpDir.path, 'Game (USA).cue'); + final savFile = p.join(tmpDir.path, 'Game (USA).sav'); + + File(romFile).writeAsBytesSync([1, 2, 3]); + File(cueFile).writeAsStringSync('FILE "Game (USA).bin" BINARY'); + File(savFile).writeAsBytesSync([0, 0, 0]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA'], + dryRun: true, + ); + + expect(summary.totalFiles, equals(1)); + expect(summary.keptCount, equals(1)); + expect(summary.movedCount, equals(0)); + expect(summary.results.first.filename, equals('Game (USA).bin')); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R defaults to dryRun = true', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_default_dry_test_'); + try { + final usaFile = p.join(tmpDir.path, 'Game (USA).sfc'); + final eurFile = p.join(tmpDir.path, 'Game (Europe).sfc'); + + File(usaFile).writeAsBytesSync([1, 2, 3]); + File(eurFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R(folderPath: tmpDir.path); + + expect(summary.movedCount, equals(1)); + expect(File(usaFile).existsSync(), isTrue); + expect(File(eurFile).existsSync(), isTrue); + + final dupDir = Directory(p.join(tmpDir.path, '_duplicates')); + expect(dupDir.existsSync(), isFalse); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); }