Loader hook
This page records findings from reading the Ren'Py 8.5.2 source (renpy/loader.py, renpy/main.py, renpy/script.py, renpy/pygame/rwobject.pyx) and inspecting the runtimes shipped in the SDK. These findings must be re-verified on every Ren'Py version RenMori supports.
1. Every asset goes through one choke point
renpy.loader.load(name) → load_core(name) → tries each function in renpy.loader.file_open_callbacks in order and returns the first non-None result. The default order is config.file_open_callback, the filesystem, archives (RPA), APK, remote.
Audit of the callers:
Only bypass: renpy.exists() uses transfn() and is documented as disk-only. A game that calls renpy.exists() on a file that has been moved into the container sees False. The hook patches it.
RenMori inserts its callback at index 0 of file_open_callbacks, so the container takes precedence over anything on disk.
2. File discovery: scandirfiles_callbacks
listdirfiles() (used for script discovery, renpy.list_files(), automatic image definitions and filling loadable_cache) is built by calling each function in renpy.loader.scandirfiles_callbacks with add(dn, fn, files, seen). RenMori registers a function that adds every container entry with dn=None, the same way archive entries are added. This makes loadable() true for container files and lets script discovery find .rpyc files that exist only inside the container.
3. Timing: the hook must be installed before index_files()
Ren'Py scripts are discovered at startup, before any game .rpy code runs, so a normal init python block is too late. Ren'Py 8.5 provides an official early hook: Ren'Py Extensions. renpy.main.main() loads, in sorted order, every *.rpe (a zip containing autorun.py, placed on sys.path) and *.rpe.py from the Ren'Py base directory, game/ and game/libs, before renpy.loader.index_files() and before the script is loaded.
RenMori ships its runtime as game/renmori.rpe. (This is planned to move into the native launcher in a later version; .rpe is also an attacker injection point and must be locked down to RenMori's own signed extension.)
4. The return type matters: RWopsIO and the reopen trap
Ren'Py 8.5 hands file objects to C code (SDL_image, FreeType, ffmpeg) through to_rwops() in rwobject.pyx. Its behavior, in order:
- Unwraps
.rawrepeatedly - For an
RWopsIOImpl, uses its CSDL_RWopsdirectly (fast, no GIL) - For an
io.IOBasewith a string.name, reopens the file from disk by that name (with.base/.lengthif present) - Otherwise, wraps anything with a
readmethod in Python callbacks that take the GIL on every read
Consequences for RenMori:
- Trap: a decrypting file object that subclasses
io.IOBaseand exposes a string.nameis reopened from disk, where it either fails to find the file or hands SDL the raw ciphertext; RenMori's stream objects must therefore expose neither.namenor.raw - Small and medium files: decrypt fully and return
RWopsIO.from_buffer(plaintext, name=name), a pure C buffer RWops that takes no GIL afterwards and is the fastest path Ren'Py has - Large files (video, long background music): return
RWopsIO(stream, name=name), wherestreamdecrypts chunks on demand; this path goes through Python callbacks with one GIL acquisition per read and must be benchmarked, and the production answer is a native nogil decryptingSDL_RWopsin the closed runtime
5. AES inside Ren'Py's own Python: no dependency needed
Ren'Py's bundled Python includes neither cryptography nor pycryptodome, but it does ship ctypes (PyInit__ctypes is present in all three runtimes), and:
The runtime reader therefore calls OpenSSL (macOS/Linux) or CNG (Windows) through ctypes. Decryption runs in native code with AES-NI or ARMv8 cryptography extensions; only the per-chunk call overhead is in Python. The build tool uses the cryptography package. All backends produce standard AES-256-GCM, so the container format does not depend on the backend.