ローダーフック
このページには、Ren'Py 8.5.2 のソース(renpy/loader.py、renpy/main.py、renpy/script.py、renpy/pygame/rwobject.pyx)の読解と、SDK に同梱されたランタイムの調査から得られた知見を記録しています。これらの知見は、RenMori が対応するすべての Ren'Py バージョンで改めて検証する必要があります。
1. すべてのアセットは単一のチョークポイントを通る
renpy.loader.load(name) → load_core(name) → renpy.loader.file_open_callbacks 内の各関数を順に試し、最初に None 以外を返した結果を返します。デフォルトの順序は、config.file_open_callback、ファイルシステム、アーカイブ(RPA)、APK、リモートです。
呼び出し元の監査結果は次のとおりです。
唯一の迂回経路: renpy.exists() は transfn() を使用し、ディスクのみを対象とすることがドキュメントに明記されています。コンテナに移されたファイルに対してゲームが renpy.exists() を呼び出すと、False が返ります。フックはこれにパッチを当てます。
RenMori は file_open_callbacks のインデックス 0 にコールバックを挿入するため、コンテナはディスク上のいかなるものよりも優先されます。
2. ファイルの検出:scandirfiles_callbacks
listdirfiles()(スクリプトの検出、renpy.list_files()、画像の自動定義、loadable_cache の構築に使用)は、renpy.loader.scandirfiles_callbacks 内の各関数を add(dn, fn, files, seen) で呼び出すことで構築されます。RenMori は、アーカイブのエントリと同じ方法で、すべてのコンテナエントリを dn=None で追加する関数を登録します。これにより、コンテナ内のファイルに対して loadable() が真となり、コンテナ内にのみ存在する .rpyc ファイルをスクリプトの検出で見つけられるようになります。
3. タイミング:フックは index_files() より前に組み込む必要がある
Ren'Py のスクリプトは、ゲームの .rpy コードが実行されるより前の起動時に検出されるため、通常の init python ブロックでは間に合いません。Ren'Py 8.5 は公式の早期フックとして Ren'Py Extension を提供しています。renpy.main.main() は、renpy.loader.index_files() の実行前、かつスクリプトの読み込み前に、Ren'Py のベースディレクトリ、game/、game/libs から、すべての *.rpe(autorun.py を含む zip で、sys.path に追加される)と *.rpe.py をソート順に読み込みます。
RenMori はランタイムを game/renmori.rpe として配布します。(これは後のバージョンでネイティブランチャーに移す予定です。.rpe は攻撃者にとっての注入ポイントでもあるため、RenMori 自身の署名済み拡張のみに限定するロックダウンが必要です。)
4. 戻り値の型が重要:RWopsIO と再オープンの罠
Ren'Py 8.5 は、rwobject.pyx の to_rwops() を通じてファイルオブジェクトを C コード(SDL_image、FreeType、ffmpeg)に渡します。その動作は順に次のとおりです。
.rawを繰り返しアンラップするRWopsIOImplであれば、その C のSDL_RWopsを直接使用する(高速で GIL を必要としない)- 文字列の
.nameを持つio.IOBaseであれば、その名前でディスクからファイルを開き直す(.base/.lengthがあればそれも使用する) - それ以外の場合は、
readメソッドを持つ任意のオブジェクトを Python のコールバックでラップし、読み込みのたびに GIL を取得する
RenMori にとっての影響は次のとおりです。
- 罠:
io.IOBaseを継承して文字列の.nameを公開する復号用ファイルオブジェクトはディスクから開き直され、ファイルが見つからずに失敗するか、生の暗号文を SDL に渡してしまう。そのため RenMori のストリームオブジェクトは.nameと.rawのいずれも公開してはならない - 小〜中規模のファイル:全体を復号して
RWopsIO.from_buffer(plaintext, name=name)を返す。これは純粋な C のバッファ RWops で、以後 GIL を取得せず、Ren'Py における最速の経路となる - 大きなファイル(動画、長い BGM):
RWopsIO(stream, name=name)を返す。streamは必要に応じてチャンクを復号する。この経路は Python のコールバックを通り、読み込みごとに 1 回 GIL を取得するためベンチマークが必要であり、本番での解決策はクローズドのランタイムにおけるネイティブの nogil な復号SDL_RWopsである
5. Ren'Py 自身の Python 内での AES:依存関係は不要
Ren'Py に同梱された Python には cryptography も pycryptodome も含まれていませんが、ctypes は同梱されており(3 つのランタイムすべてに PyInit__ctypes が存在)、さらに次のとおりです。
そのため、ランタイムの読み込み処理は ctypes を通じて OpenSSL(macOS/Linux)または CNG(Windows)を呼び出します。復号は AES-NI または ARMv8 の暗号拡張を用いてネイティブコードで実行され、Python 側で発生するのはチャンクごとの呼び出しのオーバーヘッドのみです。ビルドツールは cryptography パッケージを使用します。すべてのバックエンドは標準的な AES-256-GCM を生成するため、コンテナ形式はバックエンドに依存しません。