BLACK SOULS MOD LOADER

- a vessel for many hands -

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:

  1. Decrypt Game.rgss3a with a third-party tool.
  2. Open Scripts.rvdata2 in the RPG Maker VX Ace editor (or with rvpacker).
  3. Edit the script.
  4. 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

  1. Backup - copy the pristine Game.rgss3a to Game.rgss3a.original.bak (only on first install).
  2. Restore from backup - every subsequent install starts by copying the backup back over Game.rgss3a, so we always patch a clean archive.
  3. Decrypt the archive in memory and find the entry for Data/Scripts.rvdata2.
  4. Walk the Marshal blob - locate every script entry's byte boundaries.
  5. Compress loader.rb with zlib at the maximum compression level.
  6. Build a new entry - Marshal-encode [12345678, "BS_ModLoader", deflated_loader_bytes].
  7. Splice the new entry between the second-to-last and last script.
  8. Re-encrypt the patched Scripts.rvdata2 with the original file_key.
  9. Append the encrypted bytes to the end of Game.rgss3a.
  10. Rewrite the entry-table pointer for Scripts.rvdata2 to 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

  1. Discover the Mods/ folder. Tries the current working directory, parent, and a few sensible fallbacks.
  2. Register asset overrides - walks every Mods/<mod>/assets/**/* and builds a hash of relative_path => absolute_path.
  3. Install asset hooks - monkey-patches Cache.normal_bitmap, Kernel#load_data, and Audio.bgm_play (plus its siblings) so reads check the override map first.
  4. Load each mod - reads Mods/<mod>/main.rb, strips a UTF-8 BOM if present, and evals it at TOPLEVEL_BINDING. Per-mod exceptions are caught and reported.
  5. 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

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:

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.