API Reference
The current API version is 1.0.1. Mods declare via manifest.apiVersion the version that
introduced the newest thing they use — an older editor refuses a mod that asks for more than it
provides. See api-changelog.md for what landed in each version.
The TypeScript source of truth is mod-api.d.ts.
This document mirrors that file.
ctx (Mod Context)
The ctx argument passed to activate(ctx). Internally typed as ModContext.
All mod capabilities are reached through it.
| Field | Type | Purpose |
|---|---|---|
apiVersion | string | Echoes the editor’s API version. |
manifest | Readonly<Manifest> | This mod’s manifest. |
editor | EditorCtx | Active map / layer / tool state. |
map | MapCtx | Read and write tiles, query layers. |
tileset | TilesetCtx | Tileset images and tile properties. |
shadow | ShadowCtx | Shadow layer queries. |
fog | FogCtx | Fog layer queries and config (above-tile graphic layer group). |
panorama | FogCtx | Panorama layers — same surface as fog, beneath the tiles. |
layerGroups | LayerGroupsCtx | Custom graphic layer groups with mod-defined priorities. |
events | EventsCtx | RMXP-style events on a map. |
tools | ToolsCtx | Register custom editing tools. |
menu | MenuCtx | Add menu items. |
commands | CommandsCtx | Cross-mod command registry. |
ui | UiCtx | Panels, dialogs, toasts, and injecting UI into built-in editor screens. |
bus | BusCtx | Subscribe to / emit events. |
fs | FsCtx | Path-scoped filesystem. |
storage | StorageCtx | Per-mod persistent K/V. |
clipboard | ClipboardCtx | OS text clipboard read/write (not the tile clipboard). |
stats | StatsCtx | Read editor usage statistics. |
keybinds | KeybindsCtx | Query and modify keyboard shortcuts. |
i18n | I18nCtx | Translate the mod’s own strings, register whole app locales. |
selectors | SelectorsCtx | Modal pickers (actor, item, switch, map, tileset, audio, graphic, …). |
projectData | ProjectDataCtx | Read-only RPG record lists (actors, items, switches, …). |
mods | ModsCtx | Query other installed mods (presence, status). |
plugins | PluginsCtx | Query installed Essentials plugins. |
simulator | SimulatorCtx | Teach the Game Simulator Script commands and unimplemented codes. |
log | LogCtx | Namespaced logger. |
lifecycle | LifecycleCtx | Activation hooks. |
editor
editor.version(): stringeditor.activeMapId(): number | nulleditor.activeLayerIndex(): numbereditor.activeTool(): stringeditor.setTool(toolId: string): voideditor.setActiveLayer(index: number): voideditor.listOpenMaps(): number[]editor.gameRoot(): string | nulleditor.brushSize(): numbereditor.setBrushSize(size: number): voideditor.brushTileProperties(): { rotation, flipH, flipV, opacity, hue, saturation, lighting }editor.setBrushTileProperties(props: Partial<{...}>): voideditor.hoverTile(): { x, y } | nulleditor.viewport(): { x, y, zoom }editor.setViewport(viewport: { x?, y?, zoom? }): voideditor.viewOptions(): { showGrid, showCollision, showEvents, showEventCells, showDim, darkMode }editor.setViewOptions(opts: Partial<{...}>): voidRead/write access to editor state. version() returns the running editor’s
version (e.g. "1.0.0") — the same value the in-app updater compares against;
handy for gating features behind a minStudioVersion. setTool accepts both
built-in ids ("brush", "eraser", …) and ids of mod-registered tools.
setBrushSize changes the active brush size. setBrushTileProperties merges
partial props into the current brush. setViewport pans/zooms the active map.
editor.theme(): "dark" | "light"editor.animationFrame(): numbereditor.requestRedraw(): voideditor.setStatusBarText(text: string | null): voideditor.recentMaps(): number[]editor.projectName(): string | nulltheme() is a shorthand for viewOptions().darkMode ? "dark" : "light".
animationFrame() returns a coarse counter (~10fps) useful for overlay animation.
requestRedraw() fires a mod:requestRedraw DOM event for canvas re-renders.
setStatusBarText(text) sets a transient mod-owned status message (pass null to clear). Auto-cleared on mod unload.
recentMaps() returns open map IDs most-recent first. projectName() returns the game folder name.
map
map.info(mapId): PublicMapInfo | nullmap.layers(mapId): PublicLayerInfo[]map.readTile(mapId, layer, x, y): numbermap.writeTile(mapId, layer, x, y, tileId, label?): voidmap.batchWrite(mapId, layer, tiles: Map<"x,y", tileId>, label): voidmap.selection(mapId): { bounds, count }map.readTileData(mapId, layer, x, y): PublicTileData | nullmap.writeTileData(mapId, layer, x, y, data, label?): voidmap.selectionTiles(mapId): Array<{ x, y, tileId, layerIndex }> | nullmap.setSelection(mapId, tiles: Array<{ x, y }> | null): voidmap.addLayer(mapId, name?): numbermap.removeLayer(mapId, layerIndex): voidmap.renameLayer(mapId, layerIndex, name): voidmap.setLayerVisible(mapId, layerIndex, visible): voidmap.setLayerOpacity(mapId, layerIndex, opacity): voidmap.createMap(opts: { width?, height?, tilesetId?, name?, parentId? }): Promise<number>map.deleteMap(mapId): Promise<void>map.resize(mapId, newWidth, newHeight, shiftX?, shiftY?): voidmap.renameMap(mapId, name): voidmap.reparentMap(mapId, parentId): Promise<void>map.beginUndoGroup(label): voidmap.endUndoGroup(): voidwriteTile and batchWrite go through the editor’s normal undo/redo
pipeline — your changes are undoable like any built-in edit.
writeTileData writes full per-tile properties (rotation, flip, opacity, etc.)
on extended layers. setSelection creates or clears a tile selection.
Layer management operates on extended layers (3+). createMap creates a new
map file and registers it in MapInfos. deleteMap removes the map file.
map.transformSelection(mapId, opts: { rotation?: 0|90|180|270; flipH?: boolean; flipV?: boolean; incremental?: boolean; colorOverride?: { hue?, saturation?, lighting?, opacity? };}): voidmap.recalculateAutotiles(mapId, layerIndex, tiles: Array<{x,y}>): voidmap.getNativeTileProperties(mapId, layer, x, y): NativeTileProps | nullmap.setNativeTileProperties(mapId, layer, x, y, props: Partial<NativeTileProps>): voidmap.getClipboard(): PublicClipboardData | nullmap.setClipboard(data: PublicClipboardData | null): voidmap.createUndoScope(mapId, label): UndoScopetransformSelection rotates/flips/recolors the current selection, permuting tile positions so the group visually rotates.
recalculateAutotiles fixes autotile seams after bulk writes — call it with the affected tiles.
getNativeTileProperties / setNativeTileProperties read and write per-tile properties on native layers (0-2).
getClipboard / setClipboard read and replace the tile clipboard; setClipboard(null) clears it.
createUndoScope returns a scope object — call .write()/.writeData() to queue writes, then .commit() to apply them as a single undo entry (or .abort() to discard).
tileset
tileset.getImageBlobUrl(tilesetId): Promise<string | null>tileset.getTileProperties(tilesetId, tileId): { passage, priority, terrainTag } | nulltileset.list(): Array<{ id, name, tilesetName }>tileset.info(tilesetId): { id, name, tilesetName, autotileNames, panoramaName, fogName, battlebackName } | nulltileset.setTileProperties(tilesetId, tileId, { passage?, priority?, terrainTag? }): Promise<void>tileset.currentId(): number | nulltileset.current(): { id, name, tilesetName, autotileNames, panoramaName, fogName, battlebackName } | nulltileset.mapTilesetId(mapId): number | nullThe blob URL stays valid for the lifetime of the editor session.
list returns all loaded tilesets. info returns tileset metadata including
the autotile name array, panorama, fog, and battleback names.
currentId returns the tileset currently selected in the tile palette
(not the map’s assigned tileset). Returns null if no map is open.
current returns full info of the currently selected tileset. Equivalent
to tileset.info(tileset.currentId()).
mapTilesetId returns the tileset ID assigned to a specific map.
Returns null if the map is not loaded.
tileset.transformTileProperties(tilesetId, tileId, { rotation?, flipH?, flipV? }): { passage, priority, terrainTag } | nulltileset.resolveTileProperties(tileId, tilesetId?): { passage, priority, terrainTag } | nulltransformTileProperties returns passage/priority/terrain adjusted for the given rotation/flip — passage direction bits are rotated to match the visual transform.
resolveTileProperties resolves tile properties from any tileset (falls back to the active palette tileset when tilesetId is omitted).
tileset.registerTerrainTag({ id, name }): Disposabletileset.registerPriority({ id, name, color? }): DisposableregisterTerrainTag / registerPriority add a named entry to the Tileset
Editor’s Terrain Tag / Priority dropdowns. id is the integer written
verbatim to @terrain_tags / @priorities (Table1, i16, no clamp) when the user
paints that value. Built-in ids are 0–17 for terrain tags (the Pokemon
Essentials defaults) and 0–5 for priorities (0 = ground, 1–5 = tiles
overhead), so use 18+ / 6+ for custom entries to avoid clobbering them.
Duplicate ids are ignored (first registration wins). Both return a Disposable
and are auto-removed when the mod unloads.
Priority mode is colour-coded per level: the editor cycles a five-colour list
(yellow / orange / pink / purple / blue) for priority 1 and up, so a registered
priority past 5 continues the cycle — id 6 reuses the colour of 1, and so on.
Pass color (any CSS colour) to give your level its own instead. Either way
the colour paints the marker drawn on the tile and the chip beside the value in
the dropdown. Terrain tags have no colour.
The editor only contributes the picker label + selectable range — there is no
runtime dispatcher. To give a tag in-game behavior, read it back in your game
scripts ($game_map.terrain_tag(x, y) — a plain Integer in RMXP/BES/LBDS; resolved
through PBTerrain / GameData::TerrainTag in Pokemon Essentials) and branch on
the value. See the custom-tile-properties example mod.
tileset.setGlyphStyle(style: Partial<TilesetGlyphStyle>): DisposablesetGlyphStyle restyles the markers the Tileset Editor draws over each tile —
the passage dot/cross, the priority star, the bush B, the terrain number.
Every field is optional and merged over the defaults, so a mod that only wants
different priority colours passes only priority:
ctx.tileset.setGlyphStyle({ priority: ["#ffcc00", "#ff8a3d", "#ff5c8a"], // levels 1..n, cycles past the end passageBlocked: "#ff0000", shadowBlur: 0, // no drop shadow});| Field | Type | Default | Marks |
|---|---|---|---|
passageOpen | CSS colour | #66ff66 | passage fully open |
passageBlocked | CSS colour | #ff4444 | passage fully blocked |
passagePartial | CSS colour | #ffcc00 | some directions blocked |
bush | CSS colour | #66ff66 | bush flag |
counter | CSS colour | #66aaff | counter flag |
terrain | CSS colour | #ff66aa | terrain tag number |
priority | CSS colour[] | ["#ffcc00","#ff8a3d","#ff5c8a","#b48cff","#4fc3f7"] | priority levels 1..n — cycles past the end of the list |
neutral | CSS colour | rgba(255,255,255,0.5) | ”nothing set here”: priority 0, bush/counter off, terrain 0 |
shadowColor | CSS colour | rgba(0,0,0,0.9) | drop shadow hugging the marker’s outline |
shadowBlur | number | 1/11 | shadow blur as a fraction of the tile cell size; 0 disables it |
strokeWidth | number | 1/14 | marker stroke width as a fraction of the cell size |
A priority registered with its own color still wins over the priority list
for that one level. An empty priority array is ignored (the cycle needs at
least one colour). When several mods set a style, later registrations win per
field, so two mods can each own a different part of the look. Returns a
Disposable and is auto-removed when the mod unloads, restoring the defaults.
selectors
Modal pickers that mount the editor’s stock selector dialogs. Every method
returns a Promise that resolves to the picked value or null when the
user cancels.
// RPG records (1-indexed; index 0 reserved for "nil")selectors.pickActor(opts?): Promise<EntityPickResult | null>selectors.pickClass(opts?): Promise<EntityPickResult | null>selectors.pickSkill(opts?): Promise<EntityPickResult | null>selectors.pickItem(opts?): Promise<EntityPickResult | null>selectors.pickWeapon(opts?): Promise<EntityPickResult | null>selectors.pickArmor(opts?): Promise<EntityPickResult | null>selectors.pickEnemy(opts?): Promise<EntityPickResult | null>selectors.pickTroop(opts?): Promise<EntityPickResult | null>selectors.pickState(opts?): Promise<EntityPickResult | null>selectors.pickAnimation(opts?): Promise<EntityPickResult | null>selectors.pickCommonEvent(opts?): Promise<EntityPickResult | null>selectors.pickEntity(kind, opts?): Promise<EntityPickResult | null>
// Systemselectors.pickSwitch(opts?): Promise<EntityPickResult | null>selectors.pickVariable(opts?): Promise<EntityPickResult | null>
// Map / event / tilesetselectors.pickMap(opts?: { value?, title?, includeCurrentMap? }): Promise<EntityPickResult | null>selectors.pickEvent(mapId, opts?: { value?, title?, includePlayer?, includeThisEvent? }): Promise<EntityPickResult | null>selectors.pickTileset(opts?): Promise<PublicTilesetInfo | null>
// File-backedselectors.pickAudio("BGM"|"BGS"|"ME"|"SE", opts?: { initial?, title? }): Promise<AudioPickResult | null>selectors.pickGraphic(subfolder, opts?: { initial?, fields?: GraphicField[], extraFields?: GraphicPickerField[], showGrid?, allowTileSelect?, showHue?, title? }): Promise<GraphicPickResult | null>
// Inputselectors.pickKeyboardButton(opts?: { value? }): Promise<KeyboardButtonPickResult | null>EntityPickResult = { id, name }. AudioPickResult = { name, volume, pitch }.
GraphicPickResult = { name, hue, opacity?, blend?, direction?, pattern?, sheetCols?, sheetRows?, srcRect?, extra? } — the optional props are present only when their fields entry was enabled (custom extraFields values come back under extra, keyed by field key). srcRect ({ x, y, w, h }) appears only with allowTileSelect, and only once the user chose a tileset: it is the tiles they picked, expressed in source-image pixels (always a whole number of 32px tiles). w/h of 0 means the whole image. KeyboardButtonPickResult = { code, label }.
For RPG-record pickers, opts.extras: SelectorExtra[] lets you place synthetic rows
above the real list (e.g. { id: 0, label: "Entire Party" } for actor target params).
For pickEvent, includePlayer adds id -1 and includeThisEvent adds id 0.
// Example — let the user pick an item, then award itconst item = await ctx.selectors.pickItem({ title: "Reward" });if (item) ctx.ui.showToast({ message: `You got ${item.name}!`, level: "info" });
// Example — pick a graphic from Graphics/Pictures with hue sliderconst pic = await ctx.selectors.pickGraphic("Pictures", { showHue: true });if (pic) console.log(pic.name, pic.hue);
// Example — pick a character sheet with the full property set + a custom field.// `fields` enables built-in controls; `extraFields` adds your own (returned in `.extra`).const ch = await ctx.selectors.pickGraphic("Characters", { showGrid: true, fields: ["hue", "opacity", "blend", "direction", "pattern", "sheetCols", "sheetRows"], extraFields: [{ key: "zHeight", label: "Z Height", control: "number", min: 0, default: 0 }],});if (ch) console.log(ch.name, ch.direction, ch.pattern, ch.sheetCols, ch.extra?.zHeight);
// Example — let the user pick tiles off a tileset. The tile grid appears because// the chosen graphic is a tileset; picking sets sheetCols/sheetRows to match.// Needs the MakerStudio plugin in-game for the selection to be honoured there.const tile = await ctx.selectors.pickGraphic("Tilesets", { allowTileSelect: true, fields: ["sheetCols", "sheetRows"],});if (tile?.srcRect?.w) console.log(tile.name, tile.srcRect); // { x, y, w, h } in pixels
// Example — pick BGM with custom initial volumeconst bgm = await ctx.selectors.pickAudio("BGM", { initial: { name: "001-Battle01", volume: 80, pitch: 100 },});Selectors mount the same React components used by the built-in event editor
(EntitySelector, EventSelector, MapSelector, TilesetSelector,
SwitchPicker, KeyboardButtonSelector, AudioSelector, GraphicSelector).
Only one selector can be active at a time — opening a second one before the
first resolves will replace it (the first resolves with null).
projectData
Read-only views of the project-wide RPG record lists loaded from .rxdata.
Use these when you want to enumerate or look up records without showing a
modal picker. Lists are 1-indexed: index 0 is the reserved nil slot
({ id: 0, name: "" }), and IDs map directly to list indices.
projectData.actors(): readonly PublicRpgRecord[]projectData.classes(): readonly PublicRpgRecord[]projectData.skills(): readonly PublicRpgRecord[]projectData.items(): readonly PublicRpgRecord[]projectData.weapons(): readonly PublicRpgRecord[]projectData.armors(): readonly PublicRpgRecord[]projectData.enemies(): readonly PublicRpgRecord[]projectData.troops(): readonly PublicRpgRecord[]projectData.states(): readonly PublicRpgRecord[]projectData.animations(): readonly PublicRpgRecord[]projectData.commonEvents(): readonly PublicRpgRecord[]projectData.records(kind: PublicRecordKind): readonly PublicRpgRecord[]projectData.getRecord(kind, id): PublicRpgRecord | null
projectData.switchNames(): readonly string[]projectData.variableNames(): readonly string[]projectData.maps(): ReadonlyArray<{ id, name, parentId, order }>PublicRpgRecord = { id, name, iconName? }. PublicRecordKind = "actor" | "class" | "skill" | "item" | "weapon" | "armor" | "enemy" | "troop" | "state" | "animation" | "common_event".
// Example — find all common events whose name starts with "boss_"const boss = ctx.projectData.commonEvents().filter((c) => c.name.startsWith("boss_"));mods
Query the other mods the editor currently knows about — for soft/optional
dependencies, feature detection, or integrating with a companion mod. (Hard
dependencies that should block your mod from loading belong in
manifest.requires instead.)
mods.list(): InstalledModInfo[] // every known mod, incl. this onemods.get(id: string): InstalledModInfo | nullmods.isInstalled(id: string): boolean // present in any statusmods.isActive(id: string): boolean // present AND loaded/runninginterface InstalledModInfo { id: string; name: string; version: string; enabled: boolean; // false if user-disabled status: "active" | "error" | "disabled"; // blocked mods are not listed source: "project" | "global";}The snapshot is read-only and reflects the current load cycle. Mods blocked
before load (missing dependency, plugin version block, duplicate id) are not
listed — only mods that loaded (active), failed to activate (error), or were
switched off by the user (disabled).
// Example — only register an integration when a companion mod is active.if (ctx.mods.isActive("com.author.coremod")) { ctx.commands.execute("com.author.coremod:registerExtension", myExtension);}plugins
Query the Essentials plugins installed under <gameRoot>/Plugins/ (Ruby). Same
data the editor uses to validate requires plugin entries, exposed for runtime
feature detection.
plugins.available(): boolean // false on v16/BES (no Plugins/ dir)plugins.list(): InstalledPluginInfo[] // empty when not availableplugins.get(name: string): InstalledPluginInfo | nullplugins.isInstalled(name: string): booleaninterface InstalledPluginInfo { name: string; // meta.txt Name version: string | null; // Version, or null if none declared essentials: string[]; // Essentials — compatible versions, e.g. ["19.1","20"] link: string | null; // Link — homepage / download URL credits: string[]; // Credits — authors requires: PluginRequirement[]; // Requires — needed plugins (any/min version) exact: PluginRequirement[]; // Exact — needed plugins at an exact version optional: PluginRequirement[]; // Optional — load before this if installed conflicts: string[]; // Conflicts — incompatible plugin names}
interface PluginRequirement { name: string; version: string | null; // min version (requires) / exact version (exact), or null}Every field of the plugin’s meta.txt is exposed. Repeatable fields
(Requires, Exact, Optional, Conflicts) collect every line; comma-list
fields (Essentials, Credits) are split and trimmed. Requires / Exact /
Optional values parse as Name or Name,version.
available() is false on projects with no Plugins/ directory (RMXP v16 /
Base Essentials v5), where plugin presence can’t be verified — in that case the
other queries always report “not installed”. Names match the plugin’s meta.txt
Name field (the same string you’d put in a requires plugin entry).
// Example — adapt behaviour to an installed plugin's version.const fp = ctx.plugins.get("Following Pokemon EX");if (fp && fp.version && fp.version >= "1.5.0") { // use the newer plugin feature}Manifest: requires
Mods declare everything they depend on — other installed mods and Essentials
plugins (Ruby) — through one unified requires array in manifest.json. Each
entry is a discriminated union on type:
{ "requires": [ { "type": "mod", "id": "com.author.coremod" }, { "type": "mod", "id": "com.author.utils", "version": "^1.2.0" },
{ "type": "plugin", "name": "My Plugin" }, { "type": "plugin", "name": "Other Plugin", "url": "https://example.com/other" }, { "type": "plugin", "name": "Strict Plugin", "enforcement": "pluginAndVersion", "version": "1.2.0" }, { "type": "plugin", "name": "Optional Plugin", "enforcement": "none", "url": "https://example.com/opt" } ]}type ModRequirement = ModDependency | PluginDependency;
/** A dependency on another installed mod. */interface ModDependency { type: "mod"; /** Reverse-DNS id of the required mod (its manifest `id`). */ id: string; /** * Optional semver range. Recorded for tooling; the loader enforces presence * (the mod is installed and load-ordered first), not the range, in v1. */ version?: string;}
/** A dependency on an Essentials plugin (installed under `<gameRoot>/Plugins/`). */interface PluginDependency { type: "plugin"; /** Plugin name as it appears in meta.txt's Name field. */ name: string; /** URL where the plugin can be found/downloaded (shown in warnings). */ url?: string; /** * How strictly to enforce this dependency. * - "plugin" (default) — block if the plugin is missing; ignore version. * - "pluginAndVersion" — block if missing OR version doesn't satisfy versionCheck. * - "none" — never block, only warn. */ enforcement?: "plugin" | "pluginAndVersion" | "none"; /** * Required plugin version (semver). Only checked when enforcement is * "pluginAndVersion". Setting enforcement to "pluginAndVersion" without * a version is a ManifestError. */ version?: string; /** * How to compare installed version against version. Default: "greaterOrEqual". * - "greaterOrEqual" — installed >= required * - "exact" — installed == required * - "compatible" — same major, installed minor.patch >= required */ versionCheck?: "greaterOrEqual" | "exact" | "compatible";}Behaviour at load time:
Mod dependencies (type: "mod"): the editor topologically sorts mods so each
dependency loads before the mods that need it. If a required mod id isn’t
installed, the dependent mod is blocked with missing dependency "<id>"; a
dependency cycle blocks every mod in the cycle. The version range is recorded
but not enforced in v1 (presence is what’s checked).
Plugin dependencies (type: "plugin"):
- v21+ projects (Plugins/ directory exists): the editor scans
Plugins/*/meta.txtand checks each declared dependency according to itsenforcementsetting:"plugin"(default): blocks the mod if the plugin is missing; version is ignored."pluginAndVersion": blocks the mod if the plugin is missing or its installed version does not satisfy theversionCheckcomparison againstversion. Setting this without aversionis aManifestError."none": never blocks the mod; the editor only logs a warning.
- v16 / BES v5 projects (no Plugins/ directory): the editor logs a console warning but loads the mod anyway, since plugin presence cannot be verified.
The version comparison uses simple semver major.minor.patch (pre-release
suffixes are ignored). The versionCheck field controls the comparison
operator: "greaterOrEqual" (default, installed >= required), "exact"
(installed == required), or "compatible" (same major, installed
minor.patch >= required).
In the Mod Manager’s expanded detail view, plugin dependencies are listed with
clickable links (if url is provided), the enforcement level, and the version
requirement (when applicable).
Multi-File Mod Structure
Mods can split code across multiple .js files using native ES module import:
export function helper() { return 42; }
// index.js (entry — matches manifest "main")import { helper } from './utils.js';
export function activate(ctx) { ctx.ui.showToast({ message: `Answer: ${helper()}`, level: "info" });}How it works: the editor discovers all .js files in your mod folder, builds a dependency graph from import/from specifiers, sorts them topologically (leaf modules first), creates blob URLs bottom-up while rewriting relative imports to blob URLs, then imports your entry module. No manifest changes required — just add .js files alongside your entry.
Rules:
- Only
.jsfiles in the mod directory are discovered (subdirectories included). - Only relative specifiers starting with
./or../are rewritten. Bare specifiers (e.g.import _ from 'lodash') are left as-is and will fail at runtime. - Circular dependencies are handled gracefully (the cycle is broken and modules still load).
- CommonJS mods (
module.exports = ...) remain single-file only — thenew Functionfallback does not support multi-file. - Single-file mods work exactly as before — no changes needed.
Direct Tauri Access
Mods run in the same web context as the editor. When withGlobalTauri
is enabled (it is by default), window.__TAURI__.core.invoke is
available for calling any registered Tauri command — including ones not
exposed through the ctx API.
const invoke = window.__TAURI__.core.invoke;
// Call any registered Tauri commandconst bytes = await invoke("read_binary_file", { path: "/path/to/file" });This is useful when a mod needs capabilities beyond what ctx provides.
See Available Tauri Commands below.
Available Tauri Commands for Mods
These general-purpose Rust commands can be called via
window.__TAURI__.core.invoke. They complement the scoped ctx.fs API
with binary and file-management operations.
File I/O
| Command | Args | Returns | Purpose |
|---|---|---|---|
read_text_file | path | string | Read file as text |
write_text_file | path, content | void | Write text file |
read_binary_file | path | number[] | Read file as raw bytes |
write_binary_file | path, data | void | Write raw bytes |
File Management
| Command | Args | Returns | Purpose |
|---|---|---|---|
list_directory | path | FileEntry[] | List directory entries. Each entry: { name, path, isFile, isDir, size } |
file_exists | path | boolean | Check if a file or directory exists |
copy_file | src, dst | void | Copy file (creates parent dirs) |
rename_file | oldPath, newPath | void | Rename or move a file (creates parent dirs) |
delete_file | path | void | Delete a single file (refuses directories) |
Image
| Command | Args | Returns | Purpose |
|---|---|---|---|
get_image_dimensions | path | [width, height] | Get image dimensions without full decode |
get_tileset_image | gameRoot, tilesetName | number[] (PNG) | Get tileset image as PNG bytes |
get_tileset_info | gameRoot, tilesetName | [w, h] | Tileset image dimensions |
list_autotile_files | gameRoot | string[] | List autotile names in Graphics/Autotiles/ |
list_tileset_files | gameRoot | string[] | List tileset image names in Graphics/Tilesets/ |
list_character_files | gameRoot | string[] | List character sheet names |
list_graphic_files | gameRoot, folder | string[] | List image names (sans extension) in Graphics/<folder>/ — folder must be a single path component (e.g. "Pictures") |
get_graphic_image | gameRoot, folder, name | number[] (PNG) | Get an image from Graphics/<folder>/ as PNG bytes (cached). Same folder validation as list_graphic_files |
clear_image_cache | — | void | Clear the Rust-side image cache |
Tileset Management
| Command | Args | Returns | Purpose |
|---|---|---|---|
create_tileset | gameRoot, name, tilesetName | u32 | Create blank tileset in first nil slot. Returns new tileset ID |
delete_tileset | gameRoot, tilesetId | void | Set tileset slot to nil in Tilesets.rxdata |
update_tileset_name_graphic | gameRoot, tilesetId, name, tilesetName | void | Patch @name/@tileset_name on a tileset |
save_tileset_properties | gameRoot, tilesetId, passages, priorities, terrainTags | void | Patch passage/priority/terrain arrays |
save_expanded_autotiles | gameRoot, tilesetId, expandedAutotiles | void | Patch @expanded_autotiles JSON |
Dialog
| Command | Args | Returns | Purpose |
|---|---|---|---|
plugin:dialog|open | { options: { title?, filters?, multiple?, directory? } } | string | null | Native file/folder picker |
plugin:dialog|save | { options: { defaultPath?, filters? } } | string | null | Native save-file picker |
Note:
plugin:dialog|open/plugin:dialog|saveare Tauri dialog plugin commands — the “plugin” prefix is Tauri’s naming, not the editor’s mod system.
Discord Rich Presence
| Command | Args | Returns | Purpose |
|---|---|---|---|
discord_rpc_connect | appId: string | void | Connect to Discord IPC with your Discord Application ID |
discord_rpc_update | details?, stateText?, largeImage?, largeText?, smallImage?, smallText?, startTimestamp? | void | Set Discord rich presence activity |
discord_rpc_clear | — | void | Clear presence (keep connection open) |
discord_rpc_disconnect | — | void | Close Discord IPC connection |
These commands wrap the discord-rich-presence Rust crate. Connection is managed by the editor backend and cleaned up automatically when the app exits.
Image assets (largeImage, smallImage) must be uploaded in the Discord Developer Portal under Rich Presence → Art Assets. The key passed (e.g. "icon") must match the uploaded asset name.
shadow
shadow.list(mapId): { id, name, visible }[]shadow.setVisible(mapId, shadowId, visible): voidshadow.info(mapId, shadowId): { id, name, visible, opacity } | nullshadow.create(mapId, name?): { id, name }shadow.delete(mapId, shadowId): voidshadow.setOpacity(mapId, shadowId, opacity: number): voidshadow.generateFromTiles( mapId, tiles: Array<{ x, y, tileId, tileData? }>, config?: { blurRadius?, offsetX?, offsetY?, color? }): Promise<{ shadowId: number } | null>setOpacity sets the opacity (0-255) of an existing shadow layer.
generateFromTiles programmatically generates a shadow image from an explicit tile list and appends it as a new shadow layer. Returns the new shadow id, or null if generation failed (e.g. tileset atlas not loaded).
fog
FogCtx is the shared CRUD surface for one graphic layer group — a stack of
image layers drawn above or beneath the map tiles. ctx.fog operates on the
fog group (above tiles, in-game priority 3000); ctx.panorama exposes the
exact same surface on the panorama group (beneath tiles, priority -1000);
mod-registered groups go through ctx.layerGroups below.
fog.list(mapId): { id, name, visible }[]fog.setVisible(mapId, fogId, visible): voidfog.info(mapId, fogId): { id, name, visible, opacity, config } | nullfog.create(mapId, name?): { id, name }fog.delete(mapId, fogId): voidfog.setOpacity(mapId, fogId, opacity: number): voidfog.setConfig(mapId, fogId, config: Partial<PublicFogConfig>): voidconfig shape (PublicFogConfig): { graphicName, hue, blendType, zoom, sx, sy, followPlayer, parallax? }.
| Field | Type | Notes |
|---|---|---|
graphicName | string | Filename in the group’s graphics folder (no extension) — Graphics/Fogs/ for fog, Graphics/Panoramas/ for panorama. |
hue | number | 0-360. |
blendType | number | 0=normal, 1=add, 2=subtract, 3=multiply. |
zoom | number | Scale factor (0.1-8.0). |
sx | number | Horizontal scroll speed (px/frame). Positive = right. |
sy | number | Vertical scroll speed (px/frame). Positive = down. |
followPlayer | boolean | true = screen-locked, false = world-anchored. |
parallax | number? | Camera-follow factor when world-anchored (0..1, default 1): 1 = moves 1:1 with the map (classic fog), 0.5 = RMXP native panorama half-speed, 0 = screen-locked. Ignored while followPlayer is true. |
setConfig merges partial config into the existing config. New fog layers default to opacity 51 (20%) and empty graphic.
panorama
panorama.list(mapId): { id, name, visible }[]panorama.setVisible(mapId, layerId, visible): voidpanorama.info(mapId, layerId): { id, name, visible, opacity, config } | nullpanorama.create(mapId, name?): { id, name }panorama.delete(mapId, layerId): voidpanorama.setOpacity(mapId, layerId, opacity: number): voidpanorama.setConfig(mapId, layerId, config: Partial<PublicFogConfig>): voidSame FogCtx type as ctx.fog, operating on the map’s panorama layers
(drawn beneath the tiles; graphics from Graphics/Panoramas/). create defaults
new layers to opacity 255 and parallax: 0.5 (the classic RMXP panorama scroll
speed). Panorama layers persist in the map file and render in-game via the
MakerStudio plugin; a map that still shows its tileset’s native panorama keeps
it untouched until the panorama layers are first edited.
layerGroups
Register custom graphic layer groups — extra groups like the fog/panorama
ones with a mod-defined render priority. A group is persisted per map inside
@extended_layers (descriptor + layers), so the game renders it even when the
mod is not installed.
layerGroups.register(mapId, def: LayerGroupDef): voidlayerGroups.remove(mapId, key): voidlayerGroups.list(mapId): (LayerGroupDef & { layerCount: number })[]layerGroups.setPriority(mapId, key, priority: number): voidlayerGroups.layers(mapId, key): { id, name, visible, opacity, config }[]layerGroups.addLayer(mapId, key, opts?: { name?, opacity?, config? }): { id, name }layerGroups.deleteLayer(mapId, key, layerId): voidlayerGroups.updateLayer(mapId, key, layerId, patch: { name?, visible?, opacity?, config? }): voidLayerGroupDef: { key, name, priority, folder }.
| Field | Notes |
|---|---|
key | Unique group id. Must not be "fog" or "panorama" (those are the built-in groups). |
name | Display name shown as the group row in the editor’s Layer panel. |
priority | In-game Plane z. < 0 renders beneath the map tiles (panorama is -1000), >= 0 above them (fog is 3000). Groups on the same side render ascending by priority. |
folder | The single Graphics/ subfolder the group’s graphics load from (one path component, e.g. "Pictures"). |
register creates the group on the map or updates its descriptor — existing
layers are preserved when the key already exists. Layers share the fog shape
(PublicFogConfig, including parallax). Group rows appear in the Layer panel
like Fog/Panorama Layers, so map makers can edit the layers you add.
events
events.list(mapId): PublicEvent[]events.get(mapId, eventId): PublicEvent | nullevents.create(mapId, x, y, name?): number | nullevents.delete(mapId, eventId): voidevents.move(mapId, eventId, x, y): voidevents.rename(mapId, eventId, name): voidevents.getFull(mapId, eventId): PublicEventFull | nullevents.update(mapId, event: PublicEventFull): voidPublicEvent = { id, name, x, y, pages?, trigger? }. pages is the event
page count. trigger is the first page trigger type (0=action, 1=player_touch,
2=event_touch, 3=autorun, 4=parallel).
create adds a new event at the given position, returns the event ID.
delete, move, and rename modify existing events. All changes are undoable.
getFull / update carry the whole event, pages included:
PublicEventFull = { id, name, x, y, pages: PublicEventPage[] }PublicEventCommand = { code: number; indent: number; parameters: unknown[] }Each PublicEventPage holds the page’s settings (trigger, graphic, movement,
condition) plus its command list:
page.list?: PublicEventCommand[]page.always_on_bottom: boolean // Maker Studio: draw the event below every characteralways_on_bottom is Maker Studio’s mirror of RPG Maker XP’s always_on_top
and takes effect in-game only with the MakerStudio plugin installed
(always_on_top wins when both are set).
list is always present on pages returned by getFull(), and optional
when you pass pages back to update() — omit it to leave that page’s existing
commands untouched, set it to replace them.
Reading and writing event commands
events.commandSchemas(): PublicCommandSchema[]events.getCommandSchema(code: number): PublicCommandSchema | nullevents.createCommand(code: number, params?: unknown[]): PublicEventCommandevents.validateEvent(event: PublicEventFull): { valid: boolean; errors: string[] }events.registerCommand(def: ModCommandDef): DisposablecommandSchemas() returns all known RMXP event command schemas (code, name, category, defaultParams).
getCommandSchema(code) looks up a single schema by code (returns null for unknown codes).
createCommand(code, params?) builds a valid command struct — with the schema’s default parameters when you omit params.
validateEvent checks that every command code in the event’s pages is a known code, and returns the unknown ones in errors. Run it before events.update.
The full read / modify / write round trip:
const ev = ctx.events.getFull(mapId, eventId);if (ev) { // Replace page 1's commands with a "Show Text" line. ev.pages[0].list = [ctx.events.createCommand(101, ["Hello"])];
const check = ctx.events.validateEvent(ev); if (!check.valid) { ctx.log.error(check.errors.join("\n")); } else { ctx.events.update(mapId, ev); // undoable, like every other event change }}To append instead of replace, start from what getFull gave you — but drop
the trailing terminator command first (see below):
const list = (ev.pages[0].list ?? []).filter((c) => c.code !== 0);list.push(ctx.events.createCommand(101, ["One more line"]));ev.pages[0].list = list;Terminator: an RMXP page’s command list always ends with a { code: 0, indent: 0, parameters: [] }
terminator, which the in-game interpreter and the built-in simulator both rely
on. You do not need to add it — update() appends it on write when the list
doesn’t already end with a code-0 command. It is present in the lists getFull()
returns, so filter it out when you append to an existing list.
Indent: createCommand returns indent: 0. Commands inside a conditional
branch / loop body must carry the enclosing block’s indent — set indent
yourself when you build nested lists.
events.registerCommand(def) — custom event commands
Register a brand-new event command that map makers can insert from the
event-command picker. Your command appears on its own named tab in the
picker (a puzzle-icon tab after the built-in ones — see page below) and
edits through a native declarative form. The form is a builder for a Script
command: filling it in generates plain Ruby that the engine runs directly —
there is no runtime dispatcher.
events.registerCommand(def: ModCommandDef): Disposableinterface ModCommandDef { id: string; // unique within your mod, e.g. "cameraScrollTo" name: string; // shown in the picker + command list page?: string; // picker tab title (commands sharing it group together; falls back to the mod id) pageDescription?: string; // one-line strip shown beneath the active tab fields?: ModCommandField[]; // omit → freeform script textarea (params.script) script?: (params: ModCommandParams, ctx: ModCommandContext) => string; // the Ruby stored & run in-game parse?: (scriptText: string) => ModCommandParams | null; // recover params to re-edit summary?: (params: ModCommandParams, ctx: ModCommandContext) => string; // one-line label in the command list}The registry key is "<modId>:<id>". Duplicate keys are skipped (first wins).
The returned Disposable unregisters the command; it is also auto-removed when
your mod unloads.
Your own picker tab. Every command you register shows on a puzzle-icon tab
after the built-in categories. Set page to give that tab a title; commands
that share the same page string collect under one tab, and pageDescription
fills the one-line strip shown beneath it while the tab is active (the first
command in the group that sets a description wins). Omit page and your
commands group under a tab labelled with your mod id.
Declarative fields (def.fields) render with the editor’s own controls and
selectors, so the dialog matches the built-in command dialogs. Each field’s
key becomes a property on the params object passed to script, parse, and
summary. Any field may set disabled: (params, ctx) => boolean to grey its
control out, or hidden: (params, ctx) => boolean to remove it entirely,
conditionally.
Where the command sits (ctx) — script, summary and the field
disabled/hidden predicates all receive a second argument describing the
command’s place in the event:
interface ModCommandContext { mapId: number; eventId: number | null; // null in Database → Common Events pageIndex: number | null; // null in Database → Common Events index: number; // flat position in the page's command list, 0-based count: number; // entries in the list, trailing terminator excluded indent: number; // nesting depth (0 at the page's top level)}So ctx.index === 0 is the first command and ctx.index === ctx.count - 1 the
last; ctx.indent > 0 means it sits inside a conditional branch or loop. The
index/count granularity matches events.getFull().pages[pageIndex].list, so
continuation rows (401 text lines, 509 move sub-commands…) count as entries.
While the command is being inserted the context describes the spot it is about
to land on, itself included in count. Both callbacks re-run whenever the
editor re-reads the command, so a moved command re-renders its summary with the
new position — but the stored Ruby is only written when the form is edited:
dragging a command elsewhere does not re-run script.
// Same command, different Ruby depending on where it was dropped.script: (p, ctx) => ctx.index === 0 ? `pbSceneStart(${p.id | 0})` : `pbSceneStep(${p.id | 0})`,summary: (p, ctx) => `page ${(ctx.pageIndex ?? 0) + 1}, #${ctx.index + 1}/${ctx.count}`,type | Renders | Stored value |
|---|---|---|
number | number box (min/max/step) | number |
text | text input | string |
select | dropdown from options: { value, label }[] | number |
checkbox | checkbox | boolean |
switch | switch picker | switch id (number) |
variable | variable picker | variable id (number) |
coordinate | Transfer-Player-style tile input: a Source dropdown — Direct appointment (map-dialog picker + editable X/Y) or Appoint with variables (X/Y variable pickers). showMapSelector? adds the Map ID dimension + map tree | { mode, mapId, x, y, varMapId, varX, varY } |
record | record picker (recordKind: actor/class/skill/item/weapon/armor/enemy/troop/state/animation/common_event) | record id (number) |
event | event picker (includePlayer?/includeThisEvent?) | event id (number) |
graphic | graphic browser under Graphics/<subfolder>/ (showHue?) | filename (string) |
audio | audio browser (category: BGM/BGS/ME/SE) | { name, volume, pitch } |
Omitting fields gives a freeform script command: the editor shows a single
script textarea bound to params.script.
const off = ctx.events.registerCommand({ id: "cameraScrollTo", name: "Camera Scroll To", page: "Camera", // groups this + other "Camera" commands under one tab pageDescription: "Scroll and shake the camera.", fields: [ { type: "coordinate", key: "target", label: "Target tile", showMapSelector: true }, { type: "checkbox", key: "useSpeed", label: "Set speed" }, { type: "number", key: "speed", label: "Speed", min: 1, default: 4, hidden: (p) => !p.useSpeed }, ], script: (p) => { const t = p.target || {}; const cx = t.mode === "variable" ? `$game_variables[${t.varX | 0}]` : (t.x | 0); const cy = t.mode === "variable" ? `$game_variables[${t.varY | 0}]` : (t.y | 0); const args = [cx, cy]; if (p.useSpeed) args.push(p.speed | 0); return `pbCameraScrollTo(${args.join(", ")})`; }, parse: (text) => { const m = /^pbCameraScrollTo\(\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(\d+)\s*)?\)$/.exec(text.trim()); return m ? { target: { mode: "direct", mapId: 0, x: +m[1], y: +m[2], varMapId: 1, varX: 1, varY: 1 }, useSpeed: m[3] != null, speed: m[3] != null ? +m[3] : 4 } : null; }, summary: (p) => `(${p.target?.x ?? 0}, ${p.target?.y ?? 0})`,});// off.dispose() to remove it early.How it round-trips & runs. A mod command is stored as an ordinary RMXP
Script command (code 355) whose parameters[0] is exactly the string your
script(params) returns, e.g. pbCameraScrollTo(0, -4). That keeps the map’s
.rxdata round-tripping unchanged, passes validateEvent, and runs in-game like
any other event script — no plugin code or handler is required. parse
recognises a stored script so the command keeps its name in the list and reopens
its form (provide it for re-editability; without it, an inserted command becomes
an ordinary Script command once the dialog closes).
Limitations: values the generated script can’t carry back (e.g. a picked
coordinate mapId, or whether a literal coordinate was picked vs typed)
reset on reopen; there is no imperative custom-render field type yet.
tools.registerTool(def)
ctx.tools.registerTool({ id: "my.tool", label: "My Tool", icon: "★", onActivate() { /* selected from toolbar */ }, onDeactivate() { /* user switched away */ }, onPointerDown(ev) { /* ... */ }, onPointerMove(ev) { /* ... */ }, onPointerUp(ev) { /* ... */ },});Returns a Disposable you can dispose() to unregister early. Otherwise the
tool is removed automatically when the mod unloads.
menu.registerMenuItem(def)
ctx.menu.registerMenuItem({ menu: "Mods", // any existing or new top-level menu label: "Do Something", shortcut: "Ctrl+Shift+D", // registers a real, rebindable binding — do NOT also registerShortcut icon: "database", // optional: built-in icon name, SVG markup, or glyph handler: () => { /* ... */ }, isEnabled: () => true, isChecked: () => false,});If menu matches an existing top-level ("File", "Edit", "View",
"Map", "Tools", "Mods", "Help"), the item is appended to it. If it
matches a submenu label within a top-level menu (e.g. "Import & Export Maps"),
the item is inserted there. Otherwise a new top-level menu is created with
that label.
The optional shortcut (e.g. "Ctrl+Shift+D") registers a real, functional
keyboard binding that fires the item’s handler — it is not just a hint shown
next to the label. The binding appears in the editor’s Keyboard Shortcuts
dialog under a “Mods” section, where the user can rebind it; their override
persists and is reflected in the menu’s displayed shortcut. Because setting
shortcut already binds the key, do not also call
ctx.ui.registerShortcut for the same combo — that double-registers it. (Use
ctx.ui.registerShortcut only for shortcuts that have no menu item.)
The optional icon is rendered in the same style as built-in menu items.
It accepts any of three forms, resolved in order:
- A built-in icon name (kebab-case) — renders the matching editor icon in
the same family as native menu items. Common names:
database,code,save,save-all,grid,layers,undo,redo,cut,copy,paste,import,export,download,zoom-in,zoom-out,select-all,collision,events,dim,lock,unlock,info,mods,keyboard,terminal,run,plus,settings,edit,trash,switch,versions,x,warning,star,refresh,eye,eye-off,chevron-up,chevron-down,chevron-left,chevron-right,arrow-up,arrow-down,arrow-left,arrow-right,pause,stop,step,camera,video,clock,language. (Aliases exist, e.g.recent/history,new/file-plus,scripts/code,dark-mode/moon,add/plus,gear/cog/settings,pencil/rename/edit,delete/remove/trash,swap/repeat/switch,git-branch/branch/versions,globe/translate/language.) - Raw inline SVG markup — any string containing a
<is rendered as-is. - A single unicode glyph — anything else (e.g.
"📊").
commands.register / .execute
ctx.commands.register("mymod.export.xml", async (mapId) => { // ...});
await ctx.commands.execute("mymod.export.xml", 1);Use commands when one mod wants to invoke functionality from another. Command ids are global — namespace them with your mod id.
ui
ctx.ui.registerPanel({ id: "mymod.tilestats", title: "Tile Stats", defaultPosition: "right", showInMenu: true, // default true; set false to hide from Mods menu icon: "📊", // optional: SVG markup or unicode glyph defaultSize: { width: 640, height: 460 }, // optional: first-open floating size, default 480×360 render(host) { host.innerHTML = "<h2>Hello panel</h2>"; return () => { /* optional cleanup */ }; },});
ctx.ui.openPanel("mymod.tilestats");ctx.ui.closePanel("mymod.tilestats");ctx.ui.isPanelOpen("mymod.tilestats"); // → boolean
const value = await ctx.ui.showInputDialog({ title: "Rename", message: "Enter new name:", defaultValue: "old name",});// value is string or null (cancelled)
ctx.ui.showContextMenu(400, 300, [ { label: "Action 1", action: () => { ... } }, { label: "", separator: true }, { label: "Disabled", disabled: true }, { label: "Submenu", submenu: [ { label: "Sub-action", action: () => { ... } }, ]},]);
const ok = await ctx.ui.showConfirmDialog({ title: "Confirm", message: "Sure?" });ctx.ui.showToast({ message: "Done", level: "info" });render(host) is a vanilla DOM callback — use any framework you want
inside, or no framework. Return a cleanup function if you need it.
openPanel(panelId) opens a previously registered panel. closePanel closes
it. isPanelOpen checks visibility. showInMenu: false hides the auto-entry
from the Mods menu (use with your own registerMenuItem instead — otherwise
you’ll end up with two entries that both open the same panel).
defaultSize sets the floating window’s width/height (px) the very first
time a panel opens — before it has a dock position or the user has resized
it. Default { width: 480, height: 360 }; bump it if your panel’s content
needs more room out of the box. Has no effect on subsequent opens once the
panel is docked or resized — that’s the user’s layout from then on.
Panel persistence: mod panels keep their slot in the user’s saved dock
layout. When your mod is unloaded (hot reload, disable, uninstall), the panel
is not closed — the editor shows a “Panel provided by mod … — not loaded”
placeholder in its place and swaps your live content back in when the mod
re-registers the panel. Exported layout configurations also record which mods
provided which panels, so importing a layout on a machine missing your mod
warns the user and keeps the slot as a placeholder. Don’t try to close your
panel on deactivate to “clean up” — leaving the slot is the intended
behavior; only the user closes panels.
showInputDialog shows a text input dialog — returns the entered string or
null if cancelled. showContextMenu displays a native context menu at the
given screen coordinates with labels, actions, separators, and submenus.
Toast action buttons
ctx.ui.showToast({ message: "This action has no shortcut yet.", level: "info", durationMs: 0, // sticky — stays until the user clicks a button action: { label: "Assign shortcut", onClick: () => ctx.ui.openKeyboardShortcuts("edit.save") }, secondaryAction: { label: "Don't show again", onClick: () => rememberDismissed() },});ToastOptions.action / .secondaryAction each take { label, onClick } and render as
buttons on the toast — secondaryAction to the left of action, both left of the
built-in copy/close buttons. Clicking either one dismisses the toast and then calls
onClick; an error thrown from onClick is caught and logged, it won’t crash the
editor. Omit both for a plain message-only toast (the previous behavior, unchanged).
Theming & CSS variables
Panels (registerPanel) and custom dialogs (showCustomDialog) render into the
editor’s own DOM — not an iframe or shadow root. The editor sets its theme
variables on the <html> element, so they cascade into your UI. Reference
them with var(--name) instead of hardcoding colors, and your panel matches the
app and flips automatically when the user toggles light/dark.
ctx.ui.registerPanel({ id: "mymod.panel", title: "My Panel", render(host) { host.innerHTML = ` <div style=" padding: 8px; background: var(--bg-primary); color: var(--text-primary); font-family: inherit; font-size: 13px;"> <div style=" font-weight: 600; text-transform: uppercase; font-size: 11px; color: var(--text-tertiary); margin-bottom: 6px;">Header</div> <button style=" padding: 4px 10px; border-radius: 4px; cursor: pointer; border: 1px solid var(--border); background: var(--accent); color: var(--accent-text);">OK</button> <button style=" padding: 4px 10px; border-radius: 4px; cursor: pointer; border: 1px solid var(--danger); background: transparent; color: var(--danger);">Delete</button> </div>`; },});Stable palette (same names in both themes — values differ per active theme):
| Variable | Role |
|---|---|
--bg-primary | Main panel background |
--bg-secondary | Sidebar / inset background |
--bg-tertiary | Headers, toolbars, raised controls |
--bg-hover | Hover-state background |
--input-bg | Form input background |
--border | Borders and dividers |
--text-primary | Primary text |
--text-secondary | Labels / secondary text |
--text-tertiary | Muted / placeholder text |
--accent | Accent / primary-action color |
--accent-hover | Accent hover |
--accent-muted | Translucent accent (subtle fills, highlights) |
--accent-text | Text on an accent background |
--danger | Destructive / error |
--warning | Warning |
--highlight | Selection / emphasis |
--success, --success-hover, --success-border | Success states |
--shadow | Drop-shadow color (rgba) |
--canvas-bg | Map canvas backdrop |
The body font is inherited too — set font-family: inherit (13px base) to match.
Notes:
- Treat the names above as the public palette. Other variables exist
(
--ec-*event-command syntax colors,--dv-*Dockview tab tokens,--tile-preview-*) but are internal and may change — don’t depend on them, and don’t override the--dv-*tokens. - CSS variables only apply to DOM. Canvas overlays (
registerOverlay/registerAdvancedOverlay) draw with aCanvasRenderingContext2Dwherevar(--…)does nothing — branch onctx.editor.theme()for a literal color, or read one withgetComputedStyle(document.documentElement).getPropertyValue("--accent").trim().
Context menu registration
ctx.ui.registerContextMenuItem({ context: "map-tile", // "map-tile" | "map-event" | "tile-palette" | "tile-palette-extra" | "layer" | "map-tree" | "event-editor" label: "My Action", handler: (info) => { /* info has mapId, tileX, tileY, tileId, layerIndex, etc. */ }, isEnabled: (info) => true, // optional parentMenu: "Export Map…", // optional — nest inside an existing submenu});Injects menu items into any of 7 editor right-click menus. The info object
contains context-specific data (tile coords, event id, layer name, etc.).
Set parentMenu to an existing submenu label (e.g. "Export Map…" in the
map-tree context menu) to nest your item inside that submenu instead of
appending it at the top level. If no matching submenu is found, the item
is appended at the top level as a fallback.
Overlay rendering
ctx.ui.registerOverlay({ id: "mymod.highlight", zOrder: 0, // higher = on top render(ctx, info) { // ctx = CanvasRenderingContext2D // info = { mapId, tileSize, zoom, viewportX, viewportY, canvasWidth, canvasHeight } ctx.fillStyle = "rgba(255,0,0,0.3)"; ctx.fillRect(x, y, w, h); },});Renders custom overlays on the map canvas after tiles/events, before UI overlays.
Multiple overlays sorted by zOrder.
Keyboard shortcuts
ctx.ui.registerShortcut("Ctrl+Shift+F", () => { // handle shortcut});Register global keyboard shortcuts. Key format: "Ctrl+Shift+F", "Alt+G", etc.
Mod shortcuts take priority over built-in shortcuts.
Additional dialogs and pickers
ctx.ui.showColorPicker(opts?: { title?, initial?: string }): Promise<string | null>ctx.ui.showFilePicker(opts?: { directory?, multiple?, filters? }): Promise<string[] | null>ctx.ui.showSavePicker(opts?: { defaultPath?, filters? }): Promise<string | null>ctx.ui.confirmDestructive(opts: { title, message, confirmLabel? }): Promise<boolean>showColorPicker opens a native color picker; returns hex string (e.g. "#ff0000") or null if dismissed.
showFilePicker wraps the Tauri file-open dialog; returns array of paths or null.
showSavePicker wraps the Tauri save dialog; returns path or null.
confirmDestructive is showConfirmDialog with a red confirm button — use for irreversible operations.
Custom dialogs
const { close } = ctx.ui.showCustomDialog({ title: "My Dialog", width: "460px", // optional height: "400px", // optional zIndex: 5000, // optional render(body) { // body is an HTMLElement — append your UI const btn = document.createElement("button"); btn.textContent = "Close"; btn.addEventListener("click", () => close()); body.appendChild(btn);
// Return cleanup function (optional) return () => { /* teardown */ }; },});Opens a styled modal dialog using the editor’s standard shell (overlay, draggable
header with title + close button, scrollable body). The render callback receives
the body element — append any DOM content. Returns { close } to close programmatically.
Advanced overlay
ctx.ui.registerAdvancedOverlay({ id: "mymod.overlay", zOrder: 0, render(ctx, info) { // info.animFrame — coarse frame counter for animation ctx.fillStyle = "rgba(255,0,0,0.3)"; ctx.fillRect(x, y, w, h); },}): DisposableLike registerOverlay but info includes animFrame for animation-aware rendering.
Status bar and toolbar (registry — rendering coming soon)
ctx.ui.registerStatusBarItem({ id, render(host), align?: "left"|"right" }): Disposablectx.ui.registerToolbarButton({ id, icon, tooltip, handler, isActive? }): DisposableRegisters a status bar segment or toolbar button contributed by the mod. Both are removed automatically on mod unload.
Opening URLs
await ctx.ui.openUrl("https://example.com");Opens a URL in the user’s default browser. The editor shows a confirmation dialog first (with the URL displayed in a monospace box) — the user must click “Open Link” to proceed. Returns immediately if cancelled.
Opening the Keyboard Shortcuts dialog
ctx.ui.openKeyboardShortcuts?.(actionId?: string);Opens the editor’s native Keyboard Shortcuts dialog — the same one behind Help → Keyboard
Shortcuts…. Called with no argument it opens to the plain, unfiltered list. Pass an
actionId (one of the built-in ids from keybinds below) and the dialog opens
already scrolled to that row and listening for a keypress, exactly as if the user had
clicked it — pairs well with a toast action button offering
“Assign shortcut” for an action that doesn’t have one yet. Only matches the built-in action
ids ctx.keybinds knows about — it can’t target a mod-registered menu shortcut.
Extending built-in editor UI
Two ways in, and you want the first one most of the time.
ui.decorate(selector, apply) — anywhere
const d = ctx.ui.decorate(".fc-popup .fc-actions", (el, info) => { const row = document.createElement("label"); row.innerHTML = "<input type='checkbox'> Snow on this fog"; el.before(row); return () => row.remove(); // optional cleanup});Your callback runs for every element matching the selector — the ones on screen now, and
every one mounted later. Open the Edit Fog dialog five minutes from now and it is decorated
the same as one already open. From there you hold a real HTMLElement: append to it, restyle
it, wire listeners, or replaceWith() it entirely.
// Turn a read-only readout into an editable inputctx.ui.decorate(".properties-panel .prop-value", (el) => { const input = document.createElement("input"); input.value = el.textContent ?? ""; el.replaceWith(input);});
// A button in the Show Text command form that fills the field inctx.ui.decorate(".cpf-body textarea", (el) => { const btn = document.createElement("button"); btn.textContent = "Insert player name"; btn.onclick = () => { (el as HTMLTextAreaElement).value += "\PN"; }; el.after(btn); return () => btn.remove();});Notes:
- Return value is a cleanup, run when the element leaves the DOM or the decorator is disposed. Anything you appended should be removed there.
- Each element is decorated once per decorator, so re-renders never stack duplicates.
- Everything is disposed automatically on mod unload / hot reload.
- One
MutationObserverbacks every decorator and is only connected while at least one is registered — mods that never calldecoratecost nothing.
Picking a selector. [data-ms-part] is a stable, documented contract shared with the
theme system: dialog, menubar, toolbar, statusbar, panel-header, canvas.
Component class names (.fc-popup, .cpf-body, .ts-sidebar-section, .prop-value, …)
work too and are what you’ll reach for most, but they are internal and can change
between releases — pin your mod’s compatibility accordingly, and prefer a selector that will
fail visibly over one that silently matches the wrong thing.
ui.registerSlot(slot, render, opts?) — named points, with data
ctx.ui.registerSlot("event.command.form.101", (host, slot) => { const btn = document.createElement("button"); btn.textContent = "Insert greeting"; btn.onclick = () => { const { setParameter } = slot.data() as { setParameter(i: number, v: unknown): void }; setParameter(0, "Hello there!"); }; host.appendChild(btn);});Slots are the few places the editor declares explicitly, and their advantage over decorate
is the payload: ids and setters the DOM cannot give you.
| Slot | Where | slot.data() |
|---|---|---|
fog.config | Edit Fog / Panorama / layer-group popup, above the footer | { groupKey, mapId, layerId } |
tileset.editor.tile | Tileset Editor sidebar | { tilesetId, mode, hoverCell, selectedCells } |
event.command.form | every event command form | { code, parameters, setParameter(i, v) } |
event.command.form.<code> | one command form (e.g. .101 = Show Text) | same |
properties.panel | Tile Info panel body | { tileId, tilesetId, priority, terrainTag, passable, isBush, isCounter } |
slot.data()is a getter — one host element is reused across re-renders, so call it each time instead of caching.slot.onUpdate(fn)fires when the payload changes.{ replace: true }hides the slot’s built-in content and shows only yours (properties.panelwraps its rows, so this swaps the whole readout).{ order: n }sorts multiple registrations on the same slot.
Worked example of both mechanisms side by side: script-bridge.
simulator
The Game Simulator interprets a useful subset of RMXP. What it cannot run — Script commands above all, since those are Ruby and there is no Ruby here — is logged as unsupported. These hooks let your mod supply the missing behaviour.
// Claim a Script command (355), a move-route Script (move code 45),// or a Script conditional branch (kind 12).ctx.simulator.registerScriptHandler("pbSetSwitch", (script, sim) => { const m = script.match(/pbSetSwitch\((\d+),\s*(\w+)\)/); if (!m) return null; // decline — let others try sim.setSwitch(Number(m[1]), m[2] === "true");});
// Implement (or override) an event command code.ctx.simulator.registerCommandHandler(201, (params, sim) => { sim.log(`transfer to map ${params[1]}`);});match narrows what reaches a script handler: a string matches scripts that start with
it, a RegExp is tested against the whole body, a predicate does whatever you want,
and omitting it sees every script.
Return values:
| Return | Meaning |
|---|---|
null | Declined — the next handler (then the built-in path) takes it |
true / false | The answer for a Script conditional branch; also counts as handled |
| anything else | Handled |
registerCommandHandler handlers run before the built-in implementation, so a code the
simulator already supports can be replaced; return false to decline and fall through.
SimApi
The second argument is a narrow view of the running simulation — the internal runtime is never handed out.
interface SimApi { frame: number; // sim frame counter eventId: number | null; // event running the command (-1 = player) mapId: number; getSwitch(id): boolean; setSwitch(id, value): void; getVariable(id): number; setVariable(id, value): void; getSelfSwitch(letter, eventId?): boolean; setSelfSwitch(letter, value, eventId?): void; character(target): SimCharacterView | null; // -1 player, 0 this event, N event id characters(): SimCharacterView[]; wait(frames): void; // hold the interpreter showText(lines): void; // message box, like Show Text log(message, level?): void; // a row in the simulator log panel}
interface SimCharacterView { id: number; name: string; x: number; y: number; direction: 2 | 4 | 6 | 8; moving: boolean; erased: boolean; setPosition(x, y): void; setDirection(dir): void; setTransparent(on): void; setThrough(on): void;}Switch / variable writes take effect on the next tick, when the simulator re-evaluates page conditions. A handler that throws is caught, logged to the simulator panel and counted as handled, so a broken mod cannot wedge the simulation. Every handler is disposed on mod unload.
See the script-bridge example mod: it registers one handler
for a table of common Ruby one-liners, implements Change Gold, and badges the Script rows it can
run — a worked example of ctx.simulator together with ui.decorate and ui.registerSlot.
keybinds
keybinds.list(): KeybindInfo[]keybinds.get(actionId: string): KeybindInfo | nullkeybinds.set(actionId: string, key: string): string | nullkeybinds.reset(actionId: string): voidkeybinds.onChanged(cb: (actionId, oldKey, newKey) => void): DisposableQuery and modify the editor’s keyboard shortcuts. KeybindInfo contains actionId, label, category, key, defaultKey, and isCustom.
set returns the conflicting action ID if the key is already bound, or null on success. The key format uses normalized notation: "ctrl+s", "ctrl+shift+s", "g", etc.
onChanged subscribes to any keybind change (from the settings dialog or other mods). Returns a Disposable.
Built-in action IDs include: tool.brush, tool.eraser, tool.fill, tool.rectangle, tool.eyedropper, tool.select, tool.pan, view.toggleGrid, view.toggleCollision, view.toggleDim, brush.sizeUp, brush.sizeDown, brush.rotateCW, brush.rotateCCW, zoom.in, zoom.out, layer.select1–layer.select9, edit.save, edit.saveAll, edit.saveShadow, edit.undo, edit.redo, edit.selectAll, edit.deselect, edit.copy, edit.paste, edit.cut, app.runGame, dev.toggleDevTools. Call ctx.keybinds.list() for the full set.
Lifecycle
ctx.lifecycle.onUndo(fn: (mapId, label) => void): Disposablectx.lifecycle.onRedo(fn: (mapId, label) => void): Disposablectx.lifecycle.onBrushChange(fn: (props) => void): Disposablectx.lifecycle.onTilesetChange(fn: (tilesetId, reason) => void): DisposableConvenience wrappers around bus.on("undo"), bus.on("redo"), bus.on("brush.changed"), bus.on("tileset.changed").
i18n
Translations and locale control. Two tiers:
- Tier 1 — translate your own strings. Register per-locale dictionaries for your mod’s UI,
then resolve them with the scoped
i18n.t(). - Tier 2 — localize the whole app. Register a complete new locale — it appears in
View → Language — or patch an existing one (including the built-in
en/es).
i18n.addTranslations(locale: string, dict: Record<string, string>): Disposablei18n.t(source: string, vars?: Record<string, string | number>): stringi18n.getLocale(): stringi18n.locales(): LocaleInfo[] // [{ code, name }] — built-ins + mod localesi18n.setLocale(code: string): voidi18n.registerLocale(loc: { code: string; name: string; dict: Record<string, string> }): Disposablei18n.onChanged(cb: (locale: string) => void): Disposable// Tier 1 — your mod's own stringsctx.i18n.addTranslations("es", { "Export finished": "Exportación completada", "Exported {n} maps": "Se exportaron {n} mapas",});ctx.ui.showToast({ message: ctx.i18n.t("Exported {n} maps", { n: 3 }) });
// Tier 2 — a whole new app locale (shows up in View → Language)ctx.i18n.registerLocale({ code: "fr", name: "Français", dict: frenchDict });
// React to language switchesctx.i18n.onChanged((locale) => rerenderMyPanel(locale));Details:
- Keys are English source strings (gettext style), same convention as the app’s own
dictionaries.
i18n.t()looks up your mod’s dict for the current locale, then the app dictionary, then falls back to the source string itself — untranslated strings are never broken, just English.{name}placeholders are substituted fromvarsafter lookup, so dictionary keys keep the literal braces. addTranslationsmerges — calling it again for the same locale adds/overrides keys. The returnedDisposableremoves exactly the keys that call added.registerLocaleoverlays the app’st()for that locale and lists it in View → Language. Reusing a built-in code (e.g."es") patches that locale’s app strings. Mod entries win over built-ins; among mods, the later registration wins.- If a user’s saved language comes from your mod, it is restored automatically when your mod (re)registers the locale — including across hot reloads. While the mod is unloaded the editor falls back to its detected default.
- Everything is auto-disposed on unload/hot-reload.
- Locale switches fire the
"locale.changed"bus event ({ locale }) —i18n.onChangedis the convenience wrapper. See events-reference.md.
Translating the whole editor — app-strings.json
Every English source string the editor runs through t() is exported to
app-strings.json — sorted alphabetically, with empty values, ready to
fill in. A translation mod is that file with the values filled and handed to registerLocale:
import fr from "./app-strings.fr.json";
export function activate(ctx) { ctx.i18n.registerLocale({ code: "fr", name: "Français", dict: fr });}- Entries left as
""fall back to English, so partial translations ship fine — no need to finish all of it before releasing. {name}placeholders must survive into your translation (they’re substituted after lookup); reorder the surrounding words freely, keep the braces literal.- The file is regenerated each editor release. Diff it against your dict to find new strings — keys that disappeared are simply ignored.
- To fix or extend an existing language instead of adding one,
registerLocalewith that code ("es","en") and only the keys you’re changing.
bus
const sub = ctx.bus.on("map.tile.changed", (e) => { ... });sub.dispose(); // optional — happens automatically on unload
ctx.bus.emit("mod.loaded", { id: "..." }); // editor-level eventssave.before is cancellable: return { cancel: true, reason: "..." } to
abort the save.
See events-reference.md for the full list.
fs
// Mod folder operationsconst txt = await ctx.fs.readModFile("data.txt");await ctx.fs.writeModFile("output.txt", "hello");const exists = await ctx.fs.exists("data.txt");const entries = await ctx.fs.listDir("subfolder/");await ctx.fs.mkdir("output/cache");
// Project folder operationsconst projTxt = await ctx.fs.readProjectFile("Data/System.rxdata");await ctx.fs.writeProjectFile("Data/custom.json", content);const projExists = await ctx.fs.projectExists("Graphics/Tilesets/my_tile.png");const projEntries = await ctx.fs.listProjectDir("Graphics/Tilesets/");await ctx.fs.projectMkdir("Graphics/Custom");Paths are normalized — absolute paths and .. traversal raise
PermissionDeniedError.
exists/listDir/mkdir operate in the mod folder. projectExists/
listProjectDir/projectMkdir operate in the project (game) folder and
require fs.project or fs.write.project permissions.
storage
await ctx.storage.set("counter", 1);const v = await ctx.storage.get<number>("counter", 0);Backed by <modDir>/.storage.json.
clipboard
The OS text clipboard — the system-wide clipboard shared with every other application.
clipboard.readText(): Promise<string | null>clipboard.writeText(text: string): Promise<void>await ctx.clipboard.writeText("hello");const text = await ctx.clipboard.readText(); // null if empty / unavailablereadText returns whatever raw text the OS clipboard holds — which may be a copy made by
another app — or null when empty or the clipboard is unavailable (permission / headless).
writeText is a no-op when the clipboard is unavailable; neither method throws.
This is not the tile clipboard: ctx.map.getClipboard() / setClipboard() hold copied
map tiles, fire clipboard.changed, and never touch the OS clipboard.
log
ctx.log.info("hello", { foo: 1 });ctx.log.warn(...);ctx.log.error(err);Visible in the Mod Manager log pane.
lifecycle
ctx.lifecycle.onMapLoad((mapId) => { ... });ctx.lifecycle.onSave((mapId) => { ... });ctx.lifecycle.onActivate(() => { /* runs after activate() returns */ });ctx.lifecycle.onDeactivate(() => { /* runs on mod unload */ });ctx.lifecycle.onToolChange((toolId) => { ... });ctx.lifecycle.onLayerChange((mapId, layerIndex) => { ... });Convenience wrappers around bus.on(...) for common hooks.
onToolChange fires on tool.activated. onLayerChange fires on
layer.changed (visibility, opacity, added, removed, active).
stats
Read editor usage statistics and define custom stats.
Snapshot getters
const g = ctx.stats.global(); // GlobalStatsSnapshotconst p = ctx.stats.project(); // ProjectStatsSnapshot | nullconst a = ctx.stats.all(); // CombinedStatsSnapshot { global, project }GlobalStatsSnapshot fields: totalActiveMinutes, totalTilesPlaced, totalUndoCount, totalRedoCount, totalMapsCreated, totalMapsSaved, totalSessions, firstLaunchDate, custom.
ProjectStatsSnapshot fields: activeMinutes, tilesPlaced, undoCount, redoCount, mapsCreated, mapsSaved, mapEdits, sessionCount, firstOpened, custom.
Single-stat getters
const tiles = ctx.stats.getGlobalStat("totalTilesPlaced"); // number | stringconst pTiles = ctx.stats.getProjectStat("tilesPlaced"); // number | string | nullCustom stats
Mods can define arbitrary numeric stats that persist alongside built-in stats:
// Readconst v = ctx.stats.getCustomGlobal("my_counter", 0);const pv = ctx.stats.getCustomProject("my_project_counter", 0);
// Writectx.stats.setCustomGlobal("my_counter", 42);ctx.stats.setCustomProject("my_project_counter", 10);
// Increment (returns new value)const newV = ctx.stats.incrementCustomGlobal("my_counter"); // +1const newP = ctx.stats.incrementCustomProject("my_counter", 5); // +5Custom stats appear in the Stats dialog under a “Custom” section. Project custom stats are per-project; global custom stats are shared across all projects.
Stat metadata
Mods can register display metadata for custom stats so they appear with a name, description, and category in the Stats dialog:
ctx.stats.registerStat({ id: "achievements_unlocked", // matches the key used in setCustomGlobal/setCustomProject name: "Achievements Unlocked", // display name shown in the Stats dialog description: "Total achievements unlocked across all projects", // shown on hover category: "Achievements", // section header in the Stats dialog scope: "global", // "global" or "project" format: "number", // optional: "number" (default) | "time" | "date"});Stats with the same category are grouped under a single heading. Built-in stats use “Project” and “Global” categories. Mods can reuse these or create custom categories.
Subscription
ctx.stats.onStatsChanged((global, project) => { // Fires ~every 60s with fresh snapshots});ctx.theme — editor themes
export async function activate(ctx) { const wallpaper = await ctx.theme.assetUrl("bg.png");
ctx.theme.register({ id: "com.example.dusk", name: "Dusk", base: "dark", vars: { "--accent": "#ffc50b", "--bg-primary": "#0e0e0f", "--bg-secondary": "#141414", }, css: ` [data-ms-part="toolbar"] { border-bottom: 1px solid var(--accent); } [data-ms-part="dialog"] { border-radius: 14px; } `, canvas: { image: wallpaper, fit: "cover", opacity: 0.9 }, });}| Member | Purpose |
|---|---|
register(theme) | Adds it to View → Theme. Returns a disposer; it is also removed when your mod unloads |
apply(id | null) | Switch to a theme, or null for the built-in dark/light |
current() | Active mod theme id, or null |
list() | Every registered theme, including other mods’ |
assetUrl(relPath) | A file in your mod folder as a data: URI — use it for canvas.image, background-image, @font-face |
How a theme is applied. data-ms-theme="<your id>" is added to the root and every rule you
supply is rewritten to sit under it — your css included, so a registered theme changes nothing
until the user picks it. Anything you don’t override still resolves against base. You do not need
!important, and you do not need to keep re-appending a <style> tag: the editor owns the
stylesheet and its order.
Write css as if it applied to the whole app ([data-ms-part="toolbar"] { … }); the scope is added
for you. A rule aimed at :root or html becomes the theme scope itself, @media/@supports are
recursed into, and @keyframes/@font-face are passed through untouched.
Named parts. Style regions through data-ms-part rather than internal class names, which are
not API and do change: menubar, toolbar, statusbar, panel-header, dialog, canvas.
The map canvas. It is a <canvas>, so CSS cannot reach inside it. Give canvas.image an image
and the renderer paints it over the cleared canvas and under the map. Don’t try to do this by making
--canvas-bg transparent: the canvas then stops clearing between frames and the map smears when
panning.
Light, dark, or both. What you declare decides how the theme answers the editor’s Dark Mode toggle:
| Declared | Result |
|---|---|
dark and light | One entry in the list, two looks — it follows the toggle |
only dark (or only light) | That scheme is forced and the toggle is locked while the theme is active |
| neither | base is forced, same lock |
ctx.theme.register({ id: "com.example.dusk", name: "Dusk", base: "dark", // palette behind whatever you don't set vars: { "--radius": "10px" }, // both schemes dark: { vars: { "--accent": "#61afef" } }, light: { vars: { "--accent": "#4078f2" } },});A locked theme moves the Dark Mode setting rather than fighting it, and the menu item says Dark Mode (set by theme) and is disabled — so the switch never looks broken. The user’s own preference is untouched and comes back when they pick another theme.
Only one theme is active at a time, so two theme mods no longer fight over the cascade — the user picks one in View → Theme.