Flutterを使ったファミコンエミュレータの開発 (HelloWorldまで)
概要
Flutter(Dart)の勉強をしようと考え、どうせなら以前から作りたいと思っていたファミコンエミュレータを書くことにした。 まだROMとしてはHello Worldを表示するテストROMしか動かせてはいないが、60FPSで描画出来るようになったので記事を書いた。
ファミコンエミュレータについて
先人の皆様がファミコンエミュレータを様々な言語や環境に移植しつつ、それに伴う知見をブログなどに残してくださっているので、それらの情報に従ったら基本的に作成できる。私の場合は、下記の記事を一番参考にさせてもらった。
CPU/PPUのそれぞれの仕組みから、HelloWorldを表示するまで必要な内容をわかりやすく説明してくださっている。 まずは記事に従ってROMに含まれるスプライトを表示してみるのが良い。実際に目に見えるものが出てくるとテンションが上がるのもある。
記事の最後にサンプルROMのアセンブラが乗っているので、自分のCPUが吐き出したオペコードと比較して何が間違っているかを確認してすすめることができた。
詳細なCPUの命令ごとの動作については下記を参考にさせてもらった。
レジスタのステータスフラグの意味や、各命令でしなければならないこと、アドレッシングモードの種類やサイクル数など、ここを見れば基本的に問題ないはず。 私の場合はオペコード毎の情報を格納したMAPを作るにあたって、このサイトに加えて、下記の2サイトも参考にした。
6502 Opcodes - NES Hacker Wiki
6502 instructions - Nesdev wiki
ただ、Hello Worldを動かすにあたっては全ての命令とアドレッシングモードを実装する必要はまったくなかったので、下記の命令とアドレッシングモードを実装するところまでで止めている。アドレッシングモード毎にFetchしなければならない数が決まっているのでそこと、Reset割り込みにおけるPCの初期値セットだけを実装し、頭からFetchしていくことで、必要な命令とアドレッシングモードを抜き出すことができた。(最終的にはJMPで無限ループになるので、そこまで抜き出す。)
# 命令 BNE DEY INX JMP LDA LDX LDY SEI STA TXS
# アドレッシングモード Implied Accumulator Immediate Relative Absolute AbsolutePageX
Flutterにおけるエミュレータ
エミュレータ実行スレッドを分ける
エミュレータ自体の実行はそれなりに処理負荷がかかるので別のIsolateで実行するようにした。
ゲーム実行中はずっと動かし続けるので compute
関数を使うのではなく、Isolate.spawn
によってIsolateを立ち上げる。
emulatorReceivePort = ReceivePort(); Isolate.spawn(emulatorIsolateMain, emulatorReceivePort.sendPort);
emulatorIsolateMain
側でも ReceivePort
を生成し、それをParent側に送信することで双方向の通信が出来るようにしている。
エミュレータ実行Isolate側では下記のコードのように常にParentからのイベントを待機しながら、エミュレータの1フレームごとの処理を行っている。
Timer.periodic(Duration(milliseconds: 16), _executeFrame); await for (var message in childReceivePort) { // メッセージが来たら処理を行う }
1フレーム分の処理が完了したら、Isolate作成時に渡された sendPort
を使って、メインIsolateに向けてフレームのピクセル情報を送信する。
画面への描画
エミュレータ側で RGBA
形式の Uint8List
を作成し、それを ui.decodeImageFromPixels
によって ui.Image
形式に変換させる。
(256x240はファミコンの画面サイズ)
Future<ui.Image> _convertFrameToImage(Uint8List pixels) { final c = Completer<ui.Image>(); ui.decodeImageFromPixels( pixels, 256, 240, ui.PixelFormat.rgba8888, c.complete, ); return c.future; }
その後 ui.Image
を CustomPaint
におけるCanvasに対して canvas.drawImage
している。
当初は RawImage
に直接 ui.Image
をつっこんで、毎回 setState() {}
により描画処理を発火させていたが、おそらく CustomPaint
を使いrepaint
させるほうがインスタンス化のコストが減り、低負荷なのではないかと考えてこのような形にした。(未検証なので実は RawImage
でも軽かったりするかもしれない。)
class _EmulatorPageState extends State<EmulatorPageWidget> { _EmulatorController controller; @override void initState() { super.initState(); controller = _EmulatorController(); } Widget build(BuildContext context) { return Scaffold( body: Row( children: <Widget>[ Expanded( child: Container( width: double.infinity, height: double.infinity, color: Colors.black38, child: CustomPaint( painter: _EmulatorDrawPainter(controller), ) ) ), ] ), ); } } class _EmulatorController extends ChangeNotifier { ui.Image currentFrame; _EmulatorController(); // Isolate周りの処理は省略 /** * Isolateからフレーム更新のメッセージが届いた場合に発火する */ void _onRecvUpdateFrame(Uint8List framePixels) { _convertFrameToImage(framePixels).then((ui.Image image) { currentFrame = image; notifyListeners(); }); } } class _EmulatorDrawPainter extends CustomPainter { _EmulatorController controller; Paint paintObject; _EmulatorDrawPainter(this.controller): super(repaint: controller) { paintObject = Paint(); } @override void paint(Canvas canvas, Size size) { canvas.save(); if (controller.currentFrame != null) { canvas.drawImage(controller.currentFrame, Offset.zero, paintObject); } canvas.restore(); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
CustomPaint
において描画をし直す = paint
を再度呼び出すためには repaint
オプションに ChangeNotifier
や ValueNotifier
を渡す必要がある。
これらのNotifierを使ってデータの更新があることを CustomPaint
に通知すると、paint
が再実行される。(ValueNotifier
の場合はそのままデータを設定すると setter
の働きによりそのまま通知が行われる。 ChangeNotifier
の場合は notifyListeners()
を実行すれば良い。
手を抜いた点と、今後の予定
PPUについてかなり手を抜いた。 パレット情報を使っていないし、1フレーム分のサイクルが走ったら、そのタイミングで全部描画するという実装にしている。 今後、実際のゲームROMを実行する段になって問題が出ると思うので、そのタイミングで実装する。
最終的にはMapper4のROMが動作するところまで実装したい。 その後、GameBoyColorのエミュレータ作成にもチャレンジしてみたい気持ちである。 (とりあえず吸い出し機とゲームは購入した)