2017/02/24

LinuxでRPGツクールMV作品を動かす

RPGツクールMVで作られた作品をGNU/Linux上で動かす方法と幾つかの関連したメモなどを扱う。

  1. RPGツクールMV作品の公開形式と実行環境
  2. ダウンロードした作品の実行
    1. NW.jsで実行する
      1. NW.jsのSpectreに対する緩和策のオプション (2018/2/9追記)
    2. WindowsのGame.exeをWineで動かす
    3. 巨大な.exeファイルがある作品
  3. 問題点
    1. WebGL使用時のメモリ使用量問題
      1. 描画モードの確認方法 (NW.jsとChromium系ブラウザ)
    2. BGMの再生タイミングが遅れる?
    3. ランタイムスクリプトの更新による問題回避
  4. Webサイト上の作品とダウンロード版とのセーブデータのやりとり
    1. 手動での操作
    2. ローカルストレージファイルとセーブフォルダとを相互に変換するスクリプト
      1. ブラウザのローカルストレージからダウンロード版セーブデータへ変換
      2. ダウンロード版セーブデータからローカルストレージファイルを作成

RPGツクールMV作品の公開形式と実行環境

  • RPGツクールMV作品(以下 “MV作品”)はHTML/CSS/JavaScriptによるWebコンテンツという形をとっており、後述のNW.jsで動作する形のファイル構成で公開されている
    • 従来のバージョンとは異なり、環境に依存しないが、動作は前作RPGツクールVX Aceの作品をWineで動かすのと比べると重い(XPや無印VXの作品よりは快適)という印象がある
  • Webサイト上にあるゲームプレイ用のページからプレイする作品と、データをダウンロードしてNW.jsもしくは作品付属のGame.exeから起動する形のものがあり、両方の形で公開されている作品もある
    • 作品を動かすのにJavaScriptが使用されている関係で、WebブラウザでMV作品をプレイする場合は同言語のプログラムが高速に動作する(“V8” エンジンを用いた)ChromiumGoogle Chromeを使用するのが動作速度的には望ましい(本記事でも別のブラウザについては扱わない)
  • MV作品を動かすJavaScriptコードは “PixiJS(pixi.js)” と呼ばれる2D描画ライブラリを用いており、WebGLがブラウザでサポートされていれば自動的に選択され(WebGLモード)、使用不可な場合にはHTMLのcanvas要素を従来の方式で描画する(Canvasモード)
    • Canvasモードで動作している場合でも、ChromiumGoogle Chromeではハードウェアアクセラレーションが利用可能な場合にCanvasの描画がCPUのみで描画する場合と比べて高速になる
  • NW.jsはWebブラウザChromiumにファイル読み書きなどの処理を “V8” エンジンを用いてJavaScriptから呼び出せる機能(“Node.js” と呼ばれるプログラム)を組み込むことによってHTML/CSS/JavaScriptによって作成されたクロスプラットフォームなアプリケーションを開発・実行できるようにした実行環境の1つで、作品に付属のGame.exeはこのプログラムと同等の機能を持ったものとなっている(そのため、従来バージョンと比べてファイルサイズが数十MiBと桁違いに大きい)
    • Electronという同様のアプリケーションがあるが、仕様の異なる部分がある関係でそのままでは動作せず、ファイルの追加や編集が必要になるため、MV作品を動かす目的での使用は推奨できない(が、こちらのみ公式バイナリが提供されているARM系アーキテクチャにおいてはNW.jsの公式バイナリが提供されるようになるまでの間は有用かもしれない)
  • ランタイムパッケージ(RTP)は存在せず、作品をプレイするのに追加で必要なパッケージや作業はないが、作品のデータ量は非常に大きくなっており(数百MiB単位で、特にBGMや戦闘アニメーションのデータ量が多い)、ダウンロード可能な作品はダウンロードしたほうがデータ転送量が抑えられる(が、作品ごとのバージョンアップ時に差分のみ提供する形は最近あまり見られず、配布サイトから巨大なファイルを繰り返しダウンロードすることになってしまう傾向にある)

ダウンロードした作品の実行

NW.jsで実行する

NW.jsが手元になくディストリのパッケージもない場合、x86_64もしくはx86アーキテクチャであればNW.jsの公式サイトからビルド済みのバイナリがダウンロードできる(Mac OS X用もある)。MV作品を動かすだけなら、ダウンロードするのはSDK版ではなく通常版で十分。

ここではMV作品用のディレクトリ (“rpgmv” とする) を用意してその中にNW.jsと各作品のディレクトリを配置することにする。各作品のディレクトリはゲーム自体のデータを含むディレクトリ(www)とpackage.json以外は消しても基本的には問題ない(Game.exeの代わりに巨大な.exeファイルがある作品は例外・後述)。

[rpgmv]-+-[NW.jsのディレクトリ]-+-nw
        |                       +- ...
        |
        +-[作品ディレクトリ1]-+-package.json
        |                     +- ...
        |
        +-[作品ディレクトリ2]-+-package.json
        |                     +- ...

作品を実行するには、NW.jsの実行ファイルnwpackage.jsonのあるディレクトリの場所を指定する。

記事公開時点の手元の環境(Radeon HD 4200)では、バージョン0.20系時点では--ignore-gpu-blacklistオプションを付けないとNW.jsアプリケーションの中でWebGLが動作しなかったり、Canvasのハードウェアアクセラレーションが有効にならず重くなったりすることがあった。

標準でWebGLが動作する環境の場合は--disable-webglオプションを付けることでこれを無効化できる(Canvasモードになる)。このオプションはChromiumGoogle ChromeでWebサイト上の作品をプレイする際にWebGLを無効化するのにも使える。

これとは別に--disable-gpuというオプションがあるが、これはWebGLだけでなくCanvasのハードウェアアクセラレーションも無効化されて描画が大幅に遅くなってしまうため、おすすめはできない。

(WebGLモードで実行)
[NW.jsのディレクトリ]$ ./nw --ignore-gpu-blacklist ../作品ディレクトリ1/

(Canvasモードで実行)
[NW.jsのディレクトリ]$ ./nw --ignore-gpu-blacklist --disable-webgl ../作品ディレクトリ1/

上の例ではNW.jsのディレクトリ内で実行をしているが、絶対パス指定で実行やディレクトリ指定をすることもできる。

(WebGLモードで実行・絶対パス指定)
[任意のディレクトリ]$ /path/to/nw --ignore-gpu-blacklist /path/to/作品ディレクトリ1/

(Canvasモードで実行・絶対パス指定)
[任意のディレクトリ]$ /path/to/nw --ignore-gpu-blacklist --disable-webgl /path/to/作品ディレクトリ1/

NW.jsのSpectreに対する緩和策のオプション (2018/2/9追記)

広範なCPUが影響を受けるおそれのあるセキュリティ問題 “Spectre” に基づくサイドチャネル攻撃への緩和策がNW.jsのバージョン0.28系から入っているが、大幅な速度低下の原因となることや、NW.jsアプリケーションは多くの場合に信頼できるものであると考えられることから、--js-flags=--untrusted_code_mitigationsオプションを(信頼できないコードを実行するおそれのあるアプリケーションの場合に)付けない限りはこの緩和策は無効で、速度低下は発生しないようになっている。MV作品を動かす上では基本的にこれを使用(指定)する必要はないと考えられる。

WindowsのGame.exeをWineで動かす

x86_64やx86のアーキテクチャではWineGame.exeを動かす方法もあり、これを実行するだけで作品が起動する。

バージョン2.0時点では特にWine側の設定は必要ないが

  • --ignore-gpu-blacklist--use-gl=desktopのオプションを付ける
  • Game.exeのあるディレクトリへ作業ディレクトリを移動してから実行する

という点に注意。

(WebGLモードで実行)
[作品ディレクトリ]$ wine Game.exe --ignore-gpu-blacklist --use-gl=desktop

(Canvasモードで実行)
[作品ディレクトリ]$ wine Game.exe --ignore-gpu-blacklist --use-gl=desktop --disable-webgl

巨大な.exeファイルがある作品

Game.exeが付属しない代わりに巨大な.exeファイルが存在する作品がある。

これはEnigma Virtual Boxというソフトウェアによって生成されたもので、NW.jsでは実行できない。

このファイルは自己展開書庫に近い性質・構造のもので、ゲームのデータファイル群が.exeファイル内に格納されている。

ある作品を試しにWineで実行してみたが “err:seh:setup_exception_record stack overflow” のエラーで起動に失敗した。

(2018/2/9)同ソフトウェアによって作成された.exeファイルの仕組みは解析されており、中身を取り出す方法も知られている。そのため、ゲーム内容の難読化目的としては実質的には効果がなく、作品の開発者に対しては難読化用途でのこのアプリケーションの使用は個人的にはおすすめできない。

問題点

WebGL使用時のメモリ使用量問題

広く知られている問題として、MV作品をWebGL有効の環境で動かすと作品や操作内容によってはメモリ使用量が大幅に増え続け(十分に解放されない)、環境によっては “起動から数分” レベルのプレイ時間でメモリ不足になってしまい、場合によってはOSが操作不能になってしまう。メモリが3GiBだった記事公開時点の手元の環境では、特にダンジョン探索をしていると普通にプレイするだけでも数分から10分ぐらいであっという間にメモリ使用量が2GiBを超えてスワップが増え始め、それ以上放置するとkswapd0氏が暴れだして操作不能になるリスクが出るため早めにセーブした後一度終了して起動し直すことになった。このやり方でゲーム内時間が1時間半以内の短編作品1つをWebGLモードだけで通しプレイ(クリア)してみたが、メモリ使用量を監視しながらだと内容に集中できず、気持ち的にも落ち着かず、その上頻繁にセーブ・再起動・ロードを行う手間もかかるため、短い割に結構な苦行だった。

WebGLが無効になっている場合(Canvasモード)ではメモリ使用量が大きく増え続けることはなく、Canvasの描画にはハードウェアアクセラレーションが使用されるため、速度も出る。

作品を動かすJavaScriptコードのバージョンが1.3.4時点のものではWebGL使用時のメモリ使用に改善(解放処理の追加と思われる)が適用されており、使用量増加がある程度抑えられている印象だが、Web上には古いバージョンで作られた作品も多数公開されており、後述する通り、単純にファイルを置き換えるだけでは対処できないこともある。

個人的には、システムの搭載メモリが8GiB以上はないとWebGLでメモリ使用量を気にせずにプレイするのは難しいのではという印象がある。メモリが十分にないハードウェア環境ではCanvasモードを使うことを強くおすすめする。

(2018/2/9)その後分かったのだが、そのときに試した作品がこれまで試した中で一番メモリを使用する作品で、独自のメニューを開閉したりマップの切り替えを繰り返したりするだけでメモリをどんどん使用していくものだった。メモリ8GiB環境でもこの作品でのメモリ使用量の異常な増加は再現できたが、2017年秋頃に幾つかの別の作品を追加でプレイした際には特にメモリ使用量が問題になることはなかった。その代わりにWebGLを無効にする(ハードウェアアクセラレーションありのCanvasで動かそうとする)と正常に起動しない作品を複数確認した(アップデートの影響?)ので、WebGL周りの改良によって2015年から2016年頃に公開されて更新が止まった作品と比べるとメモリ使用量の増加はひどくないものと思われるが、メモリが4GiB以下の環境で問題が起こらないとは言い切れない。個人的には、まず “WebGL無効・ハードウェアアクセラレーション有効” でCanvasモードで動くか試して、動かないものだけWebGLを有効にするのが良さそう。なお、WebGLとハードウェアアクセラレーションありのCanvasとの速度差は(旧環境・新環境ともに)体感できない。

描画モードの確認方法 (NW.jsとChromium系ブラウザ)

  • ダウンロードした作品をNW.jsで動かす場合はSDK版を用いて作品を起動した後でF12を押して “Developer Tools” を 起動(別ウィンドウで開く)
  • Web上の作品をChromiumGoogle Chromeで動かす場合はゲームが動いているページでメニュー “その他のツール - デベロッパー ツール” を選択して同ツールを起動

した後で上部の “Console” を押すと、モード名(“WebGL” か “Canvas” のいずれか)やPixiJSのバージョンが表示される。

  • WebGLモードの場合: WebGLモードの場合
  • Canvasモードの場合: Canvasモードの場合

ただし、作品を動かすJavaScriptコードが古いと表示されないこともある。その場合はメモリ使用量の増え方で判断するか、他の作品で確認するなどする。

BGMの再生タイミングが遅れる?

バージョン55,56時点のChromium/Google Chromeや同バージョンに基づくNW.jsではBGMの鳴り始めが遅い印象がある。BGMの止まるタイミングや効果音の鳴るタイミングには問題はない。今後のバージョンでしばらく様子を見てみることにする。

(2018/2/9)これはPulseAudioclient.confにおけるサンプリングレート設定(default-sample-rate)を高い値にしていたのが原因だということが判明し、44100Hzのような低負荷な設定に変更することで改善した。場面や曲の切り替わりのタイミングでCPUに大きな負荷がかかるのだが、CPUのクロックを十分高い状態にすると高負荷な設定でもズレの幅は小さく、CPU性能にも関係していることが分かった。

WindowsのGame.exeは無音時間が短めの印象がある。

ランタイムスクリプトの更新による問題回避

古い(初期の)バージョンのRPGツクールMVで作られた作品を動かすJavaScriptコードの不具合により、バージョンの新しいNW.jsでセーブデータの保存時にブザー音がして保存に失敗したり、タイトル画面からの設定の保存時に止まったりする作品がある。これは

[引用]ファイル名:www/js/rpg_managers.js
StorageManager.localFileDirectoryPath = function() {
  // この部分(return文の行まで)
};

上記関数の内容のみをこの不具合の発生しない別の作品(幾つかMV作品をダウンロードして見つけておく)に含まれる同ファイルの内容で置き換えることで解決する。

他にも、作品を動かすコードには色々と修正が入っているため

  • www/js/rpg_*.js
  • www/js/libs/*.js

を新しいバージョンから上書きコピーすることで動作が新しいバージョンと同様に改善されることが期待できるが、上書きコピーによって作品が起動しなくなったり途中で止まってしまったりする場合があり、新しいバージョンで置き換えれば確実に動作が改善するとは言えない。また、持ってくるバージョンが1.3.4では動かなくなるが1.3.0の.jsファイル群なら条件付きで動作するといった例もある。動かない作品があるのはJavaScriptで書かれたプラグインが原因の可能性もあり、各プラグインの配布サイトから最新のプラグインを入手して置き換えることで改善されるかもしれない(実際にプラグイン更新で正常に動くようになったというものを確認したわけではない)。

いずれにしても、www/js/以下を編集する場合は元のディレクトリをコピーして残しておく(元に戻せるようにしておく)のが望ましい。

Webサイト上の作品とダウンロード版とのセーブデータのやりとり

Webサイトで公開されている作品とそのダウンロード版との間に互換性のある作品では、多少手間がかかるものの、互いにセーブデータのやりとりを行うことができる。

手動での操作

ChromiumGoogle Chromeでは前述の “デベロッパー ツール” を開いて上部の “Application” を選択し、 “Storage - Local Storage - [サイト]” を選択すると表示される “Key” と “Value” の組み合わせの集まりがセーブデータとなっており、 “Value” の内容がNW.jsGame.exeで実行したときのセーブデータファイルの中身とも対応している。

種類キー名対応するファイル名
ゲーム設定項目用RPG Configconfig.rpgsave
セーブデータ本体RPG File[1から20]file[1から20].rpgsave
システムデータRPG Globalglobal.rpgsave

“Key” と “Value” の対応表上でコンテキストメニュー(右クリックメニュー)を開いて項目の新規作成や編集・削除ができる。ダウンロード版へデータをコピーするには文字列をコピーして対応するファイル名の新規テキストファイルに貼り付けて保存し、ダウンロード版からブラウザ版にコピーするには.rpgsaveファイルの中身をコピーして “Value” に貼り付けてページを再読み込みする(“RPG Global” をダウンロード版からコピペしたもので置き換えるのを忘れずに行う)。

ローカルストレージファイルとセーブフォルダとを相互に変換するスクリプト

Webサイト上の作品のセーブデータの実体は以下のディレクトリ内に格納されており、ドメイン名を含んだファイル名のローカルストレージファイル(*.localstorage)でSQLite3のデータベース形式をとっている。ブラウザ実行時に--user-data-dirオプションを付けるとこれらの場所が変更できるので、変更して使っている場合はローカルストレージファイルはその中になる。

ブラウザ標準の場所
Chromium[ホームディレクトリ]/.config/chromium/Default/Local Storage/
Google Chrome[ホームディレクトリ]/.config/google-chrome/Default/Local Storage/

このローカルストレージファイルをSQLite3(Debian/Ubuntuでは “sqlite3” パッケージ)のsqlite3コマンドで扱うことでダウンロード版のセーブデータ群とブラウザのローカルストレージファイルとを相互に変換するスクリプト(Python 3.1以上向け)を作成した。

Webサイト上でのみ公開されている作品におけるセーブデータのバックアップを作成したい場合は単に.localstorageファイルを別の場所へコピーしておけばよいので、以下のスクリプトは必要ない。

ブラウザのローカルストレージからダウンロード版セーブデータへ変換

[任意]ファイル名:localstorage2rpgsave.py ライセンス:MIT
#! /usr/bin/python3

# localstorage2rpgsave.py - Local Storage to RPG Tkool/Maker MV savedir converter
#                           ("sqlite3" command is required)
# (C) 2017 kakurasan
# Licensed under MIT
# 20170224

import subprocess, optparse, locale, shutil, sys, os


def main ():
  locale.setlocale (locale.LC_ALL, '')

  # Option
  optparser = optparse.OptionParser (usage = '%prog (-d [DIR]) [.localstorage file]')
  optparser.set_defaults (outdir = 'save')
  optparser.add_option ('-d', '--output-directory',
                        dest = 'outdir',
                        action = 'store',
                        type = 'string',
                        help = 'set output directory',
                        metavar = 'DIR')
  options, args = optparser.parse_args ()

  if len (args) < 1:
    optparser.error ('No input file specified.')

  # Check SQLite3
  try:
    subprocess.call (('sqlite3', '-version'), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
  except:
    sys.exit ('Error: SQLite3 is not installed.')

  # Get data from local storage
  infile = args[0]
  output = None
  try:
    output = subprocess.check_output (('sqlite3', infile, 'select * from ItemTable;'))
  except:
    sys.exit ('Error: SQLite3 failed.')

  # Create output directory
  try:
    os.makedirs (options.outdir)
  except FileExistsError:
    pass
  except PermissionError:
    sys.exit ('Error: Cannot create "save" directory (PermissionError).')
  os.chdir (options.outdir)

  print ('Info: Storage "{0}" -> Save "{1}"'.format (infile, options.outdir))
  for l in output.splitlines ():
    # Determine output filename
    outfile = None
    name, value = l.decode ('ascii').split ('|')
    if name == 'RPG Config':
      outfile = 'config.rpgsave'
    elif name == 'RPG Global':
      outfile = 'global.rpgsave'
    elif name.startswith ('RPG File'):
      outfile = 'file{0}.rpgsave'.format (name[8:])
    else:
      continue

    # Write .rpgsave file
    try:
      with open (outfile, 'w', encoding = 'ascii') as f_out:
        f_out.write (value)
        print ('Info: Wrote "{0}"'.format (outfile))
    except OSError as e:
      try:
        shutil.rmtree (options.outdir)
      except:
        pass
      sys.exit ('Error: Could not write to file "{0}": {1}'.format (outfile, e))


if __name__ == '__main__':
  main ()

ダウンロード版セーブデータからローカルストレージファイルを作成

[任意]ファイル名:rpgsave2localstorage.py ライセンス:MIT
#! /usr/bin/python3

# rpgsave2localstorage.py - RPG Tkool/Maker MV savedir to Local Storage converter
#                           ("sqlite3" command is required)
# (C) 2017 kakurasan
# Licensed under MIT
# 20170224

import subprocess, optparse, locale, sys, os


def main ():
  locale.setlocale (locale.LC_ALL, '')

  # Option
  optparser = optparse.OptionParser (usage = '%prog (-o [STORAGE]) [SAVEDIR]')
  optparser.set_defaults (outstorage = 'out.localstorage')
  optparser.add_option ('-o', '--output-storage',
                        dest = 'outstorage',
                        action = 'store',
                        type = 'string',
                        help = 'set output storage',
                        metavar = 'STORAGE')
  options, args = optparser.parse_args ()

  if len (args) < 1:
    optparser.error ('No input directory specified.')

  # Check SQLite3
  try:
    subprocess.call (('sqlite3', '-version'), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
  except:
    sys.exit ('Error: SQLite3 is not installed.')

  # Get data from save directory
  indir = args[0]
  outstorage = options.outstorage
  savefiles = None
  try:
    savefiles = os.listdir (indir)
  except PermissionError:
    sys.exit ('Error: Could not open directory "{0}" (PermissionError).'.format (indir))
  except FileNotFoundError:
    sys.exit ('Error: Could not open directory "{0}" (FileNotFoundError).'.format (indir))

  # Check number of .rpgsave files
  savefile_cnt = 0
  for n in savefiles:
    if n.endswith ('.rpgsave'):
      savefile_cnt += 1
  if savefile_cnt == 0:
    sys.exit ('Error: No save files in directory "{0}".'.format (indir))

  # Create new SQLite3 database
  print ('Info: Save "{0}" -> Storage "{1}"'.format (indir, outstorage))
  status = None
  try:
    try:
      os.unlink (outstorage)
    except:
      pass
    status = subprocess.call (('sqlite3', outstorage, 'create table ItemTable (Key text, Value text);'))
  except:
    pass
  if status != 0:
    sys.exit ('Error: SQLite3 (create table) failed.')

  for name in savefiles:
    # Determine key
    key = value = None
    if name == 'config.rpgsave':
      key = 'RPG Config'
    elif name == 'global.rpgsave':
      key = 'RPG Global'
    elif name.startswith ('file'):
      key = 'RPG File{0}'.format (name[4:][:-8])
    else:
      continue

    # Load data
    infile = os.path.join (indir, name)
    try:
      with open (infile, 'r', encoding = 'ascii') as f_in:
        value = f_in.read ()
    except OSError as e:
      try:
        os.unlink (outstorage)
      except:
        pass
      sys.exit ('Error: Could not read from file "{0}": {1}'.format (infile, e))

    # Insert data
    status = None
    try:
      sql = 'insert into ItemTable values ("{0}", "{1}");'.format (key, value)
      p = subprocess.Popen (('sqlite3', outstorage), stdin = subprocess.PIPE)
      p.stdin.write (sql.encode ('ascii'))
      p.stdin.close ()
      if p.wait () != 0:
        try:
          os.unlink (outstorage)
        except:
          pass
        sys.exit ('Error: SQLite3 (insert into) failed.')
    except:
      pass
    print ('Info: Inserted "{0}"'.format (key))


if __name__ == '__main__':
  main ()
使用したバージョン:
  • NW.js 0.20.0 - 0.20.3
  • Chromium 55
  • Google Chrome 56
  • Python 3.5.2
  • Wine 2.0