How it works
A walkthrough of what the mod loader actually does to Game.rgss3a, and why.
Why a loader is needed at all
Black Souls II runs on RPG Maker VX Ace, which uses the RGSS3 runtime
and ships its scripts as Ruby 1.9 source code baked into a single encrypted
archive (Game.rgss3a). The runtime DLL (RGSS301.dll)
reads that archive at startup, deserializes the script blob, and evals every
script before any user code can run.
Vanilla RPG Maker has no plugin system, no require from external files,
no hot-reload. If you want to change a single line of code in the game, the conventional
workflow is:
- Decrypt
Game.rgss3awith a third-party tool. - Open
Scripts.rvdata2in the RPG Maker VX Ace editor (or withrvpacker). - Edit the script.
- Repack everything back into a new
Game.rgss3a.
Every change requires re-extracting and re-packing. Multiple mods can't coexist. There's no concept of "drop a folder, it works."
The mod loader fixes this by injecting a single Ruby script into the archive - a
bootstrap - that runs at game startup, scans an external Mods/
directory, and evals any main.rb files it finds at the top
level. After the bootstrap, the rest is regular Ruby - modders can monkey-patch any
class, hook any scene, replace any asset, all from a folder of .rb files.
The archive format (RGSS3A v3)
Game.rgss3a is a simple XOR-encrypted container with the following layout:
+-- header (8 bytes) ----------+
| "RGSSAD\0\3" |
+-- base_key (4 bytes LE) -----+
| derived: key = (base_key * 9 + 3) & 0xFFFFFFFF
+-- entry table --------------+
| for each file: |
| offset (4 bytes XOR key)
| size (4 bytes XOR key)
| file_key (4 bytes XOR key)
| name_len (4 bytes XOR key)
| name (name_len bytes XOR shifted-key)
| terminator: 4 bytes that XOR to 0
+-- file data ----------------+
| each file's bytes encrypted with its own file_key,
| XORed in 4-byte chunks; key advances per chunk
+----------------------------+
The encryption is symmetric XOR, so the same routine encrypts and decrypts. The format doesn't have a directory tree - paths are stored as full strings ("Graphics/Faces/Alice.png").
What's inside Scripts.rvdata2
The script blob is a Ruby Marshal.dump of an Array of three-tuples:
[
[section_id_1, name_1, zlib_deflated_source_1],
[section_id_2, name_2, zlib_deflated_source_2],
...
]
section_id is a 32-bit (sometimes Bignum) random number used internally by
the editor. name is a UTF-8 string with the script's title. source
is the Ruby source compressed with zlib.
RGSS3 inflates each entry and evals it in order. The very last entry is
conventionally named Main and contains the line that boots the game:
rgss_main { SceneManager.run }
Where the loader gets injected
We inject a new entry - BS_ModLoader - at position N-1, immediately
before Main. By the time it runs, every game class has already been
defined, so the loader can monkey-patch them and call Cache, Audio,
etc. without import order issues. Then Main runs and SceneManager.run
starts the game with the loader's hooks already in place.
What the installer does, step by step
- Backup - copy the pristine
Game.rgss3atoGame.rgss3a.original.bak(only on first install). - Restore from backup - every subsequent install starts by copying the backup back over
Game.rgss3a, so we always patch a clean archive. - Decrypt the archive in memory and find the entry for
Data/Scripts.rvdata2. - Walk the Marshal blob - locate every script entry's byte boundaries.
- Compress
loader.rbwith zlib at the maximum compression level. - Build a new entry - Marshal-encode
[12345678, "BS_ModLoader", deflated_loader_bytes]. - Splice the new entry between the second-to-last and last script.
- Re-encrypt the patched
Scripts.rvdata2with the original file_key. - Append the encrypted bytes to the end of
Game.rgss3a. - Rewrite the entry-table pointer for
Scripts.rvdata2to point to the new offset and size at the end of the archive.
The original Scripts.rvdata2 bytes inside the archive become unreferenced
dead bytes - harmless and never read. The whole patch grows the file by approximately
the size of the inflated loader (about 1 KB compressed).
What loader.rb does at runtime
- Discover the
Mods/folder. Tries the current working directory, parent, and a few sensible fallbacks. - Register asset overrides - walks every
Mods/<mod>/assets/**/*and builds a hash ofrelative_path => absolute_path. - Install asset hooks - monkey-patches
Cache.normal_bitmap,Kernel#load_data, andAudio.bgm_play(plus its siblings) so reads check the override map first. - Load each mod - reads
Mods/<mod>/main.rb, strips a UTF-8 BOM if present, andevals it atTOPLEVEL_BINDING. Per-mod exceptions are caught and reported. - Show a summary MessageBox with the mod count and any errors.
All of this happens in roughly 10–50 ms. The MessageBox is the only visible side effect at game launch.
Why this approach is safe
- The original archive is preserved as
Game.rgss3a.original.bak. Uninstalling restores it bit-for-bit. - Re-running the installer always re-derives from the backup. No accumulation, no growing files, no double-patches.
- The patch only adds one script and rewrites two 4-byte pointers in the entry table. No existing game logic is rewritten or removed.
- Mods are
eval'd inbegin/rescue; one bad mod can't crash the loader or the rest of the mods. - Asset overrides are non-destructive - they intercept reads but never modify the archive.
Why some operations need to be careful
BS2 customizes a number of vanilla RPG Maker classes - Game_CharacterBase,
Game_Player, Window_Command, Window_Selectable,
and several scene classes - in non-trivial ways. The loader's hooks have to coexist with
those customizations. Some patterns to watch out for as a mod author:
- BS2's
Game_CharacterBasedoesn't expose athrough=setter even though@throughexists. Useinstance_variable_set(:@through, true)to drive noclip. - BS2's
Window_Commanddoesn't tolerate callingclear_command_list+create_contentswhile the window is active in some contexts. Deactivate before refreshing. - Several BS2 scripts
alias_methodthe same vanilla methods (e.g.passable?is hooked by region-passing AND symbol enemies AND vanilla). Adding another alias on top usually works, but exotic state changes can confuse the chain - prefer setting an ivar directly when a clean alternative exists.
The bundled creative_mode mod is the canonical example of working through
these constraints - see its main.rb for production-quality versions of every
pattern.