Mod Authoring Guide
This is the single authoring guide — scaffolding a mod through shipping a
finished .zip. For CLI command details, see the CLI Reference;
for the SDK design rationale, SDK_V3.md.
flowchart LR
N["rsmm new"] --> E["edit manifest.toml<br/>+ drop assets"]
E --> A["rsmm apply"]
SDK["Python SDK<br/>(with sdk.Mod)"] -.->|"emits"| E
A --> T["rsmm run<br/>test in game"]
T -->|"iterate"| E
T --> P["rsmm pack → dist/<id>.zip"]
P --> U["upload via Registry"]
Write the manifest. Every mod ships the same thing: a declarative
manifest.toml([[content]]/[[patch]]) plus assets, and writing that file by hand is the normal way to make a mod.rsmm newscaffolds it, the editor schema autocompletes it, andrsmm lintchecks it. The Python SDK (with sdk.Mod(...)) is an optional generator for the same file, useful when a mod has many similar entries to produce. Yourbuild.pyis a tool you run; it is not shipped inside the mod. See Authoring with the Python SDK below.
A mod is data, not code. The shipped artifact is
manifest.toml+ assets — never an arbitrary python script dropped in the mod folder. Reversing a byte layout with a throwaway script is fine for discovery, but fold the capability intorsmm.sdkand express the mod declaratively before shipping.rsmm lint(and CI) rejects any*.pyin a mod except the sanctionedon_disable.pylifecycle hook. (The SDKbuild.pylives in the mod source but is a generator, not loaded at runtime;init.luais the one sanctioned in-mod runtime script.)
Quick start
Section titled “Quick start”# Scaffold a mod./rsmm new MyMod
# Verify it's healthy./rsmm doctor
# Install into the game./rsmm apply
# Launch the game./rsmm run
# Iterate with auto-reapply./rsmm watch # runs in background; reapplies on every change
# Roll back when done./rsmm restore --all
# Package for sharing./rsmm pack MyMod # writes dist/MyMod.zipContent lives in folders, not in the manifest
Section titled “Content lives in folders, not in the manifest”manifest.toml describes the mod — id, author, licence, multiplayer scope.
It should not grow a table for every item, enemy or structure in it. Content
goes in the file tree instead, one directory per thing:
mods/my-mod/ manifest.toml what the mod is items/ember_charm/ item.toml what the item is enemies/frost_wolf/ enemy.toml pois/runestone_shrine/ poi.toml model.glb your own mesh albedo.png your own maps, matched to mra.png texture slots by filename normal.pngEach folder is one content def. Its id defaults to the folder name, and the
<kind>.toml inside holds the same fields a [[content]] block would. rsmm new <id> --kind <kind> scaffolds this layout.
Directory names are plural, kinds singular: items/ → item, enemies/ →
enemy, bosses/, heroes/, talents/, skills/, modifiers/, rewards/,
melodies/, maps/, pois/. game_mode is deliberately not on the list — a
mod has at most one, so it stays in the manifest.
You can still write [[content]] by hand, and a declared block wins over a
folder with the same id, so dropping to the explicit form for one def does not
mean moving the rest.
POIs get presets and art-by-filename
Section titled “POIs get presets and art-by-filename”poi goes furthest with the convention, because a structure otherwise needs
five engine paths. A poi.toml usually only has to say which chapters it
appears in:
#:schema https://docs.rsmm.me/poi.schema.jsonchapters = ["Dark_Hills", "Avalon", "Storm_Island"]The #:schema line gives Taplo / Even Better TOML completion and flags a
misspelled key before apply does.
Everything else — which tile it stands in, which object it takes the place of,
which prop and material it inherits structure from, its kind and spawn weight —
comes from a preset. Drop model.glb plus albedo.png / mra.png /
normal.png beside it and those become the structure’s own art; omit them and
you get a clone of a shipped structure. See rsmm poi to browse, and
mods/runestone-shrine for a worked example.
Two override strategies
Section titled “Two override strategies”Independent of how you produce the manifest (hand-written TOML or the Python SDK), a mod changes the game in one of two ways:
1. Drop cooked files (raw)
Section titled “1. Drop cooked files (raw)”Mirror decoded paths under assets/. Full control, byte-for-byte. One mod owns each file.
2. Compose [[patch]] blocks (recommended)
Section titled “2. Compose [[patch]] blocks (recommended)”Write declarative blocks in manifest.toml for numeric values (stat), texture
swaps (texture), and plaintext .ot fields (ot). The applier composes every mod’s patches into a single
cooked file per target. Two mods touching different fields of the same file
both take effect; conflicts on the same field resolve by load_order (lower =
applies first; later wins on overlap).
[[patch]]kind = "stat"name = "Bleed_Duration_Value"value = 10
[[patch]]kind = "stat"name = "Easy"min = 5max = 10
[[patch]]kind = "ot"selector = "Merlin DMG Zone"field = "m_eComputerType"value = 0The same blocks from the optional Python SDK — python3 mods/MyMod/build.py
writes the manifest above:
from rsmm import sdk
with sdk.Mod("MyMod", author="me", load_order=50) as m: m.stat("Bleed_Duration_Value", value=10) m.stat("Easy", min=5, max=10) m.ot("Merlin DMG Zone", "m_eComputerType", 0)Editing a plaintext .ot (kind = "ot")
Section titled “Editing a plaintext .ot (kind = "ot")”A few oCTextSaver files ship uncooked beside the executable —
DarkTalesResources/ApplicationSettings.ot is the one worth touching. It holds
the friendly-fire factors, the forced-seed option, and the entity-value modifier
descriptors (see below). It is plain text: no cipher, no cooking.
A mod could ship a whole edited copy under assets/_root/, and before this
patch kind that was the only way. Don’t: that redistributes a game file, and it
reverts every unrelated change the next game patch makes to it. Declare the
field instead — apply reads the install’s own bytes, rewrites just that field,
and installs the result through the same _root/ channel:
[[patch]]kind = "ot"selector = "Merlin DMG Zone" # matches s|m_sLabel in a blockfield = "m_eComputerType" # must already exist in that blockvalue = 0# file = "DarkTalesResources/ApplicationSettings.ot" (the default)# selector_field = "m_sLabel" (the default)Rules, all enforced at merge time with an error rather than a silent no-op:
- the selector must match exactly one block;
- the field must already exist in that block — a patch edits a value, it never invents a field the engine would ignore;
- a nested block’s fields are not its parent’s (
m_oDefaultValue’su16Typeis reached by selecting the nested block, not the descriptor around it); - the value’s type comes from the file’s prefix (
i|int,u|unsigned,f|float,b|bool,s|string) — writing a string intoi|is refused; - if any edit in a file fails, the whole file is refused. Half-applied edits are a configuration nobody wrote.
The base is the install’s pristine copy (<file>.rsmm.bak when an earlier
apply made one), so re-running apply is idempotent and two texture-style
chains cannot compound.
Entity values: m_eComputerType
Section titled “Entity values: m_eComputerType”Each oSModifierValueDesc block in ApplicationSettings.ot describes one
entity value — a label, a default, m_uMaxStackSize, m_eStackMode, and
m_eComputerType. The last one picks which oCEntityValueModifier*Computer
folds concurrent modifiers of that value together. It is not a damage type.
The engine ships seven; three independent orderings in the binary agree on
Add(0) Sub(1) Mult(2) Reduce(3) Oldest(4) Max(5) Min(6), and ct=0 = Add
(contributions sum) anchors it. The shipped file uses 0 forty times, 3 six
times, 6 once — and every 3 is a persistent zone or trail: Merlin DMG Zone,
Dullahan_Trail_DOT, Mordred_Trail_DOT, Fire_Trap_DOT, Poison_Trap_DOT,
Piper Pets Within Special. Players report these do not stack when they overlap,
which is what a non-additive combiner would do.
Mod layout
Section titled “Mod layout”mods/MyMod/ manifest.toml # Required: id, name, version, author assets/ # Mirrors decoded paths from data/asset_map.csv <decoded-path>/<file> _root/ # Optional: files outside _Cooking/, at the DarkTalesResources/# install root. Prefer a `kind = "ot"` patch ApplicationSettings.ot # for plaintext .ot files (below). init.lua # Optional: Lua script run by the loader DLL build.py # Optional: Python SDK build script on_disable.py # Optional: cleanup hook when mod is disabledmanifest.toml
Section titled “manifest.toml”#:schema https://docs.rsmm.me/manifest.schema.json
[mod]id = "MyMod"name = "My Mod"version = "1.0.0"author = "you"description = "what it does"enabled = trueEvery key must be one something reads. A misspelled key used to install a mod that did nothing, so now:
rsmm lintfails on an unknown key in[[content]]or[[patch]]and names the closest valid one (unknown key 'valeu' (did you mean 'value'?)). An unknown[mod]key is a warning.- It also fails a patch that would change nothing: a stat name that does not
exist, a field that value does not have (
Easyhasmin/max, notvalue), a non-number, or a texture path the game does not ship. rsmm applyand the Python SDK refuse an unknown content field outright.
The #:schema line (which rsmm new writes for you) lets
Even Better TOML
or any Taplo-based editor autocomplete keys and underline the same mistakes as
you type. The schema is generated from the same list lint uses, so the two
never disagree.
on_disable.py (optional)
Section titled “on_disable.py (optional)”Place next to manifest.toml. Fires from ./rsmm apply when the mod flips enabled = true → false. Subprocess with 30s timeout; receives RSMM_GAME_DIR, RSMM_COOKING, RSMM_MOD_DIR env vars.
The hook runs as the player, outside the game, with no sandbox, so it never runs unasked: rsmm apply lists the pending hooks and waits for a yes (or --yes), and uninstalling from the desktop app asks whether to run it before removing the mod.
Use for cleanup the loader DLL can’t do at apply time — clearing settings keys, deleting profile caches, etc.
See mods/ExampleSeedPin/on_disable.py for a canonical example.
ConsoleRuntime / dev_mode
Section titled “ConsoleRuntime / dev_mode”The bundled mods/ConsoleRuntime/ mod ships with a dev_mode flag in its manifest.toml. Off by default. When dev_mode = true, ConsoleRuntime registers /eval, which executes arbitrary Lua inside the game process.
Toggle: edit mods/ConsoleRuntime/manifest.toml, set dev_mode = true, then ./rsmm apply (or relaunch the game). Never ship a release with it on.
Authoring with the Python SDK
Section titled “Authoring with the Python SDK”Optional. The manifest is the main way to write a mod; the Python SDK
generates one. Reach for it when a mod has many similar entries — fifty stat
tweaks, a table of item variants — that are easier to produce in a loop than to
type out. You describe the mod in Python and rsmm.sdk writes the
mods/<id>/ tree atomically. It applies the same checks as rsmm lint: an
unknown field is refused, with the nearest valid name.
Every content kind is reachable with one call, m.content(kind, id=..., **fields),
whose fields are exactly the manifest’s. Five kinds also have a named shortcut
(m.item, m.enemy, m.boss, m.map, m.hero); the rest use m.content.
Design rationale: SDK_V3.md.
First mod
Section titled “First mod”A mod is a with sdk.Mod(...) as m: block — calls accumulate in memory and the
tree is written when the block exits.
from rsmm import sdk
with sdk.Mod("FrostPack", version="1.0.0", author="you", name="Frost Pack") as m: m.i18n("EN", {"FrostPack_hello": "A chill wind blows."})python mods/FrostPack/build.py # writes mods/FrostPack/manifest.toml + assetsrsmm list # see it registeredrsmm apply # install into the gameTyped content + handles
Section titled “Typed content + handles”m.content(kind, id=..., **fields) registers any kind; m.item / m.enemy / m.boss / m.map / m.hero are shortcuts for five of them. Each returns a
ContentRef (like Forge’s RegistryObject). Pass a handle anywhere another
content id is expected — it resolves to the raw id automatically. Find valid
bases with rsmm schema [kind] [--grep T].
with sdk.Mod("FrostPack", version="1.0.0", author="you", experimental=True) as m: # required for non-confirmed kinds blade = m.item("FrostBlade", base="Orb_Grants_Strength", name="Frost Blade") m.enemy("FrostGhoul", base="Sling_Ghoul", add_flags=["Elite"]) m.boss("CrabDen", base="Boss_Marsh_Ghoul", becomes="Boss_Crab") # every other kind: the same fields as its [[content]] block m.content("reward", id="MoreChests", base="Camp_Rewards_Dark_Hills_Update5", counts={"2": [3, 3]})Kinds below
confirmedneedexperimental=True. Which ones those are is generated from the code: Content kinds & confidence.
Tags, assets, config
Section titled “Tags, assets, config”m.tag("daggers", [blade, "VanillaKnife"]) # append-across-mods set; R.tags() in Luam.texture("3D/.../T_Melusine_ALB.png", "art/albedo.png") # PNG/DDS/TGA, auto-cookedm.skinpack("Crimson Pack", key=0x900001) # new selectable slot (experimental)m.config({"fields": {"frost_damage": {"type": "float", "default": 1.5}}})m.i18n("FR", {"FrostBlade_desc": "Gèle à l'impact."})print(m.summary()) # dict of everything staged, no disk writeConfig with icons (multiselect)
Section titled “Config with icons (multiselect)”A config field is normally bool/int/float/string/enum. A
multiselect holds a list of ids and, when it names an allowlisted option
provider, the desktop draws it as a searchable list of the game’s own art:
# mods/<id>/config_schema.toml[fields.banned]type = "multiselect"source = "item-catalog" # allowlisted provider; NOT a path or a commandlabel = "Banned items"default = []The CLI resolves source into options — {id, label, group, icon, description} — and sends them with the schema, so the panel renders labels,
grouping, search and art in one round trip. Read the value back with the normal
config API (ConfigStore(mod_dir).get("banned")); the banned-items mod’s
[[content]] ban block takes its item list from exactly that.
A saved config reaches the game on the next launch, never sooner
Section titled “A saved config reaches the game on the next launch, never sooner”Nothing about config is hot-reloaded. The loader hands a mod its values once,
when it loads the mod, and a config that feeds [[content]] (an item ban, say)
only becomes real when rsmm apply rewrites the cooked asset that carries it —
which the desktop runs as part of Play. So an edit made while Ravenswatch is
running changes nothing in that session: quit the game completely, then launch
again. The desktop config panel says so after every save, and marks the
asset-rebuilding case (any provider-backed field) explicitly, because that
launch does real work before the game starts.
Providers are an allowlist: a mod supplies a name, never a path, a URL or a
command. The desktop webview can spawn the CLI, so anything a mod could inject
there would run on the player’s machine — the same reason overlay shape is data
and never markup. The providers today are item-catalog (every magical object
the game can offer), enemy-roster (every creature a camp can roll) and
shop-items (every item the Sandman’s offers can hold, with its price).
A provider-backed field does not reject ids it cannot currently see. The valid set lives in the game install, which may be missing or newly patched, and dropping an unrecognised id would silently delete the player’s selection the first time the CLI ran somewhere the catalog could not be read.
Editors for lists of game items (item-grid)
Section titled “Editors for lists of game items (item-grid)”An item-grid sorts a provider’s options into sections you declare, with an
optional number per item. The whole editor — sections, labels, which items fit
where, counts, and even its look — is your mod’s declaration. The desktop draws
every item-grid with one generic component; it does not know what yours is for.
# mods/<id>/config_schema.toml[fields.loadout]type = "item-grid"label = "Loadout"source = "shop-items" # allowlisted provider, as abovetitle = "Pick what is on offer" # optional, your own copylayout = "columns" # section groups side by side; default "stack"quote = "Every dream has its price." # optionalnumber = { attr = "price", label = "Price", min = 0, max = 99999, editable = "priceEditable" }
[[fields.loadout.sections]]id = "minor" # letters, digits, _ or -label = "Offers"group = "Minor dreams" # consecutive sections sharing a group share a headingaccepts = { role = "offer" } # option attributes that must ALL matchcount = { label = "Offers per visit", min = 1, max = 4, default = 4 }
[fields.loadout.theme] # optional: dress it in the game's own artpanel = "Ui/SandMan/UI_SandManBg.png"portrait = "Ui/NPC/NPC_Sandman_Portrait.png"header = { texture = "Ui/SandMan/UI_SandMan_categoriesBG.png", ink = "dark" }- Options come from
sourceand may carryattrs.acceptsfilters on them; a provider marks where an option sits by default withattrs.defaultIn(a list of section ids);number.attrnames the attribute holding the default number, andnumber.editablethe boolean that says it may change. emptyon a section is your text for when it holds no items — a section the provider starts empty (like a shop’s random-object slot) explains itself instead of looking broken.- The stored value keeps only what differs from those defaults:
{ sections = { minor = { items = [...], count = 2 } }, numbers = { <id> = 10 } }. An untouched editor stores nothing, so “Reset to defaults” really is the unmodded state. A content kind reads it —kind = "shop"takesconfig = "<field>", see Edit the Sandman shop. - Layout:
stackputs section groups one under another;columnsputs them side by side, each in its ownpanelframe with an optionalseparatorbetween, and moves the portrait and quote beside them on a wide screen. A vendor-style grid with a few groups reads far better as columns. - Theme slots:
title,panel(9-sliced frame),header,separator,details,quote,portrait,remove,removeHover,number,numberIcon. Each names a game texture as its decoded path (rsmm assets searchfinds them), either as a plain string or as{ texture = "…", ink = "dark" }.ink(lightby default) is the text colour that reads on that texture — the app cannot tell a parchment scroll from a slate panel, so you say which it is. The CLI accepts onlyUi/…pngpaths the game’s own asset map resolves, decodes them from the player’s install when the editor opens, and caches them under<game>/rsmm/cache/ui/. Nothing is shipped with the mod, and a schema cannot name any other file. Leave the theme out and the grid uses the app’s own style.
Test offline (no game)
Section titled “Test offline (no game)”rsmm.sdk.testkit asserts over staged state without applying:
from rsmm import sdkfrom rsmm.sdk.testkit import expect, assert_no_conflicts
def build(): m = sdk.builder.ModBuilder("FrostPack", version="1.0.0", author="you") blade = m.item("FrostBlade", base="Orb_Grants_Strength", name="Frost Blade") m.tag("daggers", [blade]); m.i18n("EN", {"FrostBlade_desc": "Freezes."}) return m
def test_frostpack(): (expect(build()) .has_item("FrostBlade").has_tag("daggers", "FrostBlade") .i18n_complete().clean()) # every locale key present, no warnings
def test_no_clashes(): assert_no_conflicts(build()) # safe alongside itselfSDK quick reference
Section titled “SDK quick reference”| Goal | Call / command |
|---|---|
| New content (handle), any kind | m.content(kind, id=…, **fields) |
| Shortcut for five kinds | m.item/enemy/boss/map/hero(id, base=…) |
| Find base ids | rsmm schema [kind] [--grep T] |
| Group content | m.tag(id, [refs…]) |
| Override asset | m.texture/model/asset(decoded, src) |
| New skin slot | m.skinpack(name, key=…) |
| Config / strings | m.config({...}) / m.i18n(loc, {...}) |
| Preview staged state | m.summary() |
| Offline assertions | from rsmm.sdk.testkit import expect, assert_no_conflicts |
| Generated API docs | rsmm docs-gen → docs/api/ |
Recipes
Section titled “Recipes”Replace a cooked file (raw)
Section titled “Replace a cooked file (raw)”# Find the decoded path:rg -i "hero.*portrait" data/asset_map.csv
# Copy your file in:cp /path/to/donor.dxt \ mods/MyMod/assets/Ui/BookMenu/Heroes/UI_HeroPortrait_Romeo_Active.png.Texture.dxt
# Apply./rsmm applyTexture swap (donor reference)
Section titled “Texture swap (donor reference)”Point one shipped texture at another shipped texture, with no image file of your own. There is no SDK method for this; declare it in the manifest:
[[patch]]kind = "texture"target = "Ui/BookMenu/Heroes/UI_HeroPortrait_Romeo_Active.png.Texture.dxt"donor = "Ui/BookMenu/Heroes/UI_HeroPortrait_SunWukong_Active.png.Texture.dxt"Both are decoded paths — find them with ./rsmm assets search Heroes Portrait png.
rsmm apply copies the donor’s pristine bytes to the target. To use your own
image instead, call m.texture(path, "art/my.png").
Custom 3D mesh (.glb)
Section titled “Custom 3D mesh (.glb)”mod.model("3D/Characters/Heroes/Juliet/Juliet_GEO.fbx", "assets/my_mesh.glb")mod.model(path, src, rotate_deg=(90, 0, 0), scale=0.8) # orientation / sizemod.model(path, src, skin="gltf") # use YOUR OWN weightsmod.model(path, src, skin="rigid") # bind to ONE bonemod.model(path, src, fit="none") # keep your own sizemod.model(path, src, submeshes="map") # keep the material splitThe cooker replaces the mesh inside the shipped asset and keeps that asset’s existing skeleton. A custom skeleton is never written, so the bones your mesh moves on are always the game’s — but which of them each vertex rides is yours to control.
Weights: skin="gltf" for a character, transfer for a prop
Section titled “Weights: skin="gltf" for a character, transfer for a prop”The default, skin="transfer", copies weights positionally: each of your
vertices takes the weights of the game vertices nearest to it. That is right
for a prop, and for a body only if you authored it in the original’s bind pose
— otherwise an arm vertex nearest a leg bone is weighted to the leg and the
model tears itself apart the moment it animates. rsmm apply warns when your
mesh does not overlap the original’s bind pose.
skin="gltf" is the way to replace a character. Rig your mesh to the
game’s skeleton in Blender, using the original’s bone names, and export with
the armature. The cooker then reads JOINTS_0/WEIGHTS_0 out of your .glb
and binds each vertex to the bone you named, so nothing is guessed and the pose
you modelled in stops mattering. It implies fit="rig" — no scale, no
recentre — because a mesh rigged to the game’s skeleton is already in place.
Two helpers go with it. bones={"MyBone": "DEF.Spine"} renames joints on the
way in, for a rig that came from elsewhere; a name the target’s skeleton does
not have is reported, and a vertex left with no usable bone is an error rather
than a silent pin to bone 0. drop_bones=["DEF.PHY.Grab"] deletes the geometry
a bone drives, for a donor carrying something that has to become a separate
object — a chain, a slung weapon.
Note that an rsmm uncook mesh carries no glTF skin (its weights ride in
extras.rsmm.cooked_b64, which Blender discards on re-export), so skin="gltf"
needs a mesh you actually rigged, not a round-tripped one.
skin="rigid" opts out of weights entirely and binds the whole mesh to a
single bone: it follows the character but never bends. Right for a prop or a
static shape, wrong for a body.
Submeshes carry materials
Section titled “Submeshes carry materials”A character is several submeshes because the entity hands a different material
to each one. By default they are all merged into the first, which leaves the
rest as empty stubs — so everything draws with the first material and your
second texture never appears. submeshes="map" lays yours onto the original’s
one for one instead. Pairing is by order, so order your objects in Blender to
match the original’s.
Two limits worth knowing before you model: meshes above 65,535 vertices are refused, because a heavier one crashes the game at load (decimate toward that ceiling, not far below it — it is far higher than the vanilla count and looks near-identical to a 250k film source); and a custom skeleton or custom animations cannot be shipped at all, because nothing writes those formats yet. Animation clips also key translation on every bone, not just rotation — measured across a shipped hero clip, 113 tracks of 113 — so a posed skeleton always has the original’s proportions, and a taller or shorter body follows the original’s limb lengths no matter how it is bound. Reskinning what already animates is the supported path.
Numeric balance / modifier / camp difficulty
Section titled “Numeric balance / modifier / camp difficulty”Three families of numeric values are editable by name: global values
(*.globalvalue.ot, e.g. Bleed_Duration_Value), game-modifier definitions,
and enemy-camp difficulty bands (Easy, …). The name is the asset’s file name
without extensions — ./rsmm assets search globalvalue Bleed lists them.
with sdk.Mod("LongerStatusEffects") as m: m.stat("Bleed_Duration_Value", value=10) m.stat("Ignite_Duration_Value", value=11) m.stat("Easy", min=5, max=10) # multi-field: one keyword per fieldor directly in manifest.toml:
[[patch]]kind = "stat"name = "Bleed_Duration_Value"value = 10rsmm apply merges every enabled mod’s stat patches into one file per value:
two mods changing different fields both take effect, and two mods changing the
same field log a conflict (the later mod by load_order wins). An unknown name
is reported and skipped.
Randomise or replace a monster population (kind="enemy", mode="override")
Section titled “Randomise or replace a monster population (kind="enemy", mode="override")”Rewrites retail enemy definitions in place so a biome’s slots instantiate different creatures. Camps, tiers, tribes and difficulty are untouched — only the prefab each slot spawns changes.
[[content]]kind = "enemy"mode = "override"id = "random_roster"cross_biome = true # draw from every creature in the game, and repoint each # biome's EntityPooling asset so they are streamed theremix = "shuffle" # each biome gets as many distinct creatures as it hasseed = 1337 # pool slots, each used once ("random" draws with # replacement and leaves ~a third of the slots unused)power (optional) sets one power cost for every overridden enemy. It is
not spawn odds: a camp spends a power budget, so raising it makes each camp
field fewer, pricier enemies, and a cost above the budget means the enemy is
never picked. Vanilla camp enemies cost 0.1-20. weight is its old,
misleading name and still works with a warning.
cross_biome is the load-bearing flag. Without it a swap must stay inside
the biome’s own entity pool, so the randomiser can only permute the cast that
chapter already had — Storm Island still shows crabs and gnolls, and it looks
identical to vanilla. With it, each chapter draws from all 50 camp-spawnable
creatures and gets 8-11 foreign ones. A biome hosts exactly as many distinct
creatures as it has pool slots (10-15): an oCGameStream ref can be repointed,
but the vector cannot grow.
Or pin a whole biome to one monster:
[[content]]kind = "enemy"mode = "override"id = "treant_world"pools = ["Dark_Hills"]entity = "Enemies\\Treant\\Standard_Clawed_Treant.entity.ot"Or write the cast yourself, chapter by chapter, instead of letting the seed deal it:
[[content]]kind = "enemy"mode = "override"id = "my_roster"cross_biome = truerepoint_pools = false # required to pull a creature across chaptersmix = "random" # how the cast is spread over the chapter's defsseed = 1337
[content.casts]Dark_Hills = ["Elite_Wolf_Alpha", "Standard_Reef_Crab", "Standard_Storm_Jinn"]Storm_Island = ["Standard_Clawed_Treant"] # one monster = the whole chapterA chapter listed in casts draws only from the monsters written there; a
chapter left out keeps its dealt (or vanilla) cast, so pinning one chapter does
not reshuffle the others. Monsters may be named by definition id
(Elite_Wolf_Alpha), prefab stem, or full entity ref. mix still decides how
the cast spreads over that chapter’s ~15 definitions, which is why a
one-monster list turns the whole chapter into that monster.
The same cast can come from the mod’s config panel instead of the
manifest: declare one multiselect field per chapter, named after the pool,
with source = "enemy-roster" in config_schema.toml, and the desktop app
draws a grouped monster picker. A chapter the player picks for overrides the
manifest’s cast for that chapter; an empty pick leaves it on the roll. See
mods/random-monsters for the worked example. Two same-source multiselect
fields are treated as buckets over one option set, so the panel draws each
pick as a chip with a remove (×) and a “move to another list” arrow — moving a
monster from one chapter to another is one click, not a hunt through two
50-row scrollers.
The picker’s options and the re-emit both work on a plain install: the enemy
definitions, pool assets and resource caches are read from the uncooked mirror
when an authoring checkout has one, and otherwise straight out of
<install>/DarkTalesResources/_Cooking through the bundled asset_map.json
(preferring the pristine .rsmm.bak when a previous apply left one). Both
stores hold the same bytes, and a test asserts an emit from either is
byte-identical.
Casting a creature from another chapter needs cross_biome = true, and
repoint_pools = false is the combination proven in-game. With pools rewritten
(repoint_pools = true) two extra rules are enforced, both of which crash a
chapter transition otherwise: a cast may not be longer than the chapter’s pool
slots, and no creature may be cast in two chapters at once.
rsmm enemies pools lists the biome pools; rsmm enemies pool <biome> lists
the prefabs valid as entity there. Scope defaults to every open-world pool;
narrow it with pools, extend it with enemies, and drop individuals with
exclude.
Two rules the builder enforces rather than documents, because both fail silently in-game:
- A creature is only ever assigned to a biome that streams it. Candidates
come from the biome’s own cast, and under
cross_biomethe pool is rewritten first so the cast is streamed. Only def-owned pool slots are repointed — never the projectiles, attack zones and VFX trails a real attack needs. - The resource cache travels with the swap. Each rewritten definition gets
the sorted union of its own
*.UsedRscCache.otand that of the definition owning the new prefab. Without it the new meshes are unlisted, resolve to null at level build, and the engine’s teardown loop destroys the null — an access violation nowhere near the edit.
Bosses, summons and quest enemies are never in scope: no pool streams them, so
they are placed by the script owning their encounter rather than rolled by a
camp selector. To make a boss arena fight a different boss, use the boss
kind, which knows which arenas pick their boss by flag alone.
The roll happens once, at rsmm apply, and is baked into the emitted
assets — not re-rolled per run. That is what keeps co-op consistent: peers
have to agree on the seed, not on a runtime RNG. A peer without the mod sees
vanilla monsters.
Disable items (kind="item", mode="ban")
Section titled “Disable items (kind="item", mode="ban")”Drop vanilla magical objects from the catalog so they can never be offered or drop — the “ban the crutch items” challenge-run lever.
[[content]]kind = "item"mode = "ban"id = "no_crutches"items = ["Avoid_Death_Once_Per_Chapter", "Armor_Per_Object"]Or skip the ids entirely and pick the items from a list of the game’s own
icons: the banned-items mod declares a multiselect config field (see
Config with icons), so opening its config in
the desktop app shows every item with its art, display name and effect text, and
the ban list is whatever you ticked. rsmm items catalog is the same list in a
terminal, and rsmm items ban --add <id> edits it without a UI. When a mod
declares the picker, the picker’s selection wins over the manifest’s items
list.
items are bare vanilla item ids — the filenames under
EntitySettings/Objects/Magical_Objects/<Rarity>/. rsmm apply rebuilds the
LiveOps versiondef magical-object vector without them, so the engine never
loads them into the pool and no draw can reach them. The vanilla manifest is
backed up once and rebuilt from that pristine copy on every apply, so disabling
the mod restores the full catalog.
Bans from several mods are unioned, so two mods each banning a different item yield both bans.
Two side effects worth knowing:
- A banned item disappears from the compendium too. The catalog entry is what the compendium enumerates; there is no separate “hide from drops only” flag.
- The entity file itself stays installed and its resource-cache line is left alone. That is deliberate: a surplus cache line only wastes a preload, while a missing one crashes the load.
An id that no vanilla item has is refused at emit time when the asset corpus is
available, and reported as a [warn] against the install’s own manifest at
apply time. Banning a name nothing matches is otherwise a perfectly well-formed
no-op that only surfaces as “the banned item still dropped” a playtest later.
Edit the Sandman shop (kind="shop")
Section titled “Edit the Sandman shop (kind="shop")”The Sandman is the game’s vendor. A shop def changes what it costs and what it
offers, by overriding the retail files in place. There is one Sandman, so a mod
declares at most one.
[mod]id = "cheap-sandman"multiplayer_scope = "deterministic-shared" # every peer needs the same shop
[[content]]kind = "shop"id = "sandman"price_scale = 0.5 # every item not listed below costs half
[content.prices] # dream shards; prefix Power_Up_Sandman_ is optionalMinor_Heal = 10Major_Upgrade_Talent_To_Legendary = 150
[content.offers.minor]count = 2 # vanilla rolls 4
[content.offers.major_object]weights = { legendary = 1, cursed = 0 } # unlisted qualities keep their vanilla weightWhat the shop is made of:
| Generator | Vanilla count | Pool | Quality weights |
|---|---|---|---|
minor |
4 | Sandman; Minor (heal, reroll, shield, strength) |
powerup |
medium |
1 | Sandman; Medium |
powerup |
medium_duplicate |
1 | Sandman; MediumDuplicate |
powerup |
medium_object |
1 | none — a random magical object | common 0.5, rare 0.5 |
major |
1 | Sandman; Major |
powerup |
major_duplicate |
1 | Sandman; MajorDuplicate |
powerup |
major_object |
1 | none — a random magical object | epic 0.5, legendary 0.25, cursed 0.25 |
Vanilla prices: Minor items 50–75, Medium 100–200, Major 250.
Each generator accepts count, weights (qualities common, rare, epic,
legendary, cursed, powerup) and pool (the include-flag filter; ""
means no filter). Things to know:
- A generator never offers the same item twice, so a
countabove the number of items in its pool adds nothing. Theminorpool holds exactly 4 items, which is why the vanilla Minor row never changes. - Every Sandman item is quality
powerup. Moving a Sandman pool’s weight onto another quality finds nothing to offer. - The
*_duplicateand*_objectgenerators are a pair. The duplicate offer is a powerup, and the object offer is the magical object it copies.major_object’s weights decide which quality of object that is. - Prices are also multiplied at runtime by the hero’s “Reduce all dream
shard prices” and “Reduce Sandman dream shard prices” values and the run’s
“Dream Shard Costs Modifier” (the
HigherPricesgame modifier), then rounded.
Unknown items, generators, qualities or fields raise at emit time rather than producing a file that silently changes nothing.
Choosing exactly which items a slot offers. slots names the items per
generator, and is how an item from another family (a Grimoire chapter, a
Wishing Well gift) gets into the shop:
[content.slots.minor]items = ["Power_Up_Sandman_Minor_Heal", "Power_Up_Grimoire_Armor_High"]count = 2 # up to what the slot ships with; minor needs at least 2The shop screen shows two of each tier’s three offers (the offer, the copy
offer and the random magical object), so all three are slots you can fill.
medium_object and major_object ship with no item list — they sell a random
magical object of the listed rarities — and giving one items makes it sell
those instead (its weights switch to powerup, the only quality they have).
A slot whose list differs from the game’s gets its own pool tag
(RSMM_Shop_minor), and each chosen item gets that tag appended to its flags.
Nothing is removed from any item, so other vendors that read the same items are
unaffected.
and a *_duplicate slot only takes the powerups that copy an object.
Editing it from a config screen instead. Point the shop at an
item-grid field backed by the
shop-items provider, with one section per slot id, and the player edits
slots, counts and prices in the desktop app; the grid’s stored value replaces
slots and prices:
[[content]]kind = "shop"id = "sandman"config = "shop" # the item-grid field in config_schema.tomlMagical-object & talent values (value_patches)
Section titled “Magical-object & talent values (value_patches)”Edit the numbers inside a magical object or a hero talent (“Skill”). Discover
the editable labels + defaults first, then declare the edits in manifest.toml:
./rsmm items show Damage_Power # item value fields (+ [shadowed] markers)./rsmm talents Juliet # hero talent values (+ [shadowed/no-op])# Item: clone a vanilla magical object with patched values[[content]]kind = "item"id = "MyStrongerPower"base = "Damage_Power"value_patches = [ ["Power Crit Chance Value", 0.4, 0.1], # A shadowed value (see below) needs clear_override to take effect: { label = "Damage Value", old = 0.2, new = 0.5, clear_override = true },]
# Talent: patch a hero's cooked entity in place (plain override, no clone)[[content]]kind = "talent"hero = "Juliet"id = "JulietBuff"value_patches = [["Primary Ability Rose Explosion Damage Value", 8.0, 24.0]]Shadowed values. Some value nodes don’t use their inline number — the game
reads the value from a selector/curve (e.g. card-count scaling), so editing the
inline float is a silent no-op. rsmm items show / rsmm talents tag these
[shadowed], and apply errors if you patch one without
clear_override = true. Clearing the override makes the inline number
authoritative but unbinds the selector — e.g. per-card-stack scaling becomes a
flat value. That trade-off is intentional; pick the flat number you want.
Which stat an item’s effect gives (stats)
Section titled “Which stat an item’s effect gives (stats)”Each effect of a magical object (its always-on effect and its super effect)
changes one stat. stats points an effect at a different one, by the effect’s
name. rsmm items show <base> lists the effects, the stat each one gives, and
whether it is the super effect:
[[content]]kind = "item"id = "Damage_Per_Vitalitz"base = "Damage_Per_Vitality"name = "Ogre Crit"stats = { "Super Effect Modifier" = "Crit damage" }value_patches = [["Super Effect Crit Chance Value", 0.2, 0.3]]super_description = "#Crit Damage@ &+{0}%~"- Stat names are the engine’s own (
Attack power,Crit chance,Crit damage,Armour,Vitality,Move Speed Ratio, …); the full list isdata/stat_keys.json. A few stats have no name yet; give those by key ("0x58e0a5c"), asrsmm items showprints them. - The amount keeps its old unit. 0.2 crit chance becomes 0.2 of the new
stat, so change the number with
value_patchestoo. super_descriptiongives the copy super-effect text of its own (the base’s text still names the old stat). Items without a super effect (cursed, legendary, power-ups) refuse it.- Some stats need more than the stat. Plain numbers (attack power, armour,
crit, cooldowns, move speed) work anywhere. Flags like
Lightning explodeor the instant-kill thresholds only do something with the components the item that ships them carries.
A talent takes the same stats table, next to its file (effect names
repeat across a hero’s files):
[[content]]kind = "talent"id = "aladdin_wish_armour"hero = "Aladdin"file = "Hero_Aladdin.entity"stats = { "Ability Trait Wish 3 Shield Gain Modifier" = "Armour" }The Items and Talents tabs of rsmm editor and the
web editor show the same thing as a stat picker per
effect.
Lua-scripted mod
Section titled “Lua-scripted mod”The loader DLL (dist/winhttp.dll) runs init.lua once per launch in a sandboxed lua_State per mod.
./rsmm install-loader # Copy the DLL into the game installAdd to Steam launch options: WINEDLLOVERRIDES="winhttp=n,b" %command%.
Lua API exposed to mods:
-- Runtimersmm.log(msg)rsmm.mod_dir() -- this mod's directoryrsmm.game_dir() -- absolute install dirrsmm.is_in_main_menu() -- boolrsmm.list_mods() -- {id, name, version, author, enabled}[]rsmm.encoded_path(decoded) -- decoded -> encoded pathrsmm.decoded_path(encoded) -- encoded -> decoded pathrsmm.register_asset_override(decoded, src_abs_path)rsmm.commit() -- apply registered overridesrsmm.on_event(name, fn) -- "ready" | "exit"
-- Game function access (53k functions resolvable by name)rsmm.resolve(name) -- "FUN_xxx" -> runtime VArsmm.call(target, "sig", ...) -- invoke by signaturersmm.module_base() -- Ravenswatch.exe image basersmm.read_u8/u16/u32/u64/f32/f64(va) -- raw memory readrsmm.read_cstr(va, max) -- read NUL-terminated stringrsmm.write_u8/u16/u32/u64/f32/f64(va, v)See mods/ExampleLuaMod/init.lua and mods/ExampleSeedPin/init.lua for working examples. Full game-function API + caveats: docs/_re/CALLING_GAME_FUNCTIONS.md.
Events
Section titled “Events”R.on is the whole surface. Both engine buses are armed by default, so a mod
sees every event the game fires without the user touching launch options.
local h = R.on("gameplay:GIVE_MAGICAL_OBJECT", function(ev) ... end)R.on_match("^gameplay:ABILITY_", function(ev, name) ... end) -- whole familyR.once("run:start", function() ... end) -- fire onceR.off(h) -- unsubscribeR.emit("mymod:something", { n = 1 }) -- tell other modsFour sources land on the same bus:
| Source | Names | Notes |
|---|---|---|
| Lifecycle | setup, ready, tick, exit |
loader thread |
| Analytics firehose | run_start, enemy_killed, unlock_hero, … |
after the action, no live handles |
| Gameplay bus | gameplay:<NAME> |
at the action, live handles, game’s MAIN thread |
| Loader-derived | hero:captured, hero:changed, hero:lost, menu:enter, menu:leave, run:start, run:end |
loader thread |
ev.source says which ("analytics", "gameplay", "loader", "mod") — and
it is stamped by the loader, so it can be trusted. Only "gameplay" handlers
run on the game’s main thread; anything else must route engine-mutating work
through R.schedule.next_main (see the thread model).
150 gameplay events are catalogued — mined out of the shipped exe by
tools/mine_event_names.py and browsable without launching anything:
./rsmm symbols events # all of them, grouped by family./rsmm symbols events boss # BOSS_ACTIVATED, BOSS_DEFEATED, …A taste of what’s on the bus: BOSS_DEFEATED, OPEN_CHEST, HERO_REVIVE,
START_NIGHTMARE, WISHING_WELL_FILLED, USE_HEAL_FOUNTAIN,
UPGRADE_RANDOM_SKILL, DUPLICATE_RANDOM_EPIC_OBJECT, CHOOSE_MELODY,
MAP_GENERATION_DONE, TELEPORT_SUBMAP_ENTER, ENEMY_KILLED.
The same catalog is available inside the game, plus a live one of everything that has actually fired:
R.events.known("gameplay") -- the 150 static names, before anything firesR.events.category("BOSS_DEFEATED") --> "boss"
R.events.list("^gameplay:") -- sorted names seen THIS sessionR.events.count("enemy_killed")R.events.dump() -- log every event with its count + payload keysThe catalog is a browsing aid, not a whitelist: the loader reads the plaintext name off the event object, so a name a future patch adds fires too.
Event payloads
Section titled “Event payloads”Most events have no payload — and that is the engine’s design, not a gap
in ours. There are only ~24 oCGameNamedEvent subclasses that carry data;
every other name is dispatched as the bare base class, so all it can tell you
is that it happened, plus the dispatcher / entity handles saying to whom.
For the ~24 that do carry data, the layouts are recovered from the binary by
tools/mine_event_payloads.py (RTTI → vftable → the code that stores it) and
the loader decodes them by matching the event’s own vftable, so the match
is exact:
R.on("gameplay:NETWORK_DAMAGE", function(ev) R.log(ev.value) -- 12.5 (f32, hand-confirmed) R.log(ev.source_id) -- "0x2a1f…" (the attacker's NET id; handles are R.log(ev.dispatcher) -- hex strings, because a Lua numberend) -- would lose the low bits)Read the field names honestly: offsets and widths are recovered, meaning is
not. A field only gets a semantic name (value, source_id,
mo_guid_lo) where hand-RE confirmed it; everything else is mechanical —
u50 is “u32 at +0x50”, f6c is “float at +0x6c”. They are real fields at
real offsets, but what they mean is for you to pin down.
Two things help with that. ev.class tells you which struct you are looking
at, and the RSMM_EVENT_PROBE loader flag adds a raw window (ev.w38 … ev.w70) to every gameplay event — so a field gets pinned from Lua in one
session instead of a C++ rebuild per guess.
Decoding is gated on the build fingerprint: vftable addresses are
build-specific, so after a game update the loader falls back to the plain
envelope until the schemas are re-mined
(python tools/mine_event_payloads.py --verify).
Damage attribution (R.damage)
Section titled “Damage attribution (R.damage)”“Who is carrying the run?” is a question about damage per PLAYER, and no single
event answers it. R.damage merges the three places the engine produces a
damage number with an attacker attached, and hands you a live ranking:
R.damage.enable{ window = 10 } -- opt-in: it installs engine hooks
R.damage.on(function(hit) -- hit.label / hit.slot / hit.is_local / hit.amount / hit.source -- hit.kind == "dealt" (they hurt something) or "taken" (they got hurt)end)
for _, row in ipairs(R.damage.board()) do -- already sorted, row.rank set R.log(row.rank, row.label, row.dealt, row.share, row.dps, row.by_type.ultimate)end
R.damage.leader() -- the row on top right nowR.damage.engine_totals() -- the game's OWN totals for the local playerR.damage.reset() -- e.g. per run or per chapterEnemies only, or everything the game counts?
Section titled “Enemies only, or everything the game counts?”Fences, jars, vegetation and mission props are damageable entities, so damage dealt to them reaches the same bookkeeping hook a boss does — and the engine’s own end-screen total counts it. A player who clears a room of furniture can therefore out-rank one who fought. Opt out per meter:
R.damage.enable{ ignore_scenery = true } -- rank enemy damage onlyR.damage.ignore_scenery(true) -- or toggle it mid-runR.damage.scenery_total() -- what the filter droppedR.damage.is_enemy(entity) -- true / false / nil = unknownThe test walks the victim’s component map — an oCEntity keeps its
components in an F14 table (slots at entity+0x5f0, stride 0x10 =
{u32 class id, component*}, bucket mask at +0x600) keyed by the engine’s
32-bit class id. A gameplay enemy carries
oCDtEntityCpntEnemyController = 0x1561073c; destructible props carry only
Hittable + HitPoint. Class ids are mined by tools/mine_class_ids.py into
data/class_ids.json, and a class id is a hash of the class NAME, so it
survives a game patch that moves every address.
It is a page-guarded READ — never an engine call — so a stale offset gives a
wrong answer, never a crash, and an entity that cannot be read is nil
(unknown), which still counts: a failed read must never delete a player’s
real damage. Filtered damage is not lost either — it stays on the row as
row.scenery / row.scenery_hits.
The SDK default is off, because counting props is what agrees with the
game’s own total; filtering is a deliberate divergence a mod asks for. The
bundled damage-meter mod turns it on — a prop takes a flat 1.0 per hit, so
counting props distorts hit counts and DPS far more than damage.
| Source | Sees | Identity |
|---|---|---|
HeroStats_OnDamageDealt (hooked) |
every hero’s damage applied on this machine, allies included | hero controller |
Entity_ResolveAttackHits (hooked) |
attacks resolved locally — used for damage taken, and as a fallback | attacker entity |
gameplay:NETWORK_DAMAGE |
damage the target’s owner replicates to you | attacker net id |
The first source is what makes an ALLY’s damage countable. It is the engine’s
own per-hero bookkeeping, and it runs for every hero — the game just declines
to total anything for a hero that is not the local player (its +0x1d88
gate), which is why the end-of-run screen only ever shows your own numbers.
Hooking it read-only gives every hero’s damage, split by ability type
(row.by_type.attack / power / special / defense / trait / ultimate / dash).
The three views are unified per player: a row found by controller, by entity
and by net id is the same row, so nobody appears twice and share stays
honest. A replicated echo of a hit already counted locally is dropped, while
repeated identical hits from one source (a multi-hit flurry) are kept.
A player keeps ONE row across a chapter change, where the engine rebuilds every
hero controller: the row is re-adopted by hero id (exact — each player’s hero is
distinct) or, failing that, by the engine’s is-local byte. Both joins are gated
on a chapter epoch, bumped by GAME_END_NEXT_CHAPTER /
MAP_GENERATION_DONE: inside one chapter, a hero controller the meter has never
seen is a different player, never a rebuilt one. Without that gate the
third and fourth players to deal damage are adopted as “the same person again”
and a four-player board collapses to two rows — a merge deletes a player, while
the duplicate it prevents is visible and keeps everyone’s damage. A declined
merge is logged (refused to merge …), as is every row boarded, with the
local_byte and hero_id the joins were about to use.
Every hook is observation-only: it replays the original with the exact arguments it received and returns the engine’s own result, so no damage value, target list or event changes.
The damage-meter mod is the ready-made consumer: it reports to the loader log
and publishes an overlay once a second (see below), which feeds both
rsmm overlay damage-meter --watch and the desktop app’s overlay window.
Player names (R.player)
Section titled “Player names (R.player)”R.player.name() --> "Ovilli" (nil when Steam is unavailable)R.player.name_of(steamid64) --> a known account's name, or nilThe local player’s real display name, read from steam_api64.dll’s flat
API by the loader — no game structures, so it survives game patches. R.damage
uses it to label your own row instead of “You”.
Remote players are not named yet, and the reason is worth knowing before
you try: the game resolves an ally’s name from the party member’s user-data
JSON (steam.personaName, gamertag, Nickname, pseudo — FUN_140929940)
and stores it in the party-slot UI model, but nothing observed so far links a
party slot to the hero entity a damage row is keyed by. Pinning that link needs
a live co-op session. Until then, label unknown players by join order and let
the player rename them (the damage-meter mod exposes player_1..4 for exactly
that).
Overlays (R.overlay)
Section titled “Overlays (R.overlay)”The game gives a mod nowhere to draw a HUD, so a mod can publish one to the desktop client instead. Two halves:
Declare the shape in manifest.toml. The client renders exactly this:
[overlay]title = "Damage"icon = "swords" # from a fixed icon setsort = { key = "dealt", dir = "desc" }highlight = "is_local" # bool row key -> accented rowempty = "Waiting for a run."
[[overlay.columns]]key = "label"label = "Player"type = "text" # text | number | percent | bar
[[overlay.columns]]key = "dealt"label = "Damage"type = "number"format = "compact" # 48.2ksuffix = ""Publish the rows at runtime, at whatever cadence suits the mod:
R.overlay.publish{ rows = { { label = "You", dealt = 4821, share = 0.57, is_local = true } }, meta = { total = 8410 }, -- shown in the footer}R.overlay.clear() -- e.g. at a run boundaryRows are flat records of string/number/boolean — anything else is dropped. An unchanged payload is skipped, so publishing every tick costs nothing.
Then: the desktop app puts an Overlay button on the mod itself (library card,
list row, mod page) for every mod that declares one, and rsmm overlay <mod>
renders the same board in a terminal.
Hot-reload (Lua iteration < 5 seconds)
Section titled “Hot-reload (Lua iteration < 5 seconds)”Run ./rsmm watch in a side terminal while the game runs. On any save under mods/:
- Lints the mods you changed. A lint error holds the apply back and is printed, so a typo
never reaches the game (
--no-lintskips this). - Re-applies cooked overrides and syncs each enabled mod’s
manifest.toml,init.luaand other top-level Lua/data files into the game-dirmods/<id>/. - The loader polls those files every ~1 second, tears down the changed mod’s
lua_State, and re-runsinit.lua.
Tweak a number, hit save, see the result in-game without restarting.
Watch the live log:
./rsmm log -f --grep "lua\|reload"Expected output on a Lua-only edit:
[lua] ExampleSeedPin reload (init.lua changed)[lua] ExampleSeedPin init OK[SeedPin] forced seed = 12345 (enable=1) after 1 ticksReading the loader log
Section titled “Reading the loader log”The loader writes to <game>/mods/_log.txt. Read it from the repo:
./rsmm log # Full dump./rsmm log -n 80 # Last 80 lines./rsmm log -f # Follow live (Ctrl-C to stop)./rsmm log --grep lua # Filter (case-insensitive)./rsmm log --clear # Clear before a fresh launchLua errors print as [lua] <mod-id> ...; rsmm.log("msg") calls land in the same file.
Loader features a mod needs
Section titled “Loader features a mod needs”Some loader features are off until switched on, because they hook the engine
more deeply. The one mods need most is hero capture: without it R.entity,
R.stat, R.combat, R.xp, R.camera and the talent grants never find your
hero, and a mod that uses them silently does nothing. Declare what your mod needs
and rsmm apply switches it on while the mod is enabled (and off again when no
enabled mod needs it, leaving flags the player set alone):
[mod]id = "camera-control"loader_flags = ["RSMM_ENABLE_HERO_CAPTURE"]Only the flags the app’s Loader features panel lets a player switch on are
accepted; rsmm lint names them. In Lua, R.entity.capture_enabled() tells “the
player turned it off” apart from “the hero is not found yet” (loading, menus).
Don’t ship vanilla bytes
Section titled “Don’t ship vanilla bytes”rsmm pack <id> hashes every file against the original cooked asset. If any file is byte-identical to the original, pack refuses — shipping unmodified game bytes is redistribution of copyrighted game content, not a mod.
$ ./rsmm pack MyModrefusing to pack MyMod: contains files byte-identical to original game assets ... assets/Ui/BookMenu/Heroes/UI_HeroPortrait_Romeo_Active.png.Texture.dxt (matches original cooked asset)Fix: replace the listed files with your own modified bytes. --allow-vanilla bypasses the check for personal backup zips only.
The data/uncooked/ mirror is git-ignored for the same reason — it exists for local reference only (see Uncooked Assets).
Online play: client-only and gameplay mods
Section titled “Online play: client-only and gameplay mods”For online play, RSMM sorts every running mod into one of two groups:
- Client-only mods change what you see or read, never the game the party plays: a damage meter or other overlay, a camera change, a texture or sound swap, new UI text.
- Gameplay mods change the game: new or edited content (
[[content]]), data patches ([[patch]]), overrides of game data, or Lua that does more than read (R.give,R.stat.modify,R.hp.set,R.talent.grant,R.emit, …).
An install that runs only client-only mods counts as vanilla online. Run
rsmm modpack to see which group each of your mods is in, and why.
RSMM decides the group from what the mod contains, not from its
multiplayer_scope, so a wrong declaration cannot move a mod into the
client-only group. Lint warns when a mod declares cosmetic or local-only (or
nothing) but changes the game. Declare deterministic-shared or
host-authoritative for those.
For Lua, the client-only calls are the whole of R.log, R.on/R.off/R.once,
R.overlay, R.config, R.kv, R.schedule, R.exp, R.health, R.i18n,
R.damage and R.camera, plus these reads: R.entity.hero/ready/hp/hp_frac/max_hp,
R.stat.get/cached/keys/names, R.hp.get/frac/max and
R.events.known/count/category. Anything else, including R[...] lookups or
passing R to another function, counts as gameplay.
Load order
Section titled “Load order”When two mods override the same encoded path, the applier keeps the later mod by alphabetical id and warns. Explicit load-order control will come with the in-game UI. If order matters now, encode it: 10_Patch, 20_Skins, …
Content kinds & confidence
Section titled “Content kinds & confidence”Every content kind carries an honesty rating — how much we trust the bytes
it emits. The single source of truth is
src/rsmm/sdk/content.py::KIND_CONFIDENCE, which rsmm lint and the SDK
enforce. The per-kind table is generated from it:
Content kinds & confidence. This page links there
rather than restating a rating, because a hand-kept copy drifts the first time
a kind is proven in game. (tests/test_kind_docs_lockstep.py fails if a prose
table here pairs a kind="…" with a rating.)
Finer than a kind. Some kinds have modes proven to different degrees. The kind’s rating is the weakest mode it ships, so read these alongside it:
kind="enemy",mode="override": proven in-game 2026-08-28. A fixed-entity override turned every Dark Hills camp into treants, andcross_biomeplaced chapter-2/3 creatures in an earlier chapter. What’s still unknown is an imported creature’s projectiles and attack zones (see the caution above).kind="enemy",mode="clone": proven in-game 2026-09-24. A Gnolls-tribe clone with a Mud Crab body spawned in Storm Island gnoll camps. The def self-registers onto its tribe’s roster at load, soUsedRscListregistration is the whole contract. The trap:power(formerlyweight) is the clone’s cost against the camp’s power budget. At 20, the first attempt was priced out of every gnoll camp and never appeared. Leave it unset to keep the base’s cost.kind="item",mode="ban": the exact inverse of the proven catalog write. It drops entries from the same LiveOps MO vector. It hasn’t been confirmed in-game yet.kind="mesh"is proven, but an override is global: every tile that uses that mesh changes too. A character replacement also needs the righttransform.skin, or the model is shredded on the first animation frame.
Capabilities that are not content kinds:
| Capability | Rating | Reality |
|---|---|---|
| Replace a cooked file (raw / texture / model / stat / text / url patch) | ✅ confirmed | Install-time file replacement. Bread and butter. |
| PNG → cooked texture | ✅ confirmed | engine/cooked_schemas/texture.py cooks PNG/DDS/TGA into the oCTexture container at apply-time. |
| Reskin an existing hero (texture/model override) | ✅ confirmed | See JulietReskin. |
| New selectable skin slot | ⚠️ experimental | Needs the loader skin detour; the DLC-entitlement filter rejects new keys by default (RSMM_SKIN_FORCE_SHOW=1 to test). Replacing an existing slot is ✅ confirmed. |
Engine event hooks (R.on("OnDamage", …)) |
⚠️ experimental | The event bus + payload envelope ship in the loader, and emitter addresses are mapped — but the runtime path is not yet verified end-to-end on CI (loader is Windows-only). Treat as unproven until the loader smoke test (below) is green. |
Call any of 53k game functions from Lua (R.engine.call) |
✅ confirmed | Covers seed pinning, stat reads, save inspection, forced option overrides. Interception (hooks) is the experimental part above. |
Opting into unverified kinds. Registering any non-confirmed kind requires
sdk.Mod(..., experimental=True) (and the manifest records experimental = true);
otherwise the SDK raises and rsmm lint fails. This is deliberate — a ⚠️/❓
kind is a known guess, not a finished feature.
with sdk.Mod("MyMapMod", experimental=True) as m: # required for any non-confirmed kind m.map("Twilight_Hills", base="Dark_Hills")See docs/INTERNALS.md for the engine notes that ground all of the above, and docs/ROADMAP.md for open work.
Cooked-file inspector
Section titled “Cooked-file inspector”./rsmm decode <path-to-cooked-file> # Structural dump./rsmm decode <path> --raw # Include hex payloadsParses the class table + section structure. Won’t fully decode per-class property bodies (schemas live in Ravenswatch.exe) but prints enough to identify what you’d be modifying.
