import 'dart:async'; import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:gba_core/gba_core.dart'; /// What the UI isolate hands the emulation isolate. final class WorkerConfig { const WorkerConfig({ required this.toUi, required this.romPath, required this.savePath, this.biosPath, }); final SendPort toUi; final String romPath; /// Where the game's save is loaded from and stored. final String savePath; /// A user-supplied BIOS image; the built-in stand-in is used without it. final String? biosPath; } // Messages from the UI isolate. typedef KeysCommand = ({int keys}); enum WorkerCommand { pause, resume, stop } // Messages to the UI isolate. typedef ReadyMessage = ({ SendPort commands, String title, SaveType saveType, bool usesHleBios, }); typedef FrameMessage = ({TransferableTypedData pixels, double fps}); typedef ErrorMessage = ({String error}); /// Sent once the emulation isolate has stored the save and finished. const String stoppedMessage = 'stopped'; /// Entry point of the emulation isolate: loads the game, then runs it in /// real time, sending each finished frame to the UI and storing the save /// when the game changes it. Future emulatorWorkerMain(WorkerConfig config) async { final toUi = config.toUi; final Gba gba; try { final romBytes = await File(config.romPath).readAsBytes(); final cartridge = Cartridge.fromBytes( romBytes, fallbackTitle: _baseName(config.romPath), ); final biosPath = config.biosPath; final bios = biosPath == null ? null : await File(biosPath).readAsBytes(); final saveFile = File(config.savePath); final saveData = await saveFile.exists() ? await saveFile.readAsBytes() : null; gba = Gba(cartridge, bios: bios, saveData: saveData); } on InvalidRomException catch (error) { toUi ..send((error: error.message)) ..send(stoppedMessage); return; } on Object catch (error) { toUi ..send((error: 'Could not start the game: $error')) ..send(stoppedMessage); return; } final worker = _Worker(gba, toUi, config.savePath); await worker.run(); } String _baseName(String path) { final name = path.split(RegExp(r'[/\\]')).last; final dot = name.lastIndexOf('.'); return dot > 0 ? name.substring(0, dot) : name; } final class _Worker { _Worker(this.gba, this.toUi, this.savePath); final Gba gba; final SendPort toUi; final String savePath; final ReceivePort _commands = ReceivePort(); int _keys = 0; bool _paused = false; bool _running = true; Completer? _wake; // How long a save may stay unwritten after the game changes it. static const Duration _saveDelay = Duration(seconds: 1); Stopwatch? _saveTimer; Future run() async { _commands.listen(_onCommand); toUi.send(( commands: _commands.sendPort, title: gba.cartridge.displayTitle, saveType: gba.cartridge.saveType, usesHleBios: gba.usesHleBios, )); final frameMicros = 1e6 / Gba.framesPerSecond; final clock = Stopwatch()..start(); var nextFrame = 0.0; var fps = 0.0; var fpsFrames = 0; var fpsStart = 0; try { while (_running) { if (_paused) { await _storeSave(); final wake = _wake = Completer(); await wake.future; _wake = null; nextFrame = clock.elapsedMicroseconds.toDouble(); continue; } gba ..setKeys(_keys) ..runFrame(); final frame = gba.frame; toUi.send(( pixels: TransferableTypedData.fromList([ frame.buffer.asUint8List(frame.offsetInBytes, frame.lengthInBytes), ]), fps: fps, )); final now = clock.elapsedMicroseconds; if (++fpsFrames == 30) { fps = fpsFrames * 1e6 / (now - fpsStart); fpsFrames = 0; fpsStart = now; } await _maybeStoreSave(); // Pace to real time. If far behind (a slow device, or the app was // in the background), start counting afresh rather than rushing. nextFrame += frameMicros; final wait = nextFrame - clock.elapsedMicroseconds; if (wait > 0) { await Future.delayed(Duration(microseconds: wait.round())); } else { await Future.delayed(Duration.zero); // Let commands in. if (-wait > frameMicros * 4) { nextFrame = clock.elapsedMicroseconds.toDouble(); } } } } on Object catch (error, stack) { toUi.send((error: 'The emulator stopped unexpectedly: $error\n$stack')); } await _storeSave(); _commands.close(); toUi.send(stoppedMessage); } void _onCommand(Object? message) { switch (message) { case KeysCommand(:final keys): _keys = keys; case WorkerCommand.pause: _paused = true; case WorkerCommand.resume: _paused = false; _wake?.complete(); case WorkerCommand.stop: _running = false; _paused = false; _wake?.complete(); } } /// Stores the save [_saveDelay] after the game first changes it, so a /// burst of writes is stored once. Future _maybeStoreSave() async { final save = gba.save; if (save == null || !save.dirty) return; final timer = _saveTimer ??= Stopwatch()..start(); if (timer.elapsed >= _saveDelay) await _storeSave(); } /// Writes the save if it changed, through a temporary file so that a /// crash mid-write cannot corrupt the stored one. Future _storeSave() async { final save = gba.save; if (save == null || !save.dirty) return; save.dirty = false; _saveTimer = null; final data = Uint8List.fromList(save.data); final temporary = File('$savePath.tmp'); await temporary.parent.create(recursive: true); await temporary.writeAsBytes(data, flush: true); await temporary.rename(savePath); } }