VitaでZAVASやりたくて(9)

おさらい

QUASI88を秘密裏に改造してランダムフロッピー(?)に対応することに成功した私はいよいよVitaにて動かすべくVita版Retroarchの情報をググりまくる冒険の旅へと飛び出したとさ。めでたしめでたし。

というわけで今回は超絶パワーアップしたQUASI88をVita版Retroarchに移植するぞの回。

Vita開発環境の準備

Vitaのプログラム開発にはVitaSDKというものが必要です。
公式サイトで手順を確認してインストールしていきます。

ただし公式サイトの手順だと1か所足りないところがあるのでここでも改めてまとめておきます。(あくまでも書いてる時点で動く手順)

  1. コンパイラとか使える状態にする
    WindowsだとWSLかmsys2に対応してるとのこと。
    ここでは僕がmsys2を選んだのでmsys2での手順を書いておきます。msys2は言われるがまま普通にインストールすればOK。

  2. vdpmのインストール
    公式の手順ではpythonのインストールが抜けてるようなのでそれを追加して以下のようにします。(1行目の最後に追加してるpythonがそれ)
    また、exportの部分は.bashrcにも追加しておきます。

pacman -S make git wget p7zip tar cmake python
git clone https://github.com/vitasdk/vdpm
cd vdpm
./bootstrap-vitasdk.sh
export VITASDK=/usr/local/vitasdk
export PATH=$VITASDK/bin:$PATH
./install-all.sh

Retroarchのソース一式を準備

Retroarchのソースと言っても今回はコアとかも全部ひっくるめたLibretroの方が必要になります。
まずはLibretroをダウンロードしてきます。(変なところにダウンロードしないように手順には念のためにcd ~/も入れてます)

cd ~/
git clone https://github.com/libretro/libretro-super.git

ダウンロードが終わって中身を見てみると予想以上にスカスカでびっくりすると思いますがそれで正常です。
あくまでも制御用のスクリプト群になっていてRetroarchやコアはこの後で別途ダウンロードします。
と言うことで次はRetroarchとQUASI88のダウンロード。(retroarchの方はかなり時間がかかるよ)

cd ~/libretro-super/
./libretro-fetch.sh retroarch
./libretro-fetch.sh quasi88

QUASI88コアのビルド

ビルドは専用のスクリプトで行います。
手動でちょいと手間をかけるやり方と(こっちの方が最小限の事しかしないので早い)、全自動ビルド用のスクリプトにまかせて楽をするやり方があります(すごく時間がかかる。のとちょっと困ったことも起こる)。

手動で最小限のことをやる

QUASI88本体部分(エミュレータ部分)のビルドはこう。

cd ~/libretro-super/
./libretro-build-vita.sh quasi88

これで「~/libretro-super/libretro-quasi88/quasi88_libretro_vita.a」というファイルができます。

QUASI88コアのビルドはこう。ちょっと時間かかります。

cp ~/libretro-super/libretro-quasi88/quasi88_libretro_vita.a ~/libretro-super/retroarch/dist-scripts/
cd ~/libretro-super/retroarch/dist-scripts/
./dist-cores.sh vita

これで「~/libretro-super/retroarch/pkg/vita/retroarch.vpk/vpk/quasi88_libretro.self」というファイルができます。
このselfファイルがVitaに持っていくコア本体です。

なお、手順についてはこちらのページを参考にさせて頂きました。

全自動ビルドスクリプトにまかせる

実はこれで何もかもやってくれます。

cd ~/libretro-super/
SINGLE_CORE=quasi88 FORCE=YES NOCLEAN=1 EXIT_ON_ERROR=1 ./libretro-buildbot-recipe.sh recipes/playstation/vita

ただし注意点もあります。

  • 恐ろしく遅い
    へーそんなにかかるのかーと想像している3倍はかかります。
    ちなみに今回必要なファイルは案外早めに出来ているのでそれを見計らって途中で中断してしまうという荒業を使うならそれなりに早いです。折角全自動なのになんか本末転倒だけど。

  • Gitリポジトリから最新版をダウンロードしてきて邪魔をする
    これはもう少し先で困ったことになるのですが、改造して、いざビルドという段階で最新版で上書きされてショック死寸前になります。
    ダウンロードしてくる箇所をコメントアウトするとかで対処は出来るんですけど地味に困る。

QUASI88コアを改造してみるぞ!

手順を把握したところで前回の改造をQUASI88コアに移植してビルドしてみます。
移植?って思うかもしれませんがQUASI88コアはRetroarch用に微妙に書き換わってるところがあるのでソースまるごとコピーではなく移植になります。
とはいえ基本的には改造箇所をそのまま適用できます。
変更点はFDCのログ出力機能が削除されているのでそれに合わせてログ出力をコメントアウトしておくだけ。(最初から書かなきゃ良かったじゃんと言われるとすんませんの世界)

こうなります。
(ほぼ前回のまま。MOD BEGINからMOD ENDまでが変更箇所)
まずは「drive.h」。

/* ---------- MOD BEGIN ---------- */
#define RND_RANGE_MAX	16

typedef struct {
	long	top;
	long	end;
} RANGE_T;
/* ---------- MOD END ---------- */

	/**** ドライブ制御ワーク ****/

typedef	struct{

  OSD_FILE *fp;			/* FILE ポインタ			*/

  char	read_only;		/* リードオンリーでファイルを開いたら真	*/
  char	over_image;		/* イメージ数が多過ぎる時に真		*/
  char	detect_broken_image;	/* 壊れたイメージが見つかったら真	*/
  char	empty;			/* ドライブに空を指定しているなら真	*/

  int	selected_image;		/* 選択しているイメージ番号 (0〜)	*/
  int	image_nr;		/* ファイル内のイメージ数   (1〜)	*/

  struct{			/* ファイル内の全イメージの情報		*/
    char	name[17];	/*	イメージ名			*/
    char	protect;	/*	プロテクト			*/
    char	type;		/*	ディスクタイプ			*/
    long	size;		/*	サイズ				*/
  }image[ MAX_NR_IMAGE ];

				/* ここから、選択中イメージのワーク	*/

  int	track;			/* 現在のトラック番号			*/
  int	sec_nr;			/* トラック内のセクタ数			*/
  int	sec;			/* 現在のセクタ番号			*/

  long	sec_pos;		/* セクタ  の現在位置			*/
  long	track_top;		/* トラックの先頭位置			*/
  long	disk_top;		/* ディスクの先頭位置			*/
  long	disk_end;		/* イメージの終端位置			*/

  char	protect;		/* ライトプロテクト			*/
  char	type;			/* ディスクタイプ			*/

				/* ファイル名				*/

  /* char	filename[ QUASI88_MAX_FILENAME ];*/

  int index_heads; /* Number of heads assumed in index layout. In some broken images it's not 2 as it should be.  */

/* ---------- MOD BEGIN ---------- */
  // ランダム範囲
  RANGE_T	rnd_range[RND_RANGE_MAX];
/* ---------- MOD END ---------- */

} PC88_DRIVE_T;

そして「fdc.c」。

/* ---------- MOD BEGIN ---------- */
//***********************************************************************
// <機能>
//      rndファイルを読み込む
//
// <パラメータ>
//      drv      ... ドライブ
//      filename ... d88ファイル名
//
// <戻り値>
//      0        ... 成功
//      1        ... 失敗
//
// <rndファイル名について>
//      「d88ファイル名.rnd」で決め打ち。つまり「filename.d88.rnd」みたいになります。
//
// <書式>
//      ランダムに読める範囲を16進表記で「XXXX(開始) - YYYY(終了)」の形で指定する。
//      最大15個まで指定可能。(RND_RANGE_MAX-1個)
//      単純にファイル先頭からのオフセット値を指定するだけなので(つまりバイナリエディタ等で読めるアドレスそのまま)、
//      d88ファイルの構造とかは知らなくてOK。
//      範囲指定なので「YYYY(終了)の値を含む」点に注意すること。
//      上記の書式以外は無視するので適当に何か書けば事実上コメント代わりにもなるよ。
//      余談だが、シードすら与えずにrand()で上書きするだけなのでひどくなんちゃってランダムである。
//      シード与えても意味がない気がするしデバッグ時に再現性ある方が便利かと思ってね。敢えてね。
//
// <例>
//      # 1st range
//      0002C0 - 0003BF
//      
//      # 2nd range
//      008000 - 0081FF
//***********************************************************************
int load_rnd_file(int drv, const char* filename) {
    char    rnd_filename[QUASI88_MAX_FILENAME];
    FILE*   fp;
    char    buf[256];
    int     n;
    long    top, end;

    sprintf(rnd_filename, "%s.rnd", filename);

    fp = fopen(rnd_filename, "r");

    if (fp == NULL) {
        return 1;
    }

    n = 0;

    // 読み込む
    while (fgets(buf, sizeof(buf), fp)) {
        // 「XXXX(開始) - YYYY(終了)」の形で読めたものだけ処理
        if (sscanf(buf, "%lx - %lx", &top, &end) == 2) {
            drive[drv].rnd_range[n].top = top;
            drive[drv].rnd_range[n].end = end;
            n++;
            if (n == (RND_RANGE_MAX - 1)) break;
        }
    }

    drive[drv].rnd_range[n].top = 0;
    drive[drv].rnd_range[n].end = 0;

    fclose(fp);

    return 0;
}
/* ---------- MOD END ---------- */

int	disk_insert( int drv, const char *filename, int img, int readonly )
{
  int	exit_flag;
  Uchar c[32];
  long	offset;
  int	num;

  int	open_as_readonly = readonly;

  		/* 現在のディスクはイジェクト */

  disk_eject( drv );


		/* ファイル名を覚えておく */
  /*
  if( strlen(filename) >= QUASI88_MAX_FILENAME ){
    DISK_ERROR( "filename too long", drv );
    return 1;
  }
  strcpy( drive[ drv ].filename, filename );
  */

		/* "r+b" でファイルを開く。だめなら "rb" でファイルを開く */

  if( open_as_readonly == FALSE ){
    drive[ drv ].fp = osd_fopen( FTYPE_DISK, filename, "r+b" );
  }
  if( drive[ drv ].fp == NULL ){
    drive[ drv ].fp = osd_fopen( FTYPE_DISK, filename, "rb" );
    open_as_readonly = TRUE;
  }

  if( drive[ drv ].fp == NULL ){
    DISK_ERROR( "Open failed", drv );
    return 1;
  }

  discover_index_params(drv);



	/* 反対側のドライブのファイルと同じファイルだった場合は	*/
	/*			反対側ドライブのワークをコピー	*/
	/* そうでない場合は	ドライブのワークを初期化	*/

  if( drive[ drv ].fp == drive[ drv^1 ].fp ){	/* 反対ドライブと同じ場合 */

/*  drive[ drv ].file_size           = drive[ drv^1 ].file_size;*/
    drive[ drv ].read_only           = drive[ drv^1 ].read_only;
    drive[ drv ].over_image          = drive[ drv^1 ].over_image;
    drive[ drv ].detect_broken_image = drive[ drv^1 ].detect_broken_image;
    drive[ drv ].image_nr            = drive[ drv^1 ].image_nr;
    memcpy( &drive[ drv   ].image,
	    &drive[ drv^1 ].image, sizeof(drive[ drv ].image) );

    if( drv==0 ){
      DISK_WARNING( " (( %s : Set in drive %d: <- 2: ))\n", filename, drv+1 );
    }else{
      DISK_WARNING( " (( %s : Set in drive 1: -> %d: ))\n", filename, drv+1 );
    }


  }else{					/* 反対ドライブと違う場合 */


    if( open_as_readonly ){
      drive[ drv ].read_only = TRUE;

      DISK_WARNING( " (( %s : Set in drive %d: as read only ))\n", 
		    filename, drv+1 );
    }else{
      drive[ drv ].read_only = FALSE;

      DISK_WARNING( " (( %s : Set in drive %d: ))\n", filename, drv+1 );
    }

    /*
    drive[ drv ].file_size = osd_file_size( drive[ drv ].filename );
    if( drive[ drv ].file_size == -1 ){
      DISK_ERROR( "Access Error(size)", drv );
      return 1;
    }
    */

    drive[ drv ].over_image          = FALSE;
    drive[ drv ].detect_broken_image = FALSE;

    num = 0;	offset = 0;
    exit_flag = FALSE;


			/* 各イメージのヘッダ情報を全て取得 */
    while( !exit_flag ){

      switch( d88_read_header( drive[ drv ].fp, offset, c ) ){

      case D88_SUCCESS:			/* イメージ情報取得成功 */

	memcpy( &drive[ drv ].image[ num ].name[0], &c[0], 16 );
	drive[ drv ].image[ num ].name[16] = '\0';
	drive[ drv ].image[ num ].protect  = c[DISK_PROTECT];
	drive[ drv ].image[ num ].type     = c[DISK_TYPE];
	drive[ drv ].image[ num ].size     = READ_SIZE_IN_HEADER( c );

						/* 次のイメージの情報取得へ */
	offset += drive[ drv ].image[ num ].size;
	num ++;

	if( num >= MAX_NR_IMAGE ){		/* イメージ数が多い時は中断 */
	  DISK_WARNING( " (( %s : Too many images [>=%d] ))\n", 
			filename, MAX_NR_IMAGE );
	  drive[ drv ].over_image = TRUE;
	  exit_flag = TRUE;
	}
	else if( offset < 0 ){			/* イメージサイズが大きすぎ? */
	  DISK_WARNING( " (( %s : Too big image? [%d] ))\n", filename, num+1 );
	  drive[ drv ].detect_broken_image = TRUE;
	  exit_flag = TRUE;
	}
	break;

      case D88_NO_IMAGE:		/* これ以上イメージがない */
	exit_flag = TRUE;
	break;

      case D88_BAD_IMAGE:		/* このイメージは壊れている */
	DISK_WARNING( " (( %s : Image No. %d Broken? ))\n", filename, num+1 );
	drive[ drv ].detect_broken_image = TRUE;
	exit_flag = TRUE;
	break;

      default:				/* ??? */
	DISK_WARNING( " (( %s : Image No. %d Error? ))\n", filename, num+1 );
	drive[ drv ].detect_broken_image = TRUE;
	exit_flag = TRUE;
	break;

      }
    }

    if( num==0 ){
      DISK_ERROR( "Image not found", drv );
      return 1;
    }

    drive[ drv ].image_nr = num;

  }



	/* disk_top をimg 枚目のディスクイメージの先頭に設定	*/

  if( img < 0 || img >= drive[ drv ].image_nr ){
    DISK_WARNING( " (( %s : Image No. %d Not exist ))\n", filename, img+1 );
    drive_set_empty( drv );
  }else{
    disk_change_image( drv, img );
  }



/* ---------- MOD BEGIN ---------- */
  // rndファイルを読む
  // 毎回読み直しで若干効率は悪いが気にしない気にしない
  load_rnd_file(drv, filename);
/* ---------- MOD END ---------- */

  return 0;
}
/*===========================================================================
 * READ系コマンドで、DATAをバッファに読み込む
 *	結果は ST0〜ST2 とバッファセットされる
 *	(READ ID の場合、この関数は呼ばないこと)
 *===========================================================================*/
static	int	fdc_read_data( void )
{
  int	drv = (fdc.us);
  int	read_size, size, ptr, error;


  print_fdc_status(( (fdc.command==READ_DIAGNOSTIC) ? BP_DIAG : BP_READ ),
		   drv, drive[drv].track, drive[drv].sec);

	/* STATUS を再設定 (READ(DELETED)DATAの場合は DATA CRCエラーのみ) */

  if( sec_buf.status == STATUS_DE ){		/* ID CRC err */
    fdc.st0 = ST0_IC_AT | (fdc.hd<<2) | fdc.us;
    fdc.st1 = ST1_DE;
    fdc.st2 = ( (sec_buf.deleted==DISK_DELETED_TRUE)? ST2_CM: 0 );
  }
  else
  if( sec_buf.status == STATUS_DE_DD ){		/* DATA CRC err */
    fdc.st0 = ST0_IC_AT | (fdc.hd<<2) | fdc.us;
    fdc.st1 = ST1_DE;
    fdc.st2 = ST2_DD | ( (sec_buf.deleted==DISK_DELETED_TRUE)? ST2_CM: 0 );
  }
  else{						/* CRC OK */
    fdc.st0 = ST0_IC_NT | (fdc.hd<<2) | fdc.us;
    fdc.st1 = 0;
    fdc.st2 = ( (sec_buf.deleted==DISK_DELETED_TRUE)? ST2_CM: 0 );
  }
  if( ! idr_match() ){				/* IDR不一致 */
    fdc.st0 |= ST0_IC_AT;
    fdc.st1 |= ST1_ND;
  }


	/* DATA 部分を読む */

  read_size = 128 << (fdc.n & 7);		/* 読み込みサイズ       */
  ptr       = 0;				/* 書き込み位置		*/

  if( fdc.command == READ_DIAGNOSTIC ){
    if((sec_buf.size==0x80 && read_size!=0x80) ||	/* セクタの Nと      */
       (sec_buf.size & 0xff00) != read_size    ){	/* IDRの Nが違う時は */
      fdc.st0 |= ST0_IC_AT;				/* DATA CRC err      */
      fdc.st1 |= ST1_DE;
      fdc.st2 |= ST2_DD;
    }
  }

  while( read_size > 0 ){	/* -------------------指定サイズ分読み続ける */

    size = MIN( read_size, sec_buf.size );
    if( osd_fseek( drive[ drv ].fp,
		   drive[ drv ].sec_pos + SZ_DISK_ID,  SEEK_SET )==0 ){
      if( osd_fread( &data_buf[ptr], sizeof(Uchar),
		     size, drive[ drv ].fp )==(size_t)size ){
/* ---------- MOD BEGIN ---------- */
          // 読めた範囲にランダム範囲があればランダム加工する
          // 関数に切り出した方が綺麗ではあるのだがパラメータが多くてかえって煩雑なのでこれで妥協
          RANGE_T   fread_range;

          fread_range.top = drive[drv].sec_pos + SZ_DISK_ID;
          fread_range.end = fread_range.top + size - 1;

          // ランダム範囲の一覧からその範囲内に読めた位置があるか見ていく
          for (int n = 0; drive[drv].rnd_range[n].top != 0; n++) {
              // 読めた範囲とランダム範囲の位置関係には以下のパターンがある。
              // 要は手前か後ろかを判断すればよい。
              // 
              //                   |rnd_range|
              // ①|fread_range|...|         |                 -> 範囲外
              // ②           |fread_range|  |
              // ③               |fread_range|
              // ④                |    |fread_range|
              // ⑤                |         |...|fread_range| -> 範囲外
              //
              // ③’              ||frd_rng|| ※読めた範囲が狭い場合③はこのパターン
              //
              if (fread_range.end < drive[drv].rnd_range[n].top) continue;
              if (drive[drv].rnd_range[n].end < fread_range.top) continue;

              // 範囲内だーッ!
              //logfdc("[READ:%08lX - %08lX] [RND:%08lX - %08lX]  ",
              //          fread_range.top, fread_range.end, drive[drv].rnd_range[n].top, drive[drv].rnd_range[n].end);

              // ランダム範囲を総舐めして読めた範囲の該当位置をランダム加工していく
              // ループ条件を細かく決めると無駄なく処理することも出来るが割とめんどくさいのでシンプルさを優先した
              for (int offset = drive[drv].rnd_range[n].top; offset <= drive[drv].rnd_range[n].end; offset++) {
                  if ((fread_range.top <= offset) && (offset <= fread_range.end)) {
                      // 「ptr + 0」の位置とfread.range.topが対応している
                      // 従ってfread.range.topとの差分を加えたものが書き込み位置になる
                      data_buf[ptr + (offset - fread_range.top)] = rand();

                      // ※もっと簡単に計算出来そうな気がするけど思いつかなかった。
                      //   fread_range側でループを回せば若干の効率悪化と引き換えに少しだけ意味が分かりやすくなるかなあ。
                  }
              }
          }
/* ---------- MOD END ---------- */

	error = 0;
      }
      else error = 1;
    } else error = 2;
    if( error ){			/* OSレベルのエラー発生 */
      printf_system_error( error );		/* DATA CRC err にする*/
      status_message( 1, STATUS_WARN_TIME, "DiskI/O Read Error" );
      fdc.st0 |= ST0_IC_AT;
      fdc.st1 |= ST1_DE;
      fdc.st2 |= ST2_DD;
      break;
    }

    ptr       += size;
    read_size -= size;
    if( read_size <= 0 ) break;


    fdc.st0 |= ST0_IC_AT;		/* 次のセクタに跨った */
    fdc.st1 |= ST1_DE;				/* DATA CRC err にする */
    fdc.st2 |= ST2_DD;


	/* セクタ間を埋める (DATA-CRC,GAP3,ID-SYNC,IAM,ID,ID-CRC,GAP2など) */

#if 0		/* セクタ間のデータ作成なし */
		    /* CRC  GAP3  SYNC   AM    ID  CRC GAP2 */
    if( fdc.mf ) size = 2 + 0x36 + 12 + (3+1) + 4 + 2 + 22;
    else         size = 2 + 0x2a +  6 + (1+1) + 4 + 2 + 11;

    ptr       += size;
    read_size -= size;

    disk_next_sec( drv );

#else		/* peach氏より、セクタ間データ生成処理が提供されました */

    size = fill_sector_gap(ptr, drv, fdc.mf);
    if (size < -1) goto FDC_READ_DATA_RETURN;

    ptr       += size;
    read_size -= size;
#endif
  }				/* ----------------------------------------- */

		/* 読み込み終わったら、次セクタへ進めておく */

  disk_next_sec( drv );


 FDC_READ_DATA_RETURN:

	/* READ DIAGNOSTIC の場合、CRC err と IDR不一致は正常としてみる	*/
	/* (ST1, ST2のビットは、そのまま残す)				*/

  if( fdc.command == READ_DIAGNOSTIC ){
    fdc.st0 &= ~ST0_IC;		/* == ST0_IC_NT     */
  }

  return 1;
}

ビルドして動かしてみる!

ビルド

ソースをいじったら手順に従ってビルドしましょう。
手動の場合こうやればいいのでした。

cd ~/libretro-super/
./libretro-build-vita.sh quasi88
cp ~/libretro-super/libretro-quasi88/quasi88_libretro_vita.a ~/libretro-super/retroarch/dist-scripts/
cd ~/libretro-super/retroarch/dist-scripts/
./dist-cores.sh vita

無事成功すると「~/libretro-super/retroarch/pkg/vita/retroarch.vpk/vpk/quasi88_libretro.self」が出来ます。
出来てなかったら何かがおかしいので頑張れ!

コアをVitaへ

出来上がったコア「quasi88_libretro.self」をVitaへ持っていきます。
VitaShellか何かで転送して「ux0:app/RETROVITA/」に上書きコピーします。

超絶パワーアップ版QUASI88コア始動

Retroarchを起動してQUASI88コアをスタートしてみます。
動くか…?

一回目はなんか知らんけど失敗する。

二回目は動きました。正直理由はよくわからないが結果オーライ。
アセットがないと言われたらアセットを最新に更新すると直ると思いますが、とりあえず動くので無視してもいいはず。

ZAVASやるぞ!

いよいよVitaでZAVASをやるときがやってきたぞ!
前回Windows版で検証済みのランダム範囲ファイルもVitaに持っていったら、一度深呼吸をして感慨にふけります。
これまでの苦労が走馬灯のように駆け巡るのであった。
おっとキーバインドもしないといけないんだったな。
スペースとかエンターとかESCとか…

さあ、お待たせしましたお待たせしすぎたかもしれません。

あとはZAVASを起動するのみだ。
オープニング画面が現れるや否や華麗にエンターのボタンを16連射!

…(待つ)…

うおおおお!こいつうごくぞ!(2日ぶり2回目)
ついにやりましたよ!
これで思う存分遊べる!

うひょーー
さっそく町に入っちゃうもんね

お…?

…?!

あっ

げっ

完全に忘れていた。
テンキーめっちゃ使うじゃん。
これはまずい。
無理やりテンキー全部を割り当ててしまうのも不可能ではなかろうけども、ばらばらなボタン配置で割り当てていたらもう意味が分からな過ぎてほぼ操作不能。

いやいや、ソフトウェアキーボードくらいあるでしょ。
ググってみると「SDLオーバーレイ」とかいうのがある。ほらほら。

あるやんあるやんええやんええやん。

まるで反応していない。

なんと画面が見にくくなるだけのがっかり機能なのであった。

調べてみると、Vita版ではタッチにごく一部しか対応しておらず、ボタンの割り当てにちょっと使える程度。(ホットキーとかで画面を4分割した計4つのボタンとして使えるようだ)

まじかよ。どうしよう。

次回予告

とりあえず無理やりテンキーを割り当ててやってみるも、ストレスだけがひたすら溜まっていく罰ゲームと化しているのではありませんか。
このままではZAVASやりたくなくなっちゃう。
ひどいよそんなのあんまりだよ。

ソフトウェアキーボードをなにがなんでも手に入れてやるぞ。
そう心に誓った私は遂にRetroarch本体の改造にまで手を染めることになるのである。
俺たちの冒険の旅はまだ始まったばかりだ!
(思い出すのに時間がかかるのでしばらく更新されない可能性があります)

この記事が気に入ったらサポートをしてみませんか?