| 1 | import 'dart:async'; |
| 2 | import 'dart:isolate'; |
| 3 | import 'dart:ui' as ui; |
| 4 | |
| 5 | import 'package:flutter/foundation.dart'; |
| 6 | import 'package:gba_core/gba_core.dart'; |
| 7 | |
| 8 | import 'emulator_worker.dart'; |
| 9 | |
| 10 | /// A running game, seen from the UI: the emulator itself runs in its own |
| 11 | /// isolate so that the UI stays smooth. |
| 12 | /// |
| 13 | /// Frames arrive as [ui.Image]s in [frame] |
| 14 | /// in between are dropped rather than queued. |
| 15 | final class EmulatorSession { |
| 16 | EmulatorSession._(this._isolate |
| 17 | |
| 18 | final Isolate _isolate; |
| 19 | final ReceivePort _fromWorker; |
| 20 | SendPort? _commands; |
| 21 | |
| 22 | /// The latest frame, 240×160. |
| 23 | final ValueNotifier<ui.Image |
| 24 | |
| 25 | /// Frames per second the emulator is managing (about 59.7 at full speed). |
| 26 | final ValueNotifier<double |
| 27 | |
| 28 | /// The game's title, once it has loaded. |
| 29 | String title = ''; |
| 30 | |
| 31 | /// The game's save type, once it has loaded. |
| 32 | SaveType saveType = SaveType. |
| 33 | |
| 34 | /// Whether the built-in BIOS stand-in is in use. |
| 35 | bool usesHleBios = true; |
| 36 | |
| 37 | /// An error the emulator reported, if any. |
| 38 | final ValueNotifier<String |
| 39 | |
| 40 | final Completer<void |
| 41 | Uint8List? _pendingPixels; |
| 42 | bool _decoding = false; |
| 43 | bool _disposed = false; |
| 44 | |
| 45 | /// Starts the game at [romPath]. Completes once it is running, or throws |
| 46 | /// a [StateError] with a message for the user if it cannot start. |
| 47 | static Future<EmulatorSession |
| 48 | required String romPath, |
| 49 | required String savePath, |
| 50 | String? biosPath, |
| 51 | }) async { |
| 52 | final fromWorker = ReceivePort() |
| 53 | final ready = Completer< |
| 54 | final isolate = await Isolate |
| 55 | emulatorWorkerMain, |
| 56 | WorkerConfig( |
| 57 | toUi: fromWorker.sendPort, |
| 58 | romPath: romPath, |
| 59 | savePath: savePath, |
| 60 | biosPath: biosPath, |
| 61 | ), |
| 62 | debugName: 'emulator', |
| 63 | errorsAreFatal: true, |
| 64 | onExit: fromWorker.sendPort, |
| 65 | ); |
| 66 | final session = EmulatorSession. |
| 67 | fromWorker.listen((message) => session._onMessage |
| 68 | await ready.future; |
| 69 | final startError = session.error. |
| 70 | if (session._commands == null) { |
| 71 | throw StateError(startError ?? 'The emulator stopped while starting.' |
| 72 | } |
| 73 | return session; |
| 74 | } |
| 75 | |
| 76 | /// Sets the held buttons: a mask of [GbaKey] bits. |
| 77 | void setKeys(int |
| 78 | |
| 79 | void pause() => _commands |
| 80 | |
| 81 | void resume() => _commands |
| 82 | |
| 83 | /// Stops the game, waiting until its save has been stored. |
| 84 | Future<void> stop |
| 85 | _commands?.send(WorkerCommand. |
| 86 | await _stopped.future.timeout( |
| 87 | const Duration(seconds: |
| 88 | onTimeout: _isolate.kill, |
| 89 | ); |
| 90 | _dispose(); |
| 91 | } |
| 92 | |
| 93 | void _onMessage(Object |
| 94 | switch (message) { |
| 95 | case ( |
| 96 | commands: final SendPort commands |
| 97 | title: final String title |
| 98 | saveType: final SaveType saveType |
| 99 | usesHleBios: final bool usesHleBios |
| 100 | ): |
| 101 | _commands = commands; |
| 102 | this.title = title; |
| 103 | this.saveType = saveType; |
| 104 | this.usesHleBios = usesHleBios; |
| 105 | if (!ready.isCompleted) ready.complete |
| 106 | case (pixels: final TransferableTypedData |
| 107 | this.fps.value = fps |
| 108 | _showFrame(pixels.materialize(). |
| 109 | case (error: final String |
| 110 | this.error.value = error |
| 111 | case stoppedMessage || null: |
| 112 | // null: the isolate exited (onExit). |
| 113 | if (!ready.isCompleted) ready.complete |
| 114 | if (!_stopped.isCompleted) _stopped.complete |
| 115 | _fromWorker.close(); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | void _showFrame(Uint8List |
| 120 | if (_decoding) { |
| 121 | _pendingPixels = pixels; // Keep only the newest. |
| 122 | return; |
| 123 | } |
| 124 | _decoding = true; |
| 125 | ui.decodeImageFromPixels( |
| 126 | pixels, |
| 127 | Ppu.width, |
| 128 | Ppu.height, |
| 129 | ui.PixelFormat.rgba8888, |
| 130 | (image) { |
| 131 | _decoding = false; |
| 132 | if (_disposed) { |
| 133 | image.dispose(); |
| 134 | return; |
| 135 | } |
| 136 | final previous = frame.value; |
| 137 | frame.value = image; |
| 138 | previous?.dispose(); |
| 139 | final pending = _pendingPixels; |
| 140 | if (pending != null) { |
| 141 | _pendingPixels = null; |
| 142 | _showFrame(pending); |
| 143 | } |
| 144 | }, |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | void _dispose() { |
| 149 | if (_disposed) return; |
| 150 | _disposed = true; |
| 151 | frame.value?.dispose(); |
| 152 | frame.dispose(); |
| 153 | fps.dispose(); |
| 154 | error.dispose(); |
| 155 | } |
| 156 | } |