import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:gba_core/gba_core.dart'; /// A game in the library. final class GameEntry { const GameEntry({ required this.romPath, required this.fileName, required this.title, required this.gameCode, required this.hasSave, }); final String romPath; final String fileName; final String title; final String gameCode; final bool hasSave; } /// Thrown when a file cannot be added to the library; [message] is for the /// user. final class LibraryException implements Exception { const LibraryException(this.message); final String message; @override String toString() => message; } /// The user's games, their saves and an optional BIOS, kept in the app's /// own storage: /// /// * `roms/` — copies of imported ROM files; /// * `saves/.sav` — each game's save; /// * `bios/gba_bios.bin` — a BIOS the user imported, if any. final class GameLibrary { GameLibrary(Directory root) : _roms = Directory('${root.path}/roms'), _saves = Directory('${root.path}/saves'), _bios = File('${root.path}/bios/gba_bios.bin'); final Directory _roms; final Directory _saves; final File _bios; /// The imported BIOS, or null to use the built-in stand-in. String? get biosPath => _bios.existsSync() ? _bios.path : null; /// Where [game]'s save is stored. String savePathFor(GameEntry game) => '${_saves.path}/${_withoutExtension(game.fileName)}.sav'; /// The games in the library, by title. Future> list() async { if (!await _roms.exists()) return const []; final games = []; await for (final entity in _roms.list()) { if (entity is! File) continue; final entry = await _describe(entity); if (entry != null) games.add(entry); } games.sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); return games; } /// Copies the ROM at [sourcePath] into the library under [fileName], /// after checking that it is a GBA ROM (off the UI isolate: ROMs can be /// 32 MB). Replaces a game with the same file name. Future importRom(String sourcePath, String fileName) async { final problem = await Isolate.run(() { try { Cartridge.fromBytes(File(sourcePath).readAsBytesSync()); return null; } on InvalidRomException catch (error) { return error.message; } on FileSystemException catch (error) { return 'The file could not be read (${error.message}).'; } }); if (problem != null) throw LibraryException(problem); await _roms.create(recursive: true); final safeName = fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); final target = File('${_roms.path}/$safeName'); await File(sourcePath).copy(target.path); return (await _describe(target))!; } /// Removes [game] and, if [deleteSave], its save. Future delete(GameEntry game, {required bool deleteSave}) async { await File(game.romPath).delete(); final save = File(savePathFor(game)); if (deleteSave && await save.exists()) await save.delete(); } /// Uses the BIOS image at [sourcePath] from now on. Future importBios(String sourcePath) async { final length = await File(sourcePath).length(); if (length != GbaMemory.biosSize) { throw LibraryException( 'A GBA BIOS file is exactly ${GbaMemory.biosSize} bytes; this one ' 'is $length bytes.', ); } await _bios.parent.create(recursive: true); await File(sourcePath).copy(_bios.path); } /// Goes back to the built-in BIOS stand-in. Future removeBios() async { if (await _bios.exists()) await _bios.delete(); } /// Reads just the header, so listing stays fast with large ROMs. Future _describe(File file) async { final RandomAccessFile handle; try { handle = await file.open(); } on FileSystemException { return null; } try { final header = Uint8List(RomHeader.size); final read = await handle.readInto(header); if (read < RomHeader.size) return null; final parsed = RomHeader.parse(header); final fileName = file.uri.pathSegments.last; final title = parsed.title.trim(); final entry = GameEntry( romPath: file.path, fileName: fileName, title: title.isEmpty || title.contains(RomHeader.replacementCharacter) ? _withoutExtension(fileName) : title, gameCode: parsed.gameCode.trim(), hasSave: false, ); return GameEntry( romPath: entry.romPath, fileName: entry.fileName, title: entry.title, gameCode: entry.gameCode, hasSave: await File(savePathFor(entry)).exists(), ); } finally { await handle.close(); } } static String _withoutExtension(String name) { final dot = name.lastIndexOf('.'); return dot > 0 ? name.substring(0, dot) : name; } }