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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions core/bin/openrom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
72 changes: 45 additions & 27 deletions core/lib/src/compressor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}';
Expand Down Expand Up @@ -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<int>;
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;
Expand Down
97 changes: 92 additions & 5 deletions core/lib/src/one_g_one_r.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,67 @@ class OneGOneRSummary {
};
}


const Set<String> _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<String> regionPriority) {
Expand Down Expand Up @@ -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;
Expand All @@ -145,7 +226,7 @@ int _getDumpScore(String filename) {
OneGOneRSummary filter1G1R({
required String folderPath,
List<String> regionPriority = const ['USA', 'Europe', 'Japan'],
bool dryRun = false,
bool dryRun = true,
bool moveDuplicates = true,
}) {
final dir = Directory(folderPath);
Expand All @@ -157,6 +238,7 @@ OneGOneRSummary filter1G1R({
.listSync(recursive: false)
.whereType<File>()
.where((f) => !p.basename(f.path).startsWith('.'))
.where((f) => !isCompanionFile(f.path))
.toList();

final Map<String, List<File>> clusters = {};
Expand All @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions core/test/compressor_test.dart
Original file line number Diff line number Diff line change
@@ -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<UnsupportedError>()));
} 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<int>.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);
}
});
}
Loading
Loading