Skip to content

rsmm.sdk.builder

Mod-builder used by from rsmm import sdk; with sdk.Mod(...) as m:.

Collects config/i18n/content/dependency declarations in memory, then materializes the whole mod on __exit__. One transactional write per mod build avoids half-built mod trees on the disk.

Mod.asset(self, decoded_path: 'str', source: 'str | Path') -> 'None'

Stage a source asset to override the game file at decoded_path.

decoded_path is the plaintext asset path as it appears in data/asset_map.json (forward slashes), e.g. 3D/Characters/Heroes/Melusine/Textures/T_Melusine_ALB.png. The file is copied verbatim into mods/<id>/assets/<decoded_path>; rsmm apply auto-cooks source formats (.glb/.png/.dds/…) into the cooked container and installs them. Pre-cooked inputs pass through.

Mod.boss(self, id: 'str', *, base: 'str', becomes: 'str | None' = None, **fields)

Make the arena of boss base spawn boss becomes instead.

Mod.enemy(self, id: 'str', *, base: 'str', name: 'str | None' = None, **fields)

Register a custom enemy cloned from vanilla base.

Mod.hero(self, id: 'str', *, base: 'str', name: 'str | None' = None, abilities: 'list | None' = None, **fields)

Register a custom hero cloned from vanilla base.

Mod.item(self, id: 'str', *, base: 'str', name: 'str | None' = None, **fields)

Register a custom item cloned from vanilla base.

Mod.map(self, id: 'str', *, base: 'str', **fields)

Register a custom map/level cloned from vanilla base.

Mod.model(self, decoded_path: 'str', source: 'str | Path', rotate_deg: 'tuple[float, float, float] | None' = None, scale: 'float | None' = None, skin: 'str | None' = None, fit: 'str | None' = None, submeshes: 'str | None' = None, bones: 'dict[str, str] | None' = None, drop_bones: 'list[str] | None' = None) -> 'None'

Override a mesh asset. Source must be a .glb/.gltf.

A custom mesh is cooked at apply-time by retargeting it onto the game asset’s skeleton (engine.geometry_cook). It is auto-scaled and recentered into the original’s space; orientation defaults to an auto-upright guess (tallest axis -> up). If the mesh comes out turned or upside down, pass rotate_deg=(x, y, z) — a rigid rotation in degrees applied before the fit (e.g. (90, 0, 0) to flip upright, (0, 180, 0) to face the other way).

The auto-fit matches only the tallest axis, so a mesh with a different aspect ratio than the original can come out too big or small. Pass scale= to multiply the auto-fit (e.g. scale=0.5 to halve it).

Replacing a SKINNED mesh — a character, not a prop — has one rule the fit cannot do for you: author your mesh in the original’s bind pose. The cooker has no bones of yours to use; it keeps the game’s skeleton and gives each of your vertices the weights of the game vertices nearest to it. That copy is positional, so an arm vertex that lands nearest a leg bone gets weighted to the leg, and the model tears itself apart the moment it animates. Line the limbs, head and torso up with the original in Blender before exporting.

skin="rigid" binds the whole mesh to a single bone instead: it follows the character but never deforms. Right for a prop or a static shape carried by a character, wrong for a body.

skin="gltf" uses the weights in your OWN .glb — the way to replace a character properly. Rig the mesh to the game’s skeleton in Blender (the bone names have to match the original’s) and export with the armature; bones are then bound by name and nothing is guessed from proximity, so the bind-pose rule above stops applying. It implies fit="rig" (no scale, no recentre), because a mesh rigged to the game’s skeleton is already in the right place. bones={"mine": "theirs"} renames on the way in, for a rig that came from elsewhere.

drop_bones=[...] deletes the geometry a bone drives — for a donor body carrying something that has to become a separate object, like a chain or a slung weapon. Needs skin="gltf", since only your own weights say which bone owns which triangle.

fit="none" keeps the mesh’s own size rather than matching the original’s height — what a structure usually wants, since its donor is a mounting point rather than a size reference. fit="rig" goes further and does not move the mesh at all.

submeshes="map" lays your submeshes onto the original’s one for one instead of merging them all into the first. A character is usually several submeshes because each one draws with a different material, and merging leaves the rest as empty stubs — so everything renders with the first material and your second texture never appears. The pairing is by order, so order your objects in Blender to match the original’s.

Mod.ot(self, selector: 'str', field: 'str', value, *, file: 'str | None' = None, selector_field: 'str | None' = None) -> 'None'

Change one field of a plaintext .ot the game ships uncooked.

selector picks the block by its m_sLabel (pass selector_field for a different key), field is the field inside that block, and value replaces what the file holds. The field must already exist — an ot patch edits, it never invents a field the engine would ignore.

file defaults to DarkTalesResources/ApplicationSettings.ot, the one uncooked file with anything worth changing in it (entity-value modifier descriptors, friendly-fire factors, the forced-seed option).

mod.ot("Merlin DMG Zone", "m_eComputerType", 0)

The game’s own bytes are the base, so this ships three strings instead of a copy of a game file, and survives a patch that edits other parts of the same file.

Mod.skinpack(self, name: 'str', key: 'int', *, ac_id: 'str' = '', al_id: 'str' = '', base_id: 'str' = '') -> 'None'

Register a custom selectable skin-pack slot.

The skin roster is hardcoded in the engine (a fixed 9 oCAdditionalContent “SkinPack” entries); see docs/_re/kinds/skins.md. A new slot can only be added at runtime, so this is realized by the native loader DLL (src/loader/src/hook_skins.cpp), which post-detours the roster builder and appends a standalone entry per def. Defs are staged to mods/<id>/skinpacks.json; the loader aggregates them across enabled mods.

Args: name: display name shown in the skin grid. key: pack key (int) the engine matches against a hero’s pack-id field (entry +0x3c). Must be unique across packs. ac_id: AC content id (entry +0x50), e.g. RW000PSAC000000A. al_id: AL content id (entry +0x60), e.g. RW000PSAL000000A. base_id: base content id (entry +0x70).

Note: registering the slot is verified, but the engine’s skin-grid populate (FUN_1401f0f10) only renders a node as a button when the manager filter (vtable[1]) returns 3. Two derived filter variants exist: the Steam build checks ISteamApps::BIsDLCEnabled via the node key (+0x3c); the local build checks a DLC entitlement file path at node+0x80. A brand-new key is rejected by both gates, so the slot is on the roster list but invisible by default. To prove the button path in-game, build the loader and run with RSMM_SKIN_FORCE_SHOW=1 — it force-pushes the node into the grid and logs the filter verdict (see docs/_re/kinds/skins.md A1/A2). The dlc_path field can be set in skinpacks.json for the local filter variant. Asset resolution for a new slot is likewise unconfirmed; stage the per-skin cooked assets with :meth:asset/:meth:texture/:meth:model, and until the resolver is mapped, prefer reusing an existing slot’s AC/AL id.

Mod.summary(self) -> 'dict'

Snapshot of everything staged so far — print before :meth:commit to see exactly what the mod will write (no disk writes).

Mod.tag(self, tag_id: 'str', members) -> 'None'

Group content into a named, cross-mod-extensible tag (Minecraft #namespace:path tags analog).

members is a ContentRef / id string, or an iterable of them. Calling tag() again with the same id appends (de-duped, order-preserved), so several mods — or several call sites — can grow one tag. Members are deref’d to raw ids, so you can pass the handles returned by :meth:item/:meth:enemy/… directly::

dagger = m.item("RubyDagger", base="Knife")
m.tag("daggers", [dagger, "VanillaKnife"])

Tags are written to mods/<id>/tags.json; downstream tooling/mods aggregate them (same model as skinpacks.json).

Mod.texture(self, decoded_path: 'str', source: 'str | Path') -> 'None'

Override a texture asset. Source must be a .png/.dds/.tga.

Mod.validate(self) -> 'list[str]'

Return human-readable warnings about the current state (does not raise). Empty list = clean. Called automatically by :meth:commit; run it yourself for a fast pre-flight.