| 1 | import 'dart:async'; |
| 2 | import 'dart:io'; |
| 3 | import 'dart:isolate'; |
| 4 | import 'dart:typed_data'; |
| 5 | |
| 6 | import 'package:gba_core/gba_core.dart'; |
| 7 | |
| 8 | /// What the UI isolate hands the emulation isolate. |
| 9 | final class WorkerConfig { |
| 10 | const WorkerConfig({ |
| 11 | required this.toUi, |
| 12 | required this.romPath, |
| 13 | required this.savePath, |
| 14 | this.biosPath, |
| 15 | }); |
| 16 | |
| 17 | final SendPort toUi; |
| 18 | final String romPath; |
| 19 | |
| 20 | /// Where the game's save is loaded from and stored. |
| 21 | final String savePath; |
| 22 | |
| 23 | /// A user-supplied BIOS image; the built-in stand-in is used without it. |
| 24 | final String? biosPath; |
| 25 | } |
| 26 | |
| 27 | // Messages from the UI isolate. |
| 28 | typedef KeysCommand = ({int |
| 29 | |
| 30 | enum WorkerCommand { pause, resume, stop } |
| 31 | |
| 32 | // Messages to the UI isolate. |
| 33 | typedef ReadyMessage = ({ |
| 34 | SendPort commands, |
| 35 | String title, |
| 36 | SaveType saveType, |
| 37 | bool usesHleBios, |
| 38 | }); |
| 39 | typedef FrameMessage = ({TransferableTypedData |
| 40 | typedef ErrorMessage = ({String |
| 41 | |
| 42 | /// Sent once the emulation isolate has stored the save and finished. |
| 43 | const String stoppedMessage = 'stopped' |
| 44 | |
| 45 | /// Entry point of the emulation isolate: loads the game, then runs it in |
| 46 | /// real time, sending each finished frame to the UI and storing the save |
| 47 | /// when the game changes it. |
| 48 | Future<void> emulatorWorkerMain( |
| 49 | final toUi = config.toUi; |
| 50 | final Gba gba; |
| 51 | try { |
| 52 | final romBytes = await File |
| 53 | final cartridge = Cartridge. |
| 54 | romBytes, |
| 55 | fallbackTitle: _baseName(config.romPath), |
| 56 | ); |
| 57 | final biosPath = config.biosPath; |
| 58 | final bios = biosPath == null |
| 59 | final saveFile = File(config |
| 60 | final saveData = await saveFile. |
| 61 | ? await saveFile.readAsBytes() |
| 62 | : null; |
| 63 | gba = Gba(cartridge, bios: |
| 64 | } on InvalidRomException catch (error) { |
| 65 | toUi |
| 66 | ..send((error: error.message)) |
| 67 | ..send(stoppedMessage); |
| 68 | return; |
| 69 | } on Object catch (error) { |
| 70 | toUi |
| 71 | ..send((error: 'Could not start the game: $ |
| 72 | ..send(stoppedMessage); |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | final worker = _Worker(gba |
| 77 | await worker.run(); |
| 78 | } |
| 79 | |
| 80 | String _baseName(String path) { |
| 81 | final name = path.split |
| 82 | final dot = name.lastIndexOf |
| 83 | return dot > 0 ? |
| 84 | } |
| 85 | |
| 86 | final class _Worker { |
| 87 | _Worker(this.gba, |
| 88 | |
| 89 | final Gba gba; |
| 90 | final SendPort toUi; |
| 91 | final String savePath; |
| 92 | final ReceivePort _commands = |
| 93 | |
| 94 | int _keys = 0; |
| 95 | bool _paused = false; |
| 96 | bool _running = true; |
| 97 | Completer<void>? _wake |
| 98 | |
| 99 | // How long a save may stay unwritten after the game changes it. |
| 100 | static const Duration |
| 101 | Stopwatch? _saveTimer; |
| 102 | |
| 103 | Future<void> run |
| 104 | _commands.listen(_onCommand); |
| 105 | toUi.send(( |
| 106 | commands: _commands.sendPort, |
| 107 | title: gba.cartridge.displayTitle, |
| 108 | saveType: gba.cartridge.saveType, |
| 109 | usesHleBios: gba.usesHleBios, |
| 110 | )); |
| 111 | |
| 112 | final frameMicros = 1e6 / |
| 113 | final clock = Stopwatch() |
| 114 | var nextFrame = 0.0; |
| 115 | var fps = 0.0; |
| 116 | var fpsFrames = 0; |
| 117 | var fpsStart = 0; |
| 118 | |
| 119 | try { |
| 120 | while (_running) { |
| 121 | if (_paused) { |
| 122 | await _storeSave(); |
| 123 | final wake = _wake = Completer |
| 124 | await wake.future; |
| 125 | _wake = null; |
| 126 | nextFrame = clock.elapsedMicroseconds.toDouble() |
| 127 | continue; |
| 128 | } |
| 129 | |
| 130 | gba |
| 131 | ..setKeys(_keys) |
| 132 | ..runFrame(); |
| 133 | final frame = gba.frame; |
| 134 | toUi.send(( |
| 135 | pixels: TransferableTypedData.fromList([ |
| 136 | frame.buffer.asUint8List(frame.offsetInBytes |
| 137 | ]), |
| 138 | fps: fps, |
| 139 | )); |
| 140 | |
| 141 | final now = clock.elapsedMicroseconds; |
| 142 | if (++fpsFrames == 30) { |
| 143 | fps = fpsFrames * 1e6 / (now - |
| 144 | fpsFrames = 0; |
| 145 | fpsStart = now; |
| 146 | } |
| 147 | await _maybeStoreSave(); |
| 148 | |
| 149 | // Pace to real time. If far behind (a slow device, or the app was |
| 150 | // in the background), start counting afresh rather than rushing. |
| 151 | nextFrame += frameMicros; |
| 152 | final wait = nextFrame - clock. |
| 153 | if (wait > 0) { |
| 154 | await Future<void> |
| 155 | } else { |
| 156 | await Future<void> |
| 157 | if (-wait > frameMicros * 4 |
| 158 | nextFrame = clock.elapsedMicroseconds.toDouble() |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | } on Object catch (error, |
| 163 | toUi.send((error: 'The emulator stopped unexpectedly: $ |
| 164 | } |
| 165 | await _storeSave(); |
| 166 | _commands.close(); |
| 167 | toUi.send(stoppedMessage); |
| 168 | } |
| 169 | |
| 170 | void _onCommand(Object |
| 171 | switch (message) { |
| 172 | case KeysCommand(:final |
| 173 | _keys = keys; |
| 174 | case WorkerCommand.pause: |
| 175 | _paused = true; |
| 176 | case WorkerCommand.resume: |
| 177 | _paused = false; |
| 178 | _wake?.complete(); |
| 179 | case WorkerCommand.stop: |
| 180 | _running = false; |
| 181 | _paused = false; |
| 182 | _wake?.complete(); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | /// Stores the save [_saveDelay] after the game first changes it, so a |
| 187 | /// burst of writes is stored once. |
| 188 | Future<void> _maybeStoreSave |
| 189 | final save = gba.save; |
| 190 | if (save == null || ! |
| 191 | final timer = _saveTimer ??= |
| 192 | if (timer.elapsed >= _saveDelay) await _storeSave |
| 193 | } |
| 194 | |
| 195 | /// Writes the save if it changed, through a temporary file so that a |
| 196 | /// crash mid-write cannot corrupt the stored one. |
| 197 | Future<void> _storeSave |
| 198 | final save = gba.save; |
| 199 | if (save == null || ! |
| 200 | save.dirty = false; |
| 201 | _saveTimer = null; |
| 202 | final data = Uint8List. |
| 203 | final temporary = File( |
| 204 | await temporary.parent.create(recursive |
| 205 | await temporary.writeAsBytes(data, flush |
| 206 | await temporary.rename(savePath); |
| 207 | } |
| 208 | } |