SDK v3
Goals
- Add new content — items, enemies, bosses, maps, heroes — declared from a mod, materialized as cooked-asset writes by RSMM.
- Edit existing content — every kind above, plus stats, text, URLs, menu
buttons. Reuse the existing
[[patch]]merge pipeline. - Open extensibility — third parties contribute new content kinds, CLI subcommands, or runtime services as Python entry-point plugins. Core stays stable; ecosystem grows out of tree.
- Crash-safe — apply transaction is atomic; a boot canary detects a crashy
mod and bisects on next launch; per-mod Lua errors never escape
pcall. - Programmer freedom —
R.call(53k fns),R.hook(after TLS injection lands),R.read/writememory primitives, event bus, scheduler. - Survives game updates — pattern-resolver already byte-pattern not VA;
manifests declare
target_game_build; mods address fns by name; schemas are versioned and auto-migrated. - Localization, config, inter-mod APIs — first-class. Wired through the SDK runtime, not bolted on per mod.
- Distribution — open spec (
repo.json), SHA256 + optional Ed25519 signature. No central host required.
Module map
src/rsmm/sdk/ __init__.py # public Python surface: `from rsmm import sdk; sdk.Mod(...)` api.py # @sdk_export + version pin (rsmm.sdk.api.v1) health.py # apply-canary + crash-history bisect transaction.py # stage -> swap install pipeline config.py # schema-driven per-mod config, generates UI rows i18n.py # lang/<locale>.toml merge into text banks content.py # R.content.register(kind, def) facade intermod.py # R.api.expose / R.api.require plugins.py # entry-point discovery, version-gated load repo.py # repo.json schema + sign/verify (Ed25519) versioning.py # game-build hash check + schema migrations kinds/ # one module per content kind items.py # magical objects enemies.py bosses.py maps.py heroes.py _schema_mining.py # Ghidra-driven class-cohort schema extractorLua side (loader DLL):
src/loader/lua/ rsmm.lua # R = require "rsmm" — re-exports everything below rsmm/health.lua # R.health (crash count, last_error) rsmm/config.lua # R.config.get/set/on_change rsmm/i18n.lua # R.i18n.t rsmm/api.lua # R.api.expose/require rsmm/events.lua # R.on / R.emit rsmm/schedule.lua # R.schedule.{next_frame, after}rsmm.lua is rebuilt by rsmm build from the Python side so the Lua API
declarations stay in lockstep with the Python registrations.
Manifest v2
[mod]id = "MyMod"name = "My Mod"version = "1.2.3" # semverauthor = "Me"description = "..."enabled = true
sdk_version = ">=3.0,<4" # required SDK APItarget_game_build = "1.2.3" # what game version it was built forload_order = 100 # tiebreak orderingpriority = 0
[dependencies]otheritempack = ">=1.0"
[provides]api = "myapi" # what `R.api.require("myapi")` resolves to
[[patch]] # existing field-merge support...
[[content]] # NEW: declarative content registrationkind = "item" # one of: item, enemy, boss, map, heroid = "FrostBlade"source = "content/frost_blade.toml"Apply transaction
rsmm apply becomes two-phase:
- Stage: every write goes to
<cooking>/.rsmm_stage/<encoded>. Backups created next to originals as before. Nothing in_Cooking/proper is touched yet. - Commit: atomic rename of staged files into place (POSIX
os.rename,MoveFileExWw/MOVEFILE_REPLACE_EXISTINGon Windows). On the first error mid-commit, every successful rename is rolled back from.rsmm.bak.
State writes are also staged. .rsmm_state.json is written to
.rsmm_state.json.tmp then renamed.
Power-loss safety: rsmm apply is restartable — staged-but-uncommitted
files are detected at startup and either committed (if a COMMIT marker
exists) or discarded.
Boot canary
Loader DLL writes <cooking>/.rsmm_boot.json at DllMain with:
{"started_at": 1716120000, "mods": ["A", "B", "C"], "last_step": "init"}last_step is updated as each mod’s init.lua runs:
init -> per_mod:A -> per_mod:B -> per_mod:C -> readyOn clean shutdown, the file is deleted.
Next launch, rsmm reads any stale canary. If last_step is per_mod:X,
mod X is flagged. Three strikes (configurable) → mod auto-disabled and
moved to crash_history in R.health.
R.health API
R.health.crash_count() -- int, since SDK installR.health.last_error() -- string|nilR.health.last_mod() -- string|nilR.health.disable("modid", "reason")R.health.set_threshold(n)rsmm safe-mode CLI: disables every mod with crash_count >= threshold
and runs apply.
rsmm safe-mode --bisect (planned): disable half, launch, repeat.
Config
mods/<id>/config_schema.toml:
[fields.damage_mult]type = "float"min = 0.1max = 10.0default = 1.0label = "Damage multiplier"
[fields.enable_effect]type = "bool"default = truemods/<id>/config.toml is generated/written by the user via the web UI or
rsmm config <id> set damage_mult 2.5. SDK API:
local cfg = R.config -- bound to the calling modlocal mult = cfg.get("damage_mult")cfg.on_change("damage_mult", function(new, old) ... end)Validation errors at write-time → rejected with explicit message.
i18n
mods/<id>/lang/<locale>.toml:
[strings]title = "Frost Blade"desc = "An icy weapon."Locales: EN, JA, KO, RU, ES, DE, PL, FR, IT, PT-BR, ZH-S, ZH-T (game’s
existing 12 user locales + the RAW QA pseudo-locale).
At apply time, RSMM merges strings into per-locale text-bank overrides,
keys namespaced as RSMM_<modid>_<key>.
Lua API:
R.i18n.t("title") -- "Frost Blade"R.i18n.t("hello", {name="X"}) -- substitution: "Hello, X"Missing locale → fall back to EN. Missing key in EN → return the key
literally and log warning.
Inter-mod API
-- producerR.api.expose({ spawn_item = function(id, pos) ... end, version = "1.0.0",})
-- consumerlocal items = R.api.require("itempack", ">=1.0")items.spawn_item("FrostBlade", player.pos)expose is implicitly namespaced to the calling mod’s id. require
returns a proxy that:
pcalls every call so the producer mod’s failure can’t crash the consumer,- checks the producer’s
versionagainst the semver spec on every call (cheap; cached after first hit), - returns the version reported by
provides.apiin the producer’s manifest if set.
Plugin registry
Third-party Python packages can register SDK extensions via PEP-621 entry points:
# their pyproject.toml[project.entry-points."rsmm.plugins"]my_pack = "my_pack.entry:register"register(api) is called with an rsmm.sdk.api.v1 namespace and may:
- declare new
R.contentkinds (viaapi.content.register_kind(...)), - add CLI subcommands (
api.cli.register(name, fn)), - expose Lua-side modules (file copied to the loader’s
lua/dir).
Discovery: importlib.metadata.entry_points(group="rsmm.plugins").
Each plugin declares requires_api = ">=1.x,<2"; unsatisfied plugins
are skipped with a warning.
Content kinds
R.content.register(kind, def) (Lua) and rsmm.sdk.content.register(...)
(Python) both funnel into one Python pipeline. Per kind:
items— magical-object registry entry + entity cooked file + text-bank keys + icon texture override.enemies— entity clone of a vanilla base enemy + AI controller + stat globals + spawn-table entry.bosses— same as enemies + boss-fight controller + arena patch.maps— biome entry into level list + tile/spawn-weight patch.heroes— entity + portraits + power tree + i18n keys + character-select slot patch.
Each kind module owns:
- Its template
.genbyte slice (extracted from a vanilla cooked file at build time, cached underdata/templates/<kind>/). - A field-by-field patcher (id, name, stats, icon path, …) that emits a
modified
.gen. - The reverse-translation back into a decoded
mods/_merged/assets/...tree consumed byapply_mods.py.
Schema mining
src/rsmm/sdk/kinds/_schema_mining.py drives a Ghidra-headless pipeline:
- For each class involved (e.g.
oCEntityCpntMagicalObjectSettings), bucket every vanilla cooked file by body size and callclass_diffto identify field offsets. - Cross-reference with strings from
docs/_re/out/strings.jsonto label text-bank-key offsets. - Emit
data/schemas/<class>.jsonconsumed by_schema_mining.encode().
This is an empirical RE task; it lands kind by kind. v3 ships with items first, then enemies; bosses/maps/heroes are template-clone-only until their schemas are mined.
Distribution (repo.json)
A mod-repo index file any host can publish:
{ "schema": "rsmm.repo.v1", "name": "Ovilli's mods", "updated_at": "2026-05-19T00:00:00Z", "mods": [ { "id": "FrostBlade", "version": "1.2.3", "sdk_version": ">=3.0,<4", "target_game_build": "1.2.3", "url": "https://example.com/FrostBlade-1.2.3.zip", "sha256": "...", "size": 12345, "sig": "...", // optional Ed25519, base64 "pubkey_id": "ovilli" // matches a key in user's ~/.rsmm/keys/ } ]}CLI:
rsmm repo add https://example.com/repo.jsonrsmm install FrostBlade # resolves to one of the registered reposrsmm pack MyMod --sign keys/me # writes dist/MyMod-1.2.3.zip + .sigrsmm verify dist/FrostBlade.zip # SHA256 + sig vs ~/.rsmm/keys/Trust model:
- Unsigned: install proceeds with
WARN unsigned mod. - Signed by an unknown key: install proceeds with
WARN unknown signer (pubkey_id=...). Trust? [y/N]. - Signed by a known + trusted key: silent install.
No revocation list in v3 (out of scope). Users can hand-delete keys from
~/.rsmm/keys/.
TLS-callback DLL injection (unlocks R.hook)
Goal: install our DLL before Ravenswatch.exe’s entry-point runs, so MinHook patches land before the AT integrity sweep.
- The
winhttp.dllproxy is already loaded viaWINEDLLOVERRIDES=…n,b…. That happens before_DllMainCRTStartupof the EXE, but the runtime is not yet initialized. - TLS callback signature
PIMAGE_TLS_CALLBACKruns once per process atDLL_PROCESS_ATTACH. By placing the MinHook initialization in a TLS callback inside our DLL, we run earlier thanDllMain, which is enough to land hooks before the EXE’smain()initializes anti-tamper. - Gated by env var
RSMM_TLS_HOOK=1during the trial period; default off.
Plumbing scaffold lives in src/loader/src/tls_callback.cpp.
Versioning + game updates
- Loader writes the running EXE’s SHA256 to
<cooking>/.rsmm_game_build.jsonafter a successful boot. Next apply compares — mismatch → warn + soft-disable mods that use raw VAs. rsmm doctorflags mods whosetarget_game_builddiffers from the current.rsmm_game_build.json.- Each SDK-managed content def carries
schema_version. On schema bump, migrations undersrc/rsmm/sdk/kinds/<kind>/migrations/<from>_to_<to>.pyrun at build time.
Open work
- Schema mining for non-item kinds. Items first, then enemies, then the rest.
- TLS-callback hook reliability under Proton + Wine.
rsmm safe-mode --bisectdriver.- In-game config panel (loader-side ImGui).
- Web GUI updates to surface health + config + i18n.
Migration
manifest.tomlv1 still parses; v2 fields are all optional with sane defaults. No mod break.apply_mods.pykeeps the same CLI surface; the staging dir is invisible to users.R.hookexposes “not supported” today and silently upgrades when TLS injection lands.