Skip to content

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/&lt;id&gt;.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 new scaffolds it, the editor schema autocompletes it, and rsmm lint checks 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. Your build.py is 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 into rsmm.sdk and express the mod declaratively before shipping. rsmm lint (and CI) rejects any *.py in a mod except the sanctioned on_disable.py lifecycle hook. (The SDK build.py lives in the mod source but is a generator, not loaded at runtime; init.lua is the one sanctioned in-mod runtime script.)


Terminal window
# 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.zip

Content 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.png

Each 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.

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.json
chapters = ["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.

Independent of how you produce the manifest (hand-written TOML or the Python SDK), a mod changes the game in one of two ways:

Mirror decoded paths under assets/. Full control, byte-for-byte. One mod owns each file.

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 = 5
max = 10
[[patch]]
kind = "ot"
selector = "Merlin DMG Zone"
field = "m_eComputerType"
value = 0

The same blocks from the optional Python SDK — python3 mods/MyMod/build.py writes the manifest above:

mods/MyMod/build.py
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)

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 block
field = "m_eComputerType" # must already exist in that block
value = 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’s u16Type is 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 into i| 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.

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.


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 disabled
#: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 = true

Every key must be one something reads. A misspelled key used to install a mod that did nothing, so now:

  • rsmm lint fails 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 (Easy has min/max, not value), a non-number, or a texture path the game does not ship.
  • rsmm apply and 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.

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.

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.


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.

A mod is a with sdk.Mod(...) as m: block — calls accumulate in memory and the tree is written when the block exits.

mods/FrostPack/build.py
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."})
Terminal window
python mods/FrostPack/build.py # writes mods/FrostPack/manifest.toml + assets
rsmm list # see it registered
rsmm apply # install into the game

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 confirmed need experimental=True. Which ones those are is generated from the code: Content kinds & confidence.

m.tag("daggers", [blade, "VanillaKnife"]) # append-across-mods set; R.tags() in Lua
m.texture("3D/.../T_Melusine_ALB.png", "art/albedo.png") # PNG/DDS/TGA, auto-cooked
m.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 write

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 command
label = "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 above
title = "Pick what is on offer" # optional, your own copy
layout = "columns" # section groups side by side; default "stack"
quote = "Every dream has its price." # optional
number = { 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 heading
accepts = { role = "offer" } # option attributes that must ALL match
count = { label = "Offers per visit", min = 1, max = 4, default = 4 }
[fields.loadout.theme] # optional: dress it in the game's own art
panel = "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 source and may carry attrs. accepts filters on them; a provider marks where an option sits by default with attrs.defaultIn (a list of section ids); number.attr names the attribute holding the default number, and number.editable the boolean that says it may change.
  • empty on 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" takes config = "<field>", see Edit the Sandman shop.
  • Layout: stack puts section groups one under another; columns puts them side by side, each in its own panel frame with an optional separator between, 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 search finds them), either as a plain string or as { texture = "…", ink = "dark" }. ink (light by 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 only Ui/…png paths 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.

rsmm.sdk.testkit asserts over staged state without applying:

from rsmm import sdk
from 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 itself
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/

Terminal window
# 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 apply

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").

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 / size
mod.model(path, src, skin="gltf") # use YOUR OWN weights
mod.model(path, src, skin="rigid") # bind to ONE bone
mod.model(path, src, fit="none") # keep your own size
mod.model(path, src, submeshes="map") # keep the material split

The 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.

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 field

or directly in manifest.toml:

[[patch]]
kind = "stat"
name = "Bleed_Duration_Value"
value = 10

rsmm 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 there
mix = "shuffle" # each biome gets as many distinct creatures as it has
seed = 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 = true
repoint_pools = false # required to pull a creature across chapters
mix = "random" # how the cast is spread over the chapter's defs
seed = 1337
[content.casts]
Dark_Hills = ["Elite_Wolf_Alpha", "Standard_Reef_Crab", "Standard_Storm_Jinn"]
Storm_Island = ["Standard_Clawed_Treant"] # one monster = the whole chapter

A 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_biome the 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.ot and 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.

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.

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 optional
Minor_Heal = 10
Major_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 weight

What 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 count above the number of items in its pool adds nothing. The minor pool 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 *_duplicate and *_object generators 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 HigherPrices game 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 2

The 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.toml

Magical-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:

Terminal window
./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 is data/stat_keys.json. A few stats have no name yet; give those by key ("0x58e0a5c"), as rsmm items show prints them.
  • The amount keeps its old unit. 0.2 crit chance becomes 0.2 of the new stat, so change the number with value_patches too.
  • super_description gives 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 explode or 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.

The loader DLL (dist/winhttp.dll) runs init.lua once per launch in a sandboxed lua_State per mod.

Terminal window
./rsmm install-loader # Copy the DLL into the game install

Add to Steam launch options: WINEDLLOVERRIDES="winhttp=n,b" %command%.

Lua API exposed to mods:

-- Runtime
rsmm.log(msg)
rsmm.mod_dir() -- this mod's directory
rsmm.game_dir() -- absolute install dir
rsmm.is_in_main_menu() -- bool
rsmm.list_mods() -- {id, name, version, author, enabled}[]
rsmm.encoded_path(decoded) -- decoded -> encoded path
rsmm.decoded_path(encoded) -- encoded -> decoded path
rsmm.register_asset_override(decoded, src_abs_path)
rsmm.commit() -- apply registered overrides
rsmm.on_event(name, fn) -- "ready" | "exit"
-- Game function access (53k functions resolvable by name)
rsmm.resolve(name) -- "FUN_xxx" -> runtime VA
rsmm.call(target, "sig", ...) -- invoke by signature
rsmm.module_base() -- Ravenswatch.exe image base
rsmm.read_u8/u16/u32/u64/f32/f64(va) -- raw memory read
rsmm.read_cstr(va, max) -- read NUL-terminated string
rsmm.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.

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 family
R.once("run:start", function() ... end) -- fire once
R.off(h) -- unsubscribe
R.emit("mymod:something", { n = 1 }) -- tell other mods

Four 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:

Terminal window
./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 fires
R.events.category("BOSS_DEFEATED") --> "boss"
R.events.list("^gameplay:") -- sorted names seen THIS session
R.events.count("enemy_killed")
R.events.dump() -- log every event with its count + payload keys

The 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.

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 number
end) -- 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).

“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 now
R.damage.engine_totals() -- the game's OWN totals for the local player
R.damage.reset() -- e.g. per run or per chapter

Enemies 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 only
R.damage.ignore_scenery(true) -- or toggle it mid-run
R.damage.scenery_total() -- what the filter dropped
R.damage.is_enemy(entity) -- true / false / nil = unknown

The 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.

R.player.name() --> "Ovilli" (nil when Steam is unavailable)
R.player.name_of(steamid64) --> a known account's name, or nil

The 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).

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 set
sort = { key = "dealt", dir = "desc" }
highlight = "is_local" # bool row key -> accented row
empty = "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.2k
suffix = ""

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 boundary

Rows 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.

Run ./rsmm watch in a side terminal while the game runs. On any save under mods/:

  1. Lints the mods you changed. A lint error holds the apply back and is printed, so a typo never reaches the game (--no-lint skips this).
  2. Re-applies cooked overrides and syncs each enabled mod’s manifest.toml, init.lua and other top-level Lua/data files into the game-dir mods/<id>/.
  3. The loader polls those files every ~1 second, tears down the changed mod’s lua_State, and re-runs init.lua.

Tweak a number, hit save, see the result in-game without restarting.

Watch the live log:

Terminal window
./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 ticks

The loader writes to <game>/mods/_log.txt. Read it from the repo:

Terminal window
./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 launch

Lua errors print as [lua] <mod-id> ...; rsmm.log("msg") calls land in the same file.


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).


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 MyMod
refusing 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.

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, …


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, and cross_biome placed 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, so UsedRscList registration is the whole contract. The trap: power (formerly weight) 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 right transform.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.


Terminal window
./rsmm decode <path-to-cooked-file> # Structural dump
./rsmm decode <path> --raw # Include hex payloads

Parses 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.