Writing a mod? Start with the authors' guide (quick start, concepts, reference, recipes, context for AI assistants, gaps). This page remains the detailed host reference.
A mod is a small TypeScript program that players exchange as a single file
(.pvmod). It reads the game state on demand (song, chart notes, player,
judgements…), receives the match events, and builds a HUD out of typed nodes
that the game displays in the WebView overlay above the native playfield. With
the stage permission, it also draws into the native rendering, frame-accurate
with the notes (stage.*). It can register
judgement sets and expose
configurable elements to other packages.
Mods are distinct from skins (skin.ts, which lay out the playfield
and build the game's HUD) and from map scripts, which
belong to a map. All three share the same host and the same API.
The crates/modding crate provides the host, the API, the packages, and the SDK;
crates/stage validates and evaluates the native rendering of scripts. The desktop
application starts the host, sends it the game events, and displays its scene in the
WebView overlay during a match.
Create a mod
A mod's folder
mods/
sdk/ generated declarations; no main.ts, so not a mod
modding.d.ts
modding.ts
osu/ etterna/ prism/ game mods: judgements (ordinary folders, like any mod)
scroll-*/ metron/ ... scrolls, ratings, HUD: one folder per mod
score-counter/
main.ts
mon-mod/
main.ts entry point (otherwise index.ts)
lib/format.ts imported by main.ts
fonts/Title.ttf
images/star.png
A folder is a mod if it contains main.ts (or, failing that, index.ts) and
its name does not start with .; a folder without main.ts (such as mods/sdk) is
silently ignored. The game's mods are not compiled into the executable: they
are ordinary folders in mods/, loaded like your own. Roots, in this order:
mods\ next to prism.exe (in development, the repository's mods/ folder,
found by walking up from the executable to the folder containing
Cargo.lock), then the player's mods folder. Mod order:
providers before their dependents (uses), otherwise loadOrder then id;
this order is also that of event delivery and of HUD layer stacking.
The entry point is loaded as a Rust-TS project: relative imports, paths from
tsconfig.json, and the folder's node_modules are resolved, never outside the
folder; dynamic import() is rejected. Loading transpiles without type
checking: check your mods with tsc (see Typing and SDK).
Declaring the mod: defineMod
/// <reference path="../sdk/modding.d.ts" />
export default defineMod({
id: "big-score",
name: "Big Score",
version: "1.2.0",
apiVersion: 2,
author: "Ada",
description: "A big score at the top right.",
homepage: "https://example.com/big-score",
fonts: { title: "fonts/Title-Bold.ttf" },
setup(mod) {
ctx.on("game.judgement", (judgement) => {
hud.text({ id: "combo", text: `${judgement.combo}x`, style: { font: "title" } });
});
},
});
| Field | Required | Rule |
|---|---|---|
id |
yes | 1 to 64 characters among a-z, 0-9, ., -, _, starting and ending with a letter or digit, with no .. and no reserved Windows name (score-counter, com.exemple.score); stable. No prefix is reserved; if two folders declare the same id, the first one loaded wins and a message reports it |
name |
yes | 1 to 64 characters |
version |
yes | semantic version (1.2.0, 2.0.0-beta.1), compared on replacement |
apiVersion |
yes | version of the API the mod is written for; must equal the game's (2) |
loadOrder |
no | integer from 0 to 10000 (default 1000) that sorts mods with no dependency between them by (loadOrder, id); the game's mods use it to keep the order of the selectors |
author |
no | 1 to 64 characters |
description |
no | 1 to 1024 characters, line breaks allowed |
homepage |
no | https:// or http:// address, 256 characters at most |
fonts |
no | bundled fonts: { name: "relative/path.ttf" }, see Fonts |
uses |
no | packages used: { "<id>": "^1.2" } or { "<id>": { version, required, feature } }, see Dependencies |
provides |
no | elements that other packages configure: { elements: { <name>: { options } } }, see Elements |
permissions |
no | ["stage"] to draw into the native rendering, see stage.*; ["network"] to declare download mirrors and sources, see downloads.register |
images |
no | PNGs from the folder that its stage.sprite and stage.emitter elements display (64 at most) |
setup |
no | function called once the declaration is validated, with the validated manifest |
An unknown field makes the declaration fail. A mod written for another version
of the API is rejected with a clear message:
`x` requires modding API version 1; this game provides version 2.
Loading and phases
- Loading. The top-level code of the entry point (and of the imported
modules) runs. Only
defineModandlog.*are available;ctx.on(...)can already register handlers. Any other host function throws an exception (is not available while the mod loads). Loading therefore has no side effects: this is also how the installer reads a package's declaration, without ever callingsetup. - Declaration.
defineModmust be called exactly once; an invalid declaration, an id already taken, or a second call makes the mod fail (even if the exception is caught). - Checks. The declared fonts are checked on disk, and the mod's storage is opened.
setup(mod). Called under the execution budget, after thesetupof the packages the mod uses (Dependencies); an exception makes the mod fail. After that, the mod receives events and can use everything exceptdefineMod.
Mods load in the background when the host starts: the game does not wait for
them. The application creates its first host after receiving the interface's
persisted settings. ModHostOptions.disabled_mods contains the disabled
ids: their declaration is read to preserve their metadata, but neither their
setup nor their handlers run. They provide no elements to dependents, and no
HUD, judgement, scrolling system, or difficulty view.
Typing and SDK
The functions are globals; to type them, reference the generated declarations (a comment, with no effect at runtime):
/// <reference path="../sdk/modding.d.ts" />
This path applies to a mod written in the repository's mods/ folder; elsewhere, copy
mods/sdk/modding.d.ts and adjust the path. mods/sdk/modding.ts is an optional
module (game.onJudgement(...) shortcuts, models helpers); a mod that
uses it copies it into its own folder, since a package cannot import anything
from outside itself.
Both files are generated from the Rust contracts (all exposed types
derive TsSchema) and versioned in mods/sdk/, like the
Generated reference block of this document. After an API
change:
cargo run -p modding --example write_sdk
The committed_sdk_matches_the_rust_contracts test (cargo test -p modding --test sdk) fails if the files or this block no longer match the contracts;
examples_type_check uses the UI's TypeScript to check the mods in
mods/ and the skins in skins/. For a single mod:
node apps/web/node_modules/typescript/bin/tsc --noEmit --strict --target es2022 --lib es2022 --module esnext --moduleResolution bundler mods/score-counter/main.ts
Minimal example
A mods/combo-corner/ folder (to be created) with this main.ts:
/// <reference path="../sdk/modding.d.ts" />
export default defineMod({
id: "combo-corner",
name: "Combo Corner",
version: "1.0.0",
apiVersion: 2,
setup() {
// The combo in the top right, updated by the overlay on every judgement.
hud.text({
id: "combo", text: "", bind: { kind: "combo" }, showWhen: "judged",
layout: { x: 0.98, y: 0.02, anchor: "topRight" }, style: { size: 0.05 },
});
ctx.on("game.songEnd", (end) => log.info(`combo max ${end.maxCombo}`));
},
});
Copy the folder into the mods folder, then click
Reload in the Mods page: the mod appears in the list, and its messages
(log.info) below it. To share it, export it as a
.pvmod package.
Events
Subscribe with ctx.on(name, handler), at the top level or in setup.
| Event | Content |
|---|---|
game.songStart |
song, judgements (the game's configuration), timeUs (time at start, negative during a pre-roll); the chart and the playfield become readable |
game.judgement |
column, timeUs, offsetUs (input − note; input − end for a release; 0 for a miss), tier ({ index, id, name, color }), combo, counts, accuracy |
game.pause, game.resume |
timeUs |
game.tick |
timeUs, hitCount (native counter of non-Miss hits, reset to zero at each game) |
game.beat |
index (beat count since the first tempo point, across its changes; negative before it), timeUs (time of the beat), bpm, meterBeat (position within the measure, 0 on the first beat; each tempo point starts a measure) |
game.songEnd |
counts, maxCombo, accuracy, aborted |
game.playfieldChange |
Playfield |
game.settingsChange |
ModSettings |
elements.configure |
element, from, options: another package configures an element of the mod (Configurable elements) |
controls.action |
id, pressed, timeUs: action declared by this mod, physical key pressed or released during the game |
Times are song times in microseconds. combo, counts and
accuracy of a judgement are the judgement engine's totals after that note;
a mod can resynchronize even if it missed events. game.songEnd
carries the result recomputed from the inputs when the chart ends, and the
live totals when the player quits (aborted).
game.tick arrives 30 times per second, only while a chart is playing and
is not paused; the time is extrapolated from the last songStart or
resume. A late tick is skipped.
The counter is sampled before the callback: a mod can avoid a
new read of game.hits when it is unchanged. A hit that arrived
during the read remains detectable on the next tick; the counter does not depend
on the delivery of judgement events.
game.beat arrives at the time of each beat of the chart (Song.timing), without
waiting for a tick: the mods thread wakes up at the beat time,
only while the chart is playing and is not paused. After a pause or
a jump, it resumes at the next beat; when late, it delivers only the
last beat reached, never a burst. A skin can thus pulse to the
music by starting a transition on each beat, with no per-frame work.
The game never waits for mods: events go through a queue of 1024 slots; if it is full, the event is dropped and counted. Sending an event allocates nothing on the game threads.
Entities
Read on demand, once the mod is running (setup, handlers). Song times
are in microseconds; Hit.offsetMs is in real milliseconds.
| Function | Result |
|---|---|
game.song() |
Song | null: title, artist, creator, difficulty, mode (catalog id, "keys"), layout ("4" to "7"), keys, durationUs, noteCount, holdCount, bpm ({ min, max, main } or null without a BPM point), timing (sorted tempo changes, at most 1024: { timeUs, bpm, beatUs, meter }). The last song started, null before the first |
game.notes(query) |
NotePage: a bounded page of the notes whose head is in [fromUs, toUs), see below |
game.hits({ limit: 50 }) |
Hit[]: latest native non-Miss hits, from oldest to newest; each Hit contains only offsetMs and tier |
game.playfield() |
Playfield | null: keys, lanes ({ column, x, width }), hitY, spawnY, scrollTimeUs |
game.player() |
PlayerState: playing, paused, timeUs, combo, maxCombo, counts, judged, accuracy |
game.judgements() |
JudgementConfig | null: preset and tiers ({ index, id, name, color, gradient?, earlyMs, lateMs, weight, breaksCombo }) |
game.settings() |
ModSettings | null: volume, showFps, scrollTimeMs, audioOffsetMs; null until the game has sent them |
Chart notes
let cursor: number | null = null;
do {
const page: NotePage = game.notes({ fromUs: now, toUs: now + 2_000_000, cursor, limit: 128 });
for (const note of page.notes) { /* note.index, column, timeUs, endUs */ }
cursor = page.next ?? null;
} while (cursor !== null);
- Notes are sorted by time, then by column;
indexis their position. limitdefaults to 64, ranging from 1 to 256;columnfilters on a single column.nextis the cursor for the rest of the interval,nullwhen the page completes it. A query never copies more than one page: the chart stays shared (Arc) between the game and the mod thread.endUsis the end of a hold,nullfor a regular note; mines are not notes.
Latest hits
const hits = game.hits({ limit: 50 });
const latest = hits.at(-1); // { offsetMs: number, tier: number }, or undefined
offsetMs is negative when early, positive when late, and zero for an
exact hit. tier is the index into game.judgements().tiers, where the
tier's name, color, and windows are found. No timestamp or copied score
is added to this small structure. Heads and releases judged
separately each produce their own real hit.
limit is an integer from 1 to 256; {} requests 50. Misses are excluded,
never replaced by a fake hit at zero. The actual order and duplicates
are preserved, notably for chords. Each play receives a new bounded
history. The native side publishes it with no per-hit allocation, under the existing
judgement lock; the mod thread's atomic readers do not block
the game. Losing a notification in the mod queue does not lose the history.
Persistent reader contention is reported explicitly,
without returning a partial array.
mods/hit-bar/main.ts is a complete example: the standalone mod
pvng.hit-bar draws ordinary hud.box, hud.group, and hud.text nodes.
It chooses its own length, asymmetric bands, and hit positions.
It keeps its nodes and only updates those whose values change,
on ticks where hitCount changes. There is no native hud.hitBar widget.
Skins place its pvng.hit-bar/bar element independently of the latest
judgement and of the counters from pvng.judgement-display.
Playback actions
The pvng.playback-controls mod (mods/playback-controls/) provides the intro button,
independently of the skin. It uses a hud.text with
bind: { kind: "action", action: "skipIntro" }: the engine controls its
availability and its action, the overlay translates its text and automatically
hides the node outside its usage window. The script retains
ordinary HUD composition and styling, with no DOM or IPC access.
Mod action keys
A mod can declare, during its setup, an action with a default physical
key. The game displays it under the mod's name in Settings → Controls:
setup() {
controls.register({ id: "toggle-hud", name: "Afficher le HUD", defaultKey: "KeyH" });
hud.text({ id: "my-hud", text: "HUD actif" });
let visible = true;
ctx.on("controls.action", action => {
if (action.id !== "toggle-hud" || !action.pressed) return;
visible = !visible;
hud.update({ id: "my-hud", visible });
});
}
Registered identifiers are bound to the mod (<modId>/<id>). The player's
choices persist even if the mod is disabled; only the actions of active
mods are offered. Esc and F2 remain reserved. Several actions and
columns can use the same key: game inputs and replays
are never overridden. Key changes take effect at the
next play. Events arrive through a bounded queue on the mod thread
with its usual budget, without blocking native capture or judgement;
a saturated queue may drop events, so do not handle a scoring hold
or any critical state from this callback. The Skip intro button
remains an engine action, with its own configurable key in the same place.
Gameplay modifiers: gameplay.register
During its setup, a mod declares a gameplay modifier that the player enables
per play from the Mods tab of the selected chart:
setup() {
gameplay.register({
id: "auto",
name: "Auto",
description: "Le jeu joue le chart parfaitement tout seul.",
kind: "auto",
});
}
Only host-known kinds exist ("auto", "ghost", and the chart
modifiers "mirror", "random", "noLn", "fullLn", see docs/game.md): the
behavior is native, the script only declares. It can neither inject
inputs nor modify judgement; the pvng.auto mod (see
mods/auto/main.ts) is nothing more than this declaration. Rust validates
the identifier (a-z, 0-9, -), the name (32 characters), the description (200),
rejects duplicates and more than 8 modifiers per mod, as well as any call
outside setup. The published key is <modId>/<id>.
A declaration may also carry the following, all optional and validated by Rust: group (one of assist, layout, longNotes,
timing, difficulty, other, otherwise the one for the kind), icon (a lucide icon name from a fixed list of 28:
MODIFIER_ICONS in crates/modding/src/modifiers.rs, the same list as apps/web/src/lib/mod-icons.ts, which a test compares, otherwise the icon for the
kind) and conflictsWith (at most 8 <modId>/<id> keys of incompatible modifiers; the interface disables the others when one is
enabled, and the host applies its own rule anyway if a selection contains both). A modifier's parameters
never come from the script: the host declares them for the kind (GameplayModifierKind::params(); only fullLn has any, see
docs/game.md). The gameplayMods event publishes for each modifier { key, modId, name, description, kind, group, icon, conflictsWith, params } where params is a list of ModParamInfo (id, label, description, section, kind
slider|choice|columnMask, min, max, step, default, unit, choices, showWhen). A columnMask parameter is a bit mask (bit 0 = first lane, max = all) that the interface renders as one toggle
per key of the selected map. The chosen values are the
gameplayModParams setting, validated (bounds, step, declared choices) when a play starts. The host's English texts are fallbacks;
the interface translates chartparam.<id>.label|description|choice.<value>, chartparam.section.<section> and chartgroup.<group>.
The list (ModHost::gameplay_modifiers(), GameplayModifiers { revision, mods })
is sent to the interface with the gameplayMods event and follows the lifecycle
of mods like judgement sets: a disabled, failed, or uninstalled mod
removes its modifiers and the revision increases. The gameplayMods setting
contains the chosen keys; a key that no mod provides anymore has no effect.
A play with Auto creates neither a score nor a replay.
Downloads: downloads.register
A mod with the network permission (permissions: ["network"] in defineMod) can add a mirror to the
osu! source (kind: "mirror", target: "osu") or a whole source (kind: "source", one more tab
on the Download page), during its setup only (4 declarations per mod, 64 in total). The script only
declares; all HTTP is done by crates/downloader, on its own threads, never by the mods
thread. A search that the data cannot describe is written as a bridge (next section): pure functions, never I/O.
Full example: examples/mods/download-sources/main.ts (fictional hosts example.org, run by the tests
against recorded responses).
downloads.register({
id: "maps", name: "Ma source", kind: "source",
hosts: ["api.example.org", "files.example.org"], // allowlist, shown to the player
rateLimit: 60, // requests per minute (1 to 120, default 30)
auth: { kind: "token", scope: "download" }, // token entered by the player, never given to the script
search: {
url: "https://api.example.org/v1/search",
params: { q: "{query}[ keys>={keysMin}]", offset: "{offset}", limit: "{limit}", order: "{sort}" },
sorts: { newest: "date", plays: "plays" },
paging: { kind: "offset", size: 20 },
response: { format: "mapped", results: "data.items", total: "data.total",
item: { id: "uuid", title: "title", artist: "artist",
difficulties: { path: "charts", keys: "keys", stars: "stars", only: { path: "mode", equals: "mania" } } } },
},
download: { urlField: "files.zip" },
});
URL templates. Typed variables from a closed list: {query} (text, always encoded by the game), {status} and
{sort} (values from the statuses and sorts tables), {offset}, {page}, {limit}, {cursor}, {keysMin},
{keysMax}, {starsMin}, {starsMax}, {bpmMin}, {bpmMax}, {lengthMin}, {lengthMax}, {genre}, {language};
{id} only in download.url. A parameter whose variable has no value is omitted; a group
[ …] is written only if all of its variables have a value; \[ \] \{ \} write the literal character. Filters whose
variables are in the request are sent to the server, the others only narrow down the loaded results (the page
says so, clientFilters).
Responses. Simple JSON paths (a.b[0].c, 8 levels, no filter or script) to the fields of a result
(id, title, artist, creator, status, bpm, playCount, favourites, cover, difficulties with only);
format: "osu" reads an array of osu! API v2 beatmapset objects as-is (what osu! mirrors respond with). Each
value is typed and bounded by Rust; an unreadable result is ignored, nothing is made up. Pagination is offset,
page (starting at 0 or 1) or cursor (the cursor is read from response.nextCursor); a mirror paginates by
offset or page only.
Security. HTTPS only; exact hosts (1 to 8 lowercase DNS names, no port, wildcard, IP address or local name);
every URL (template, URL read from a response, cover) is checked before sending; redirects are followed manually
(3 at most), each hop rechecked, token header removed as soon as the host changes; a name that resolves to a private,
local or link-local address is refused; token kept by the host (DPAPI, one file per source), sent only in the
declared header (Authorization, X-API-Key or X-Auth-Token), never in an event, an error or a
log; same size limits and safe extraction as the built-in sources; rate held by a sliding window
per source. The player sees the hosts on the Mods page, in the Download page and in Settings, can revoke the
permission mod by mod (networkDenied setting) and turn off each mirror or source (downloadDisabled); the order of
mirrors is the downloadMirrorOrder setting (a mod mirror comes after the game's mirrors). Disabling, failing
or uninstalling a mod removes its declarations immediately (ModHost::download_sources(), deny_network).
What a declaration can NOT describe (the game rejects the declaration rather than guess; POST requests,
XML or HTML responses, multi-request searches and computed URLs are done with a bridge, below): JSON keys containing a dot or a character outside A-Za-z0-9_-, a mirror for a source other than osu!.
What neither a declaration nor a bridge can do (real limits, to be coded in crates/downloader if needed):
OAuth or form-based login, cookies, network code written in the script (never), archives the game cannot import
(rar, 7z, single file), Etterna-style packs with unpacked content (a mod source lists sets, a zip
archive = one folder), native DOM/XML parsing.
API bridges: downloads.registerBridge
When an API cannot be described as data (POST request, XML or HTML response, detail to fetch before the file,
URL to compute), the mod writes a bridge: pure, synchronous functions that compute the next step, without
ever performing I/O. The game makes all the requests (native HTTP, same safeguards as downloads.register),
then calls the mod back with the response. The script still has no network access; the network permission remains
required, can be denied mod by mod, and the player sees the hosts. downloads.register declarations work
as before. Full example: examples/mods/download-bridge/main.ts (fictional site using POST + XML, then a detail
request; run by crates/modding/tests/bridge.rs against recorded responses).
downloads.registerBridge({
id: "maps", name: "Ma source", hosts: ["maps.example.org", "files.example.org"], rateLimit: 30,
auth: { kind: "token", header: "Authorization", scope: "download" }, // optional, never shown to the script
search(query, page, state) { // page 1 the first time; returns a step
return { request: { url: "https://maps.example.org/cgi/find", method: "POST", form: { terms: query }, expect: "xml" }, state: { page } };
},
onResponse(response, state) { // { status, headers, text }; returns the next step
return { results: [{ id: "7", title: "…", artist: "…", actions: [{ id: "download", label: "Download" }] }], nextPage: 2, state };
},
action(result, actionId, state) { // a button on a result; ends with { download }
return { request: { url: `https://maps.example.org/maps/${result.id}.json`, expect: "json" }, state: { id: result.id } };
},
});
Steps. Each function returns one step: { request, state } (the game makes the request, then calls
onResponse(response, state)), { results, nextPage?, state } (typed results, end of the search or of the page),
{ download: { url, method?, headers?, filename?, format }, state? } (file to download; format: zip, osz
or qp, the archives the game knows how to import) or { error }. request: url, method (GET or POST),
headers (allowlist: accept, accept-language, content-type, referer, origin, x-requested-with,
x-api-key, x-auth-token, authorization, if-none-match), a form / json / text body (64 KiB) and expect
(json, text, xml, html). state is JSON (8 KiB) that the game hands back unchanged on the next call.
Text-only responses. response.text is the response text (2 MiB at most; beyond that, it is an error,
not a truncation), status and a few headers (content-type, content-length, retry-after, x-total-count, etag, last-modified, content-disposition).
XML and HTML arrive as text: no DOM and no native parseXml; use JSON.parse, regular
expressions and strings. 4xx/5xx responses (except 429, and 401/403 when a token is configured, which the game translates itself)
are delivered to the script, which decides what to do.
Typed results. { id, title, artist, creator?, coverUrl?, tags?, size?, keyCount?, details?: [{ label, value }], actions: [{ id, label }] } (100 results per page; each text is bounded and sanitized by Rust). The mod chooses what
is displayed; the interface draws it with a generic rendering shared by all 8 themes (labels, size, details, buttons),
never markup. coverUrl must be on a declared host (otherwise it is ignored) and goes through the game's cover route.
A result without a button keeps the download icon (download action); a button triggers action(result, id, null)
at download time, on a download thread.
Limits. 6 requests per search or per action, 60 s in total, 2 MiB of text per response, 256 KiB per step.
Each script call goes through the mods thread under the usual execution budget. An exception, a result that
JSON cannot serialize, or a budget overrun disables the bridge: the source leaves the Download page and the
mod's log says why; other mods and sources keep running. A step that does not comply with the schema (unknown
field, format: "rar", undeclared host, forbidden header) is a displayed error, without disabling the bridge.
Token. Kept by the game (DPAPI), injected by the host into the declared header (according to scope) or in place of
{token} in the URL, headers or body (only if auth is declared). The script never sees it, whether as an argument,
in the response (if the site sends it back, the game replaces it with [token]), or in an event, error or
log. No cookies, no OAuth. Download. The game downloads the file itself (size cap), extracts it without
leaving the target folder (zip-slip rejected) and requires at least one chart readable by the game. The button sends the
downloadStart command with action.
Judgement tiers
Index 0 is the tightest window; the miss tier comes last
(earlyMs and lateMs set to null). earlyMs is the window before the note,
lateMs the one after. counts (judgements, player, end of song) follows the same
order. id is stable within a set: perfect, great… for the
pvng.osu set, the mod's own for its sets, t0…tN for custom
presets, miss for the miss. weight is null when accuracy
does not use weights (Wife3). See Judgement.
Playfield
HUD units: x as a fraction of the screen width (center of the lane),
width, hitY and spawnY as a fraction of the height. A note is at height
hitY + (spawnY - hitY) × (note time − current time) / scrollTimeUs.
game.playfieldChange signals a change (resize).
HUD
Each mod has a retained node tree: a node stays displayed until
hud.remove, hud.clear, or the mod is disabled. A mod's nodes form
a layer; layers are stacked above the skin's layer (which builds the
game's HUD with the same API, see skins), in the mods'
load order.
| Function | Role |
|---|---|
hud.text({ id, parent?, text, bind?, visible?, showWhen?, animate?, element?, layout?, style? }) |
plain text (\n for a line break), or a game value with bind |
hud.box({ id, parent?, fill?, visible?, showWhen?, animate?, element?, layout?, style? }) |
rectangle: background, gradient, border, rounded corners, shadow; partially filled with fill |
hud.image({ id, parent?, src, fit?, visible?, showWhen?, animate?, element?, layout?, style? }) |
.png image from the mod's folder; fit: contain (default), cover, fill |
hud.group({ id, parent?, flow?, visible?, showWhen?, animate?, element?, layout?, style? }) |
container; with flow, its children are laid out in a row or a column |
hud.update({ id, text?, bind?, src?, fit?, flow?, fill?, visible?, showWhen?, animate?, element?, layout?, style? }) |
modifies an existing node |
hud.remove(id) |
removes the node and its descendants |
hud.clear() |
removes all of the mod's nodes |
The creation functions return the id, which is the node's handle. Reusing
an id replaces the node in place (the same parent is required); an identical
node changes nothing. Children follow their creation order. In hud.update, the
given fields of layout and style replace those of the node, the others stay;
text/bind, src/fit, flow and fill exist only for the corresponding
node type (on any other type, the call throws an exception).
Bindings
A value that changes on every judgement or every frame should not go through the script: the overlay updates it itself when it receives the event, without a round trip through the mods thread. The script only places the nodes.
bind(text) replaces the text during a play (the node'stextstays displayed outside of a play):{ kind: "combo" },"maxCombo","hits"(judged minus misses),"misses","judged","remaining"(remaining notes) — all integers;{ kind: "accuracy", decimals? }(translated percentage, 0 to 4 decimals, 2 by default);{ kind: "tierCount", tier }and{ kind: "tierName", tier }(counter and translated name of the tier at indextier);{ kind: "lastJudgement", colors? }(name of the last judged tier, the node takes its color, or the one thatcolorsgives for its id or its index:{ "perfect": "#fff", "3": "#4fc3f7" }, 64 at most);"elapsed","total"(m:ss),"timer"("elapsed / total");"fps";{ kind: "label", label }(translated word:hits,misses,combo,accuracy,paused,remaining,fps).fill(box) cuts the box horizontally at a fraction, from the left:{ kind: "songProgress" }(follows the clock on every frame),{ kind: "accuracy" },{ kind: "tierShare", tier }(share of judgements).showWhen(any node):"paused","running","showFps"(player setting),"judged"(after the first judgement); combined withvisible.animate(any node):{ on: "judgement" | "hit" | "miss", kind: "pop" | "popFade" | "flash", durationMs }(1 to 5000 ms), restarted on each occurrence, acting on scale and opacity (the node'stransformis not touched).element(any node):fps,accuracy,hits,misses,combo,timer,remaining,status,judgementorjudgementCounts; the font the player chose for this element (hudFontssetting) replaces that of the node and its descendants.
hud.text({ id: "combo", text: "0", bind: { kind: "combo" }, element: "combo",
animate: { on: "hit", kind: "pop", durationMs: 150 }, style: { size: 0.06 } });
hud.box({ id: "bar", fill: { kind: "songProgress" },
layout: { x: 0.5, y: 0.98, width: 1.2, height: 0.006, anchor: "bottom" },
style: { background: "#66b3ff" } });
Units
layout.x,layout.y: fractions of the parent's width and height (of the screen at the first level), within [-10, 10];(0, 0)is the top left; ignored in aflowgroup;layout.width,layout.height(within [0, 4]) and allstylelengths: fractions of the screen height, to keep proportions at any aspect ratio. At 1920×1080,0.05equals 54 px;layout.anchor: the point of the node placed at(x, y):topLeft(default),top,topRight,left,center,right,bottomLeft,bottom,bottomRight;- without
width/height, the node takes the size of its content. Give a size to a group whose children are placed in fractions.
Style
| Property | Values | Effect (overlay CSS) |
|---|---|---|
color |
color | text color (white by default) |
background |
color | background-color |
gradient |
{ angle, stops: [{ color, at }] }, 2 to 8 stops, at within [0, 1] |
linear-gradient(angle deg, …) |
border |
{ width, color }, width within [0, 1] |
solid border |
radius |
[0, 1] | border-radius |
padding |
[0, 1] | padding |
opacity |
[0, 1] | opacity |
font |
font name | font-family (see Fonts) |
size |
]0, 1], 0.04 by default | font size (line height) |
weight |
100 to 900 in steps of 100 | font-weight |
italic |
boolean | font-style |
align |
start, center, end |
text-align |
shadow |
{ x, y, blur, color }, x/y within [-1, 1], blur within [0, 1] |
text-shadow for text, box-shadow otherwise |
transform |
{ x?, y?, scale?, rotate? }, translation within [-4, 4], scale within [0, 10], rotate in degrees within [-3600, 3600] |
translate, scale, rotate after placement |
transitionMs |
[0, 10000] | transition duration for changes |
A group's flow: { direction: "row" | "column", gap?, align?, justify? }
(gap within [0, 1]; align: start, center, end, stretch; justify:
start, center, end, spaceBetween).
Colors: #rgb, #rgba, #rrggbb or #rrggbbaa (sRGB, non-premultiplied
alpha); the published scene carries them all as #rrggbbaa. Any out-of-range
value, unknown field, or unreadable color throws a TypeError exception
with the reason; the mod can catch it.
Per-mod limits: 256 nodes, 256 characters per text (no control character
other than \n and \t), id of 1 to 64 characters, 8 levels of groups.
Fonts
fonts: { title: "fonts/Title-Bold.ttf" } in defineMod:
- path relative to the mod's folder (symbolic links and junctions included),
.ttfor.otfextension and TrueType/OpenType signature, 8 MiB at most, 8 fonts at most; - name: 1 to 64 characters among ASCII letters and digits, space,
-,_,., with no leading or trailing space.
An invalid font prevents the mod from loading (font `name`: …).
style.font:
- a name declared by the mod designates its font, published under the family
<mod id>/<name>(big-score/title); it shadows an identical global name for that mod only; "<package>/<font>"designates a font that another package provides, passed through as is (a configurable element can thus take its user's font);- any other name is global and passed through as is (
"default", the game's font, or a player's font).
The overlay uses the game's font for a family it does not know.
Images
src is the path of a .png in the mod's folder (8 MiB at most), checked at
call time: relative path, actual file in the folder, PNG signature. The scene
carries this path; the overlay requests it from the game, which checks it again before
serving it (ModAssets::read).
Native rendering: stage.*
A mod can draw in two ways:
hud.*: nodes in the WebView overlay, on top of everything;stage.*: elements drawn by the game's native rendering engine (wgpu), in the same frame as the notes, for those who want frame-accurate effects: sparks bursting on impact, lane glows, sprites that follow the playfield.
The skin and map scripts have access to it in the same way. The
types, validation, budgets, and evaluation are in crates/stage.
Permission
It must be declared: defineMod({ …, permissions: ["stage"] }) (likewise in
defineSkin and defineChart). The Mods page flags these mods ("Draws in the
native renderer") and the player can revoke it mod by mod; the stageDenied
setting (list of mod ids, validated in Rust: valid ids, no duplicates,
256 at most) stores this. Without the permission, or if the player has revoked it, every
stage.* call throws an error; revoking the permission immediately clears the mod's
scene. A mod that builds its scene in setup gets it back after a
reload (Reload button); one that builds it at game.songStart gets
it back from the next play.
Elements
Each element belongs to the script that created it and carries an id: recreating it
with the same id replaces it (in the same place, with its running animations and particles
kept).
| Function | Element |
|---|---|
stage.sprite({ id, at, image, size, color?, opacity?, rotation?, scale?, layer?, animations? }) |
a PNG image from the package, declared in images |
stage.rect({ id, at, size, color?, radius?, … }) |
a filled rectangle, corners rounded by radius screen heights (half the short side: a circle or a pill) |
stage.text({ id, at, text, size, color?, align?, … }) |
a line (128 characters at most) in the game's font, size screen heights |
stage.emitter({ id, at, size, lifetimeMs, maxParticles, color?, endColor?, speed?, speedJitter?, direction?, spread?, gravity?, burst?, rate?, fade?, shrink?, image? }) |
a particle emitter: burst particles at each "burst", rate per second continuously |
stage.trigger({ id, on, target, play? | stop? }) |
a trigger (see below) |
stage.play({ target, animation }), stage.stop({ target, animation }) |
starts or stops an animation from a handler |
stage.remove(id), stage.clear() |
removes an element or a trigger, or everything |
A mod's images are those of defineMod({ images: [...] }) (64 at most,
PNGs from the mod's folder); all are decoded at the start of each map, with
the same limits as skin images, and packed into the playfield atlas.
The skin and map scripts use their own images.
Position (at): { space, column?, x?, y? }.
space |
Origin | x, y |
|---|---|---|
screen (default) |
top-left corner of the screen | fractions of the width and height |
playfield |
center of the columns box | screen heights, rightward and downward |
lane |
center of column column |
same |
receptor |
receptor of column column |
same |
Any position other than screen follows the playfield: it moves, rotates, and
scales with it, including when a map script makes it move
(rotation, zoom, offsetX of the columns). An emitter without column placed on
lane or receptor projects its particles onto the column of the event that
triggers it.
Layers (layer): below (behind the playfield, over the map background),
lanes (over the lanes and receptors, under the notes), above (default, over the
notes). All are under the HUD overlay. Within a layer, elements are
drawn in the order of script creation, then of their elements.
Animations and triggers
Nothing runs in the script on every frame. An element declares its animations
once: animations: { name: { durationMs, easing?, repeat?, from, to } } (8 at
most, 10 s at most), where from and to give x, y (offset in screen
heights), scale, rotation (degrees, clockwise), opacity, color; absent
values stay those of the element, which come back when the animation
ends. With repeat, it restarts until stop.
A trigger starts (play) or stops (stop) an animation of the target
element when on occurs; "burst" makes an emitter spray particles. on contains a
single field:
on |
Occurs when |
|---|---|
{ judgement: { tier?, column?, miss? } } |
a note is judged (absent filters: everything) |
{ press: { column? } }, { release: { column? } } |
a column key is pressed, released |
{ holdStart: { column? } }, { holdEnd: { column? } } |
a column starts, stops holding a long note |
The render thread evaluates animations, particles, and triggers on every frame, based on the song clock (a pause freezes them) and the judgements the frame displays: a judgement made by the input thread is queued, with no lock or wait, before the game state shows it, so the burst goes off in the very frame where the note disappears.
export default defineMod({
id: "sparks", name: "Sparks", version: "1.0.0", apiVersion: 2,
permissions: ["stage"],
setup() {
ctx.on("game.songStart", (start: SongStartEvent) => {
stage.clear();
const perfect = start.judgements.tiers[0];
stage.emitter({ id: "sparks", at: { space: "receptor" }, size: 0.008, color: perfect.color,
lifetimeMs: 380, speed: 0.55, spread: 150, gravity: 1.6, burst: 14, maxParticles: 120 });
stage.trigger({ id: "hit", on: { judgement: { tier: 0 } }, target: "sparks", play: "burst" });
});
},
});
Native rendering budgets
- Per script: 128 elements (emitters included), 16 emitters, 1024 particles
in total (sum of the
maxParticles, 512 at most per emitter, 256 perburst), 64 triggers; beyond that, the call throws an error. - Every value is bounded and validated in Rust (sizes 0 to 4 screen heights,
offsets ±4, opacity 0 to 1, scale 0 to 16, colors
#rgbto#rrggbbaa, column < 32, declared images); an invalid value throws an error. - Everything is drawn in the playfield's single instance batch, with the game's
shader (no custom shader): a particle, rectangle, or
sprite image is one quad, a text is one quad per glyph. The bench
(
--bench-gameplay) shows the instances per frame and the share of native scenes. - A frame without a scene change allocates nothing; a dead particle frees its slot, and the oldest one is replaced when the emitter is full.
Storage
| Function | Role |
|---|---|
storage.get(key) |
stored JSON value, null otherwise |
storage.set({ key, value }) |
stores a JSON value |
storage.remove(key) |
removes a key |
storage.keys() |
keys, sorted |
storage.clear() |
empties the storage |
Each mod has its own file data\mod-storage\<id>.json, chosen by the host
from the validated id: a mod cannot read another's storage. Limits:
256 KiB (keys plus values as JSON), 256 keys of 1 to 128 characters. Changes
are written at most once per second (temporary file then
rename) and when the host shuts down; uninstalling does not erase them.
Package dependencies
A mod or a skin declares the packages it uses with uses: the package id
and a semver version range ("^1", ">=1.2, <2"), or
{ version, required, feature }.
- Mods are set up (
setup) after the packages they use, otherwise in load order (loadOrder, thenid); a circular dependency rejects all the packages in the cycle (dependency cycle: a → b → a). - A dependency is optional by default: if it is absent, of a version that does not match, or failing, the package still loads,
ctx.has("<id>")isfalse, the function that depends on it is skipped, and the Mods page displays "feature "X" disabled: package Y missing" (featurenames X; without it: "features using Y disabled"). required: truemakes the dependency mandatory: without it the package does not load (requires Y ^1: package Y missing).- 16 packages at most; a package cannot use itself. A skin resolves its dependencies at every map launch, against the active mods.
Configurable elements
A package exposes elements that others place and style, without the scripts calling each other: each keeps its own engine and budget.
// Provider: declares its elements and their options, and receives the settings.
defineMod({
id: "badges", /* … */
provides: { elements: { badge: { options: {
x: { type: "number", min: 0, max: 1, default: 0.5 },
label: { type: "string", maxLength: 32, default: "?" },
colors: { type: "colors", default: {} },
} } } },
setup() {
ctx.on("elements.configure", ({ element, from, options }) => { /* redraw */ });
},
});
// User (mod or skin): declares the provider in `uses`, then configures.
defineSkin({
id: "mon-skin", /* … */
uses: { "badges": "^1" },
setup(play: PlayContext) {
if (ctx.has("badges")) {
ctx.element("badges/badge").configure({ x: 0.9, label: "Hi" });
}
},
});
- Option types:
numberandinteger(min,max),boolean,string(maxLength, 256 at most),color(#rgb,#rrggbb,#rrggbbaa),enum(values, 1 to 32),colors(colors by key, 64 at most).defaultis the value received when the user provides none. 16 elements and 32 options at most. configurechecks the options against the declaration (unknown option, type, bounds: exception in the calling script), fills in the default values, then the provider receiveselements.configure({ element, from, options }) after the calling script. Configuring an absent package does nothing; a package missing fromusesthrows an exception.- The last setting of each user is kept: a provider that restarts (a skin at every launch) receives it again. When a skin is replaced, the elements it had configured revert to their default values.
- An element can take a font from its user:
style.fontaccepts"<package>/<font>"(Fonts). - An element declaration can have
root(id of the root node of the provider's HUD, which the editor finds on screen; only the id is checked) and each option can have alabel(64 characters at most) andplayer: trueto be listed in Settings → Mods if the element has no place; all the options of an element that has numericxandy(positions, sizes, visibility, colors, decimals…) are adjusted in the skin editor,playeror not. - The player has the last layer: their configuration of the active skin
(
skinConfig[skin].elements, written by the skin editor:<package>/<element>→{ option: value }) is overlaid on the complete options the provider would receive (configure()from the skin or a mod, otherwise defaults). A single order: defaults < skin < player's editor; a global value never overrides the editor. The provider then receiveselements.configurewithfrom: "player"and all the options. A value that the declaration rejects (unknown option, type, bounds, enumeration) is ignored and reported in the provider's messages. The values of an absent or disabled provider are kept and ignored; they apply when it runs again, at every restart of the provider and after a skin is replaced. Global settingelementOptions: only for theplayer: trueoptions of an element with no place (no numericxory, so the editor cannot place it), beneath the skin's configuration; the values that an older version had saved there for an element that the editor places are carried over once into the configuration of the active skin and of skins already configured (without overwriting a value from the skin), then erased. - On the host side,
ModHost::elements()publishes the catalog of the active packages' elements (ElementCatalog { revision, elements }: key, mod,root, options with label, bounds, default and effective value), republished under a new revision when mods load or when the player's values change.ModHost::set_element_overridesreplaces them without restarting any mod.
The pvng.judgement-display mod (mods/judgement-display/)
displays the last judgement in the color of its tier (lastJudgement binding,
short animation) and the number of judgements of each tier
(tierName/tierCount bindings), updated by the overlay without going through
a script. It provides two elements:
| Element | Options |
|---|---|
judgement |
x, y, anchor, size, font, visible, animation (popFade, pop, flash, none), durationMs, colors (by tier id or index) |
counts |
x, y, anchor, size, font, visible, textColor, background, colors |
HUD mods
Every HUD widget is a mod, an ordinary folder in mods/ (exportable,
uninstallable, deletable like the others), that provides elements; the default skin
(skins/default/skin.ts) declares them in uses (optional) and places them with
ctx.element(…).configure without creating a single hud.* node
(skins). Each one follows the judgement-display pattern:
provides.elements with root, options x, y (0 to 1), anchor, size,
visible, colors, and nodes carrying their element so that the hudFonts
setting continues to apply. All their options (positions, sizes,
visibility, colors, decimals…) are set in the skin editor, skin by skin;
player: true no longer places them in Settings → Mods (see above).
| Mod | Elements (key mod/element) |
Specific options |
|---|---|---|
pvng.accuracy |
accuracy |
textColor, decimals (player, 0 to 4, default 2) |
pvng.combo |
combo |
showLabel, textColor, labelColor |
pvng.counters |
timer, remaining, hits, misses |
textColor (timer); width, labelColor, valueColor (rows) |
pvng.progress-bar |
progress |
width, height, radius, direction (leftToRight, rightToLeft), trackColor, fillColor, fillEndColor (gradient), showTime, timeSize, timeColor |
pvng.fps |
fps |
textColor, always (player: ignores the "Show FPS" setting) |
pvng.pause-status |
status |
textColor, background |
Widths and heights are in screen heights, like any layout:
a bar spanning the full width of a 16:9 screen measures about 1.73. The progress
bar is a demonstration of what a mod does with ordinary primitives:
fill: { kind: "songProgress" } remains a binding that the
overlay clips from the clock, the reverse direction is the same box rotated by a
half turn around its center, and the time is a hud.text bound to timer.
The hits, misses and remaining bindings remain available to any mod.
Mods leave their elements without nodes as long as no play is running and
remove them at the end (game.songEnd); an element with visible: false (like
hits and misses in the default skin) is not drawn.
Judgement sets
Every judgement set comes from a mod. A mod registers a judgement set in its
setup with judgements.register (nowhere else: registration closes once the
mods are loaded). The set appears, under the mod's name, in the Settings →
Judgement category and in the quick selector of the map selection, alongside
the sets from other mods and the player's custom
presets (which require no mod), under the key <mod id>/<set id>, with one
slider per declared parameter. A mod that does only this is a shareable
judgement pack: once installed, its sets appear without restarting the game.
The osu!mania and Etterna sets are written this way, in ordinary folders of
mods/, presented like all the others: pvng.osu
(mods/osu/main.ts, set osu-mania, parameter od) and pvng.etterna
(mods/etterna/main.ts, set etterna, parameter judge, abbreviated J,
Wife3). Rule details: Judgement.
export default defineMod({
id: "judges", name: "Judges", version: "1.0.0", apiVersion: 2,
setup() {
judgements.register({
id: "tight", // a-z, 0-9, -, 32 characters at most
name: "Tight",
// `short` (optional): prefix attached to the value when space is short ("W2");
// without it, the label, a space and the value ("Width 2").
params: { width: { label: "Width", short: "W", min: 1, max: 3, step: 1, default: 2 } },
accuracy: "weights", // "osuScoreV1" | "wife3" | "weights" | "continuous"
holds: "head", // "osuCombined" | "etterna" | "head" | "separate"
// Hit tiers from tightest to widest, then the Miss. `windowMs` applies to
// both sides; `earlyMs` (before the note) and `lateMs` (after) split them.
tiers: ({ width }) => [
{ id: "hit", name: "Hit", color: "#ffffff", earlyMs: 10 * width, lateMs: 12 * width, weight: 1 },
{ id: "miss", name: "Miss", color: "#ff0000", windowMs: 20 * width, weight: 0, breaksCombo: true },
],
});
},
});
tiers(params)runs on the mod thread, within the mod's budget, once per parameter combination, right after the mod'ssetup: each parameter takes the values frommintomaxin steps ofstep(herewidth= 1, 2, 3), normalized like the player's own values (clamped, rounded to the step). It must be synchronous and return the tiers. The game keeps the resolved tiers of each combination: matches and the settings preview read them without calling the mod again, and a mod does not need to cache its tiers. See the combination cache.- Limits: at most 4096 combinations per set and 1 s of computation for all sets during loading; beyond that, each combination is resolved on its first use (player's choice or launch), only once, then stays cached; the mod receives an informational message.
- A combination for which
tiersthrows an exception, returns an invalid shape or tiers that the game rejects is unavailable: a single message per set reports it ("1 of 3 parameter combinations are unavailable (first: width=3: …)"), it is never retried and the selectors skip it; a set with no valid combination is not offered. An exception fromtiersdoes not count toward the errors that disable the mod; exceeding the execution budget does. - A mod disabled during a session (repeated errors or budget overruns)
removes its sets: the host republishes the list (
JudgementSets::revisionincreases) and the interface receives it immediately. A selected set that disappears (mod disabled, failed, uninstalled) or an unavailable combination gives way to the default set at launch, and the player is notified. - The set describes only its tiers: accuracy (
accuracy) and holds (holds) are native rules that it chooses. Underwife3, tiers have no weight; otherwise each tier, Miss included, has one.osuCombinedrequires exactly 5 hit tiers. continuoususes the windows and weights as points of the curve: a plateau within the first tier, then native linear interpolation between the bounds, separately for early and late. Weights must be finite and non-increasing; the Miss keeps its declared penalty.mods/prism/main.tsprovides a complete example with no parameters.separateproduces two score objects per LN, of equal weight: head then physical release. Each event keeps its true offset; the expiry of a tail held too long is a Miss, not an invented release. The other hold models keep their rules.gradient?: string[]on a tier declares 2 to 8 simultaneous hexadecimal colors for its text. This metadata is validated and kept in the resolved tables and the replays;colorremains its single color.- The game validates the returned tiers
(
validate_set: 2 to 33 tiers, increasing windows on each side, unique ids, colors, weights); the resolved result is stored as is in the replays, which are re-judged without the mod. - Limits: 8 sets per mod, unique ids within the mod; name of 1 to 64
characters; at most 8 parameters, names of 1 to 32 ASCII letters and digits
starting with a letter,
labelof 1 to 32 characters,shortof 1 to 8 characters,min < max,0 < step ≤ max − min,defaultwithin[min, max]. An invalid declaration makes the mod'ssetupfail.
Difficulty ratings and mod tables
chartset and chart store the folders and difficulties; they have
neither a star rating nor an MSD. A mod declares its columns in setup with
tables.register, then a view with ratings.register that points to a
numeric column of its own table. The pvng.ratings mod provides the
complete example in mods/metron/main.ts: etterna_rating holds the
MinaCalc 515 MSD and the seven skillsets; osu_rating keeps the osu!mania
Current stars. Five other versioned tables provide osu!mania 2016, Quaver 2025,
Interlude 2025, Daniel 2026 and SunnyXXY 2024. Their native calculations are
requested after the menu loads and after an import, with at most two catch-up
runs in progress for the mod. A calculation error specific to a chart is kept
as unavailability, without interrupting the other views or producing an
artificial rating.
The mod also chooses the presentation with panels. For Etterna, it declares
a skillset radar with fill bars and a timeline; for osu, a chart profile
(share of long notes, average and peak density) and a timeline.
These are neutral statistics that are actually computed, not an imitation
of skillsets, osu strain or pp. pvng.osu and pvng.etterna remain
the independent judgement set mods.
panels is an ordered list, empty by default, of at most four panels:
metrics: values readable side by side;bars: values and comparative bars;radar: named axes and fill bars, with no number of axes imposed by a calculator;timeline: chart series, chosen fromdensityandbpm.
The first three have title and fields. Each field declares label,
source, unit (empty by default) and decimals (1 by default).
source: { kind: "column", column: "value" } reads a numeric column of
this view's table, including its main column.
source: { kind: "chart", metric: "holdPercent" } reads a neutral statistic:
notes, holds, holdPercent, averageNps, peakNps, bpmMin, bpmMax
or duration (seconds). holdPercent equals holds / notes × 100,
zero for an empty chart; a missing analysis remains unavailable.
bars and radar also accept max, a finite fixed bound, strictly
positive and less than or equal to 1,000,000, validated by Rust. Without this
bound (or with null), the scale stays automatic according to the panel's
values, with a maximum of at least 1. With max: 40, declared by the
ratings mod for the Etterna skillsets, 10 fills 25%, 20 fills 50%
and any value greater than or equal to 40 fills 100%. The geometry of the
bars and the radar is clamped between 0 and max; the displayed numeric value
remains intact. An unavailable value has no fill. The axes carry
the full names provided by the mod, with no numeric index to decode.
A timeline has title and series: [{ label, source, unit }].
The values come from the existing analysis and its binSeconds;
the charts run no calculation on the UI thread.
The theme draws these declarations without knowing the identifier of the mod or
of the calculator. Changing the rating replaces the panels and their sources;
a missing value is displayed as "—", never as an invented zero.
export default defineMod({
id: "ratings-pack", name: "Ratings pack", version: "1.0.0", apiVersion: 2,
setup() {
const calculator = metron.catalog().calculators.find(c => c.id === "quaver-2025");
if (!calculator) return;
tables.register({
id: "quaver_rating",
columns: [{ name: "value", kind: "number", indexed: true }],
});
ratings.register({
id: "stars", name: "My stars", calculator: calculator.id, unit: "★",
table: "quaver_rating", column: "value", version: calculator.version,
panels: [
{ kind: "metrics", title: "Chart profile", fields: [
{ label: "Stars", source: { kind: "column", column: "value" }, unit: "★", decimals: 2 },
{ label: "Long notes", source: { kind: "chart", metric: "holdPercent" }, unit: "%", decimals: 1 },
] },
{ kind: "timeline", title: "Timeline", series: [
{ label: "Density", source: "density", unit: "NPS" },
] },
],
});
library.filters.register({
id: "stars", name: "My stars", table: "quaver_rating",
column: "value", version: calculator.version, unit: "★",
});
const backfill = () => { ratings.backfill({ id: "stars" }); };
ctx.on("library.ready", backfill);
ctx.on("library.chartAdd", backfill);
ctx.on("ratings.progress", result => {
if (result.id !== "stars") return;
if (result.total === 0 && !result.error) { ui.dismiss(); return; }
ui.popup({
title: "My stars", detail: result.error ?? `${result.done} / ${result.total}`,
done: result.done, total: result.total,
actions: result.error ? [{ id: "retry", label: "Retry" }] : [],
});
});
ctx.on("ui.action", action => { if (action.id === "retry") backfill(); });
ctx.on("game.songStart", () => {
const stars = metron.difficulty({ calculator: "quaver-2025" });
if (stars !== null) hud.text({ id: "map-stars", text: `${stars.toFixed(2)} ★` });
});
},
});
library.ready arrives after the interface and the mods have loaded:
no chart is rescanned at startup; it is the global check for missing or
outdated ratings (also triggered by the explicit Rescan and the
"Recalculate ratings" button). library.chartAdd passes chartId after a scan, one
per chart; library.chartsAdded passes the same ids in a single event
(chartIds, 8192 at most, truncated beyond that): a mod responds to it with
ratings.backfill({ id, charts: chartIds }), which examines only those charts (nothing
else is calculated; this is what installing a pack does).
ratings.backfill({ id }) queries the charts that are missing
or whose version, date or size has changed, then launches the native calculation
on the workers of crates/library. It returns a request identifier
(number | null); ratings.progress provides done, total, failed,
finished, error and ahead only to the requesting mod. The result stays
in SQLite. All host requests go through ONE FIFO queue, one calculation at a time:
a request that arrives while another is running waits (ahead = number of calculations ahead of it,
0 once it is running; done and total stay at 0 until then), without
blocking the mods thread. A request that nobody is listening to anymore (the mod was
unloaded or the mod host replaced) is abandoned at the next report: an
orphaned calculation therefore never blocks the requests of the live host (the rows already calculated remain).
ui.popup draws the title, the text, the progress
and up to four declarative actions; the interface never treats its
text as HTML. Closing sends ui.action with id: \"dismiss\";
ui.dismiss() removes the window. A mod has no access to the DOM or to raw SQL.
Library filters declared by mods
library.filters.register({ id, name, table, column, version, unit? })
adds a criterion to the search catalog. Like note views,
registration is reserved to setup and requires a table already declared
by this mod. The number or text type comes from the validated column;
the script passes no SQL, no expression, and no function executed per chart.
The example above makes Quaver stars actually filterable, using
the results of the existing native calculation, under the key ratings-pack/stars.
The pvng.ratings mod also declares its global filters and its seven
skillsets on its own indexed columns.
Limits: 32 filters per mod, unique identifiers conforming to contribution
identifiers, a name of 1 to 64 characters, an optional unit of 0 to 16
characters with no control characters, and a strictly positive integer version. This version
is that of the rows expected in the table; for a table fed by
Metron, use calculator.version. indexed: true on the relevant columns
lets SQLite make use of their indexes.
The host/ prefix is reserved for neutral fields; a mod named host
cannot declare filters. Received keys are capped at 256 bytes,
with no control characters.
The host publishes the full libraryFilters catalog (neutral fields and mods)
after loading and removes the contributions of a stopped mod. An interface
sends a typed LibraryQuery: text limited to 512 characters and 16 clauses
at most. A numeric clause has at least one finite, inclusive bound,
with minimum ≤ maximum; signed decimals are accepted with no imposed rounding.
A non-empty text clause is at most 256 characters long, ignores outer whitespace,
and chooses between exact match and literal substring.
Free words and all clauses combine with AND on the same chart,
never across multiple difficulties of a folder.
The native query revalidates the owner, the column and its type, then
requires the chart version, date, and size recorded with the row.
Missing values, calculation errors, and stale rows do not match,
even against a zero bound. All values are bound as SQLite parameters.
Arbitrary pages are capped at 80 sets and contain only the matching
difficulties; the count, the page, and the anchor ranks use
a single snapshot. libraryPage accepts at most two folder identifiers
in anchors; the response gives their exact filtered rank, or null only
if they no longer match. The interface can thus find the visible folder and
the open folder again after a catch-up, even when moved by several pages,
without loading the whole library. Folder order is based on their
first original index, not on the index of their first surviving difficulty.
An unavailable filter causes an explicit error, not the silent removal
of a condition. It requires no DOM access, native plugin,
chart decoding during search, or per-difficulty mod callback.
Table and column identifiers are bounded and validated, tables
are isolated per mod, and their rows disappear with their chart. A
numeric column can be indexed for filters. The engine rejects a
view that targets another mod's table, a non-numeric column, or a
version different from metron.catalog(). Results that cannot be computed are
recorded as an error and displayed as "—", with no artificial rating.
ratings.register is reserved to setup: at most 16 views per mod, unique
ids, a name of 1 to 64 characters, a unit of 1 to 16 characters.
Rust validates the panels: title and labels of 1 to 64 characters, unit
of 0 to 16 characters with no control characters, precision from 0 to 3;
1 to 16 fields for metrics/bars, 3 to 12 for radar, 1 or 2 distinct
sources for timeline. Columns must belong
to the declared numeric table. HTML, CSS, functions, and arbitrary sources
are not accepted.
Calculators available to mods (metron.catalog()): osu-2016, osu-2018,
osu-current, etterna-515, quaver-2025, interlude-2025,
daniel-2026, sunnyxxy-2024. MinaCalc 515 produces the MSD at 1.00× for
4K/6K/7K, not for 5K. During a play,
metron.difficulty({ calculator }) reads the preloaded native value for
the chart (number | null); game.song()?.ratings gives the available values
indexed by calculator identifier, while
game.settings().ratingSystem gives the chosen <mod>/<view> key.
Disabling or uninstalling a mod removes its views live; the choice
reverts to pvng.ratings/etterna-515 (or the first available view) with
a notice. Hiding a view does not disable the mod that provides the judgement.
Changing the rating does not affect the judgement.
For a performance counter, metron.performance({ calculator, accuracy })
receives a score fraction in [0, 1] specific to the calculator and
returns a request identifier (number | null, null if the service is
busy). Only osu-2018 (pp) and etterna-515 (SSR) have a performance value;
osu-2016 is available for difficulty only. osu-2018 remains a performance
calculator, but pvng.ratings no longer declares it as a difficulty view
(its difficulty is exactly that of osu-2016). Etterna computes the SSR,
not osu! pp. The host does not implicitly convert the accuracy of the active
judgement set into an osu! or Etterna score: the mod supplies the appropriate accuracy.
The metron.performanceResult result (only to the requesting mod) contains
requestId, calculator, value, unit (pp or SSR), and error. A
new chart invalidates old responses; requests are limited to
two per mod and 64 in total. The decoded chart is shared without copying its
notes, Metron runs in the compute pool, and the game and mod threads
never wait on the computation.
ctx.on("metron.performanceResult", result => {
if (result.error) { log.warn(result.error); return; }
if (result.value !== null) {
hud.text({ id: "performance", text: `${result.value.toFixed(2)} ${result.unit}` });
}
});
// With an osu! 2018 score computed by the mod:
function scoreUpdated(osuScoreFraction: number) {
metron.performance({ calculator: "osu-2018", accuracy: osuScoreFraction });
}
osu-current, Quaver, Interlude, Daniel, and SunnyXXY do not provide
performance in Metron: any request is rejected, and no pp is derived
from stars. Leyna pattern recognition is not a scalar rating.
Local leaderboard: leaderboard.*
A mod reads a chart's LOCAL leaderboard: read-only, typed, bounded, with no permission (it is the player's own data; no path, file, or replay entry is transmitted). Design: Mods: the local leaderboard and song-select tabs.
export default defineMod({
id: "best-plays", name: "Best plays", version: "1.0.0", apiVersion: 2,
setup() {
ctx.on("game.songStart", () => { /* chartId comes from library.chartAdd, from your tables… */ });
ctx.on("library.chartsAdded", ({ chartIds }) => {
const id = leaderboard.query({ chartId: chartIds[0], limit: 5 }); // number | null
if (id === null) return; // too many pending requests
});
ctx.on("leaderboard.result", result => {
if (result.error) { log.warn(result.error); return; }
for (const entry of result.entries) {
const who = entry.playerName ?? "inconnu"; // old replay without a name
const pp = entry.performance === null ? "—" : `${entry.performance.toFixed(2)} ${result.performance?.unit}`;
log.info(`#${entry.rank} ${who} ${entry.accuracy.toFixed(2)}% ${pp} (${result.judgement})`);
}
});
},
});
leaderboard.query({ chartId, limit?, offset? }):chartIdis the library chart identifier (library.chartAdd,library.chartsAdded, mod tables),limitfrom 1 to 100 (20 by default),offsetfrom 0 to 100,000. Out of bounds, wrong type, or unknown field: exception (validated in Rust).leaderboard.best({ chartId })isquerywithlimit: 1: rank 1 of the entire leaderboard, imported replays included (received).- The return value is a request number, or
nullwhen requests pile up (2 pending per mod, 16 across all mods) or when the host cannot accept them. The computation runs on acrates/libraryworker; the mod thread never waits for it. leaderboard.resultarrives only at the requesting mod:requestId,chartId,offset,total(leaderboard size),entries,judgement(name of the current judgement),performance({ calculator, unit }ornull),unavailableReplays, anderror(English message,nullif all is well: unknown chart, library or workers unavailable). A disabled or unloaded mod no longer receives them.- An entry:
rank(1-based in the entire leaderboard),replayId,playerName(nullfor an old replay without a name: never attributed to the current player),received,accuracy(percent),performance(nullif it cannot be computed),performanceNonstandard,maxCombo,misses,tiers([{ name, count }], Miss counted only once),rate(recorded rate),modified(chart modifiers),playedAtMs. - This is the interface's leaderboard: each replay is rejudged with the player's CURRENT judgement, scored by the chosen performance calculator, and ranked by performance when there is one (accuracy, combo, misses, and date only break ties afterward). There is no cache: each request rejudges the chart, hence one page at a time and the limit of 2.
Tabs in the selection screen: tabs.*
A mod adds a tab to the selected chart's panel (after Info, Leaderboard, Mods, Training, Editor) or adds sections to the Info, Leaderboard, and Mods tabs, in a declarative way: Rust validates and bounds everything, the interface is a generic render (all eight interfaces inherit it), and never any markup, style, or code from the mod. Design: Mods: the local leaderboard and song-select tabs.
setup() {
tables.register({ id: "skills_rating", columns: [{ name: "stream", kind: "number" }] });
ratings.register({ id: "skills", name: "Skills", calculator: "etterna-515", unit: "MSD",
table: "skills_rating", column: "stream", version: metron.catalog().calculators.find(c => c.id === "etterna-515")!.version });
tabs.register({ id: "skills", title: "Skills", icon: "gauge", order: 10, panels: [
{ kind: "metrics", title: "Overview", fields: [
{ label: "Stream", source: { kind: "column", rating: "skills", column: "stream" }, unit: "MSD", decimals: 1 },
{ label: "Plays", source: { kind: "leaderboard", stat: "plays" }, decimals: 0 } ] },
{ kind: "leaderboard", title: "Best plays", limit: 5 },
{ kind: "text", title: "Note", text: "Valeurs de la chart de base." },
] });
tabs.extend({ tab: "info", slot: "bottom", panels: [{ kind: "text", text: "Sous les infos de l'hôte" }] });
}
tabs.registerandtabs.extendare reserved forsetup; skins and map scripts do not call them. Panels:metrics,bars,radar,timeline(as in the notes) and two new ones,leaderboard(1 to 10 best plays of the chart, the already-evaluated leaderboard from the Leaderboard tab) andtext(1 to 280 characters, no markup).- Field sources:
column(numeric column of a note's table declared by the same mod, declared earlier),chart(neutral statistic),leaderboard(plays,bestPerformance,bestAccuracy: exact or unavailable; the unit is that of the leaderboard). - Bounds: 4 tabs and 4 extensions per mod, 12 tabs and 24 extensions in total, tab title
of 1 to 24 characters, icon from a closed list (
layers,info,trophy,star,gauge,activity,chart-bar,list,flame,music,clock,target,sparkles,bookmark,heart,users),orderfrom 0 to 1000, 1 to 8 panels per tab and 1 to 4 per extension. tabs.extend({ tab: "info" | "leaderboard" | "mods", slot: "top" | "bottom", order?, panels })adds before or after the host's content, without ever removing or rewriting it; on Leaderboard, two bounded strips surround the list (only the list scrolls).- Tabs follow the mod live (disabling, failure, uninstallation); if the displayed tab disappears, the page falls back to Info.
Scroll speed
Each system shipped with the game is an independent mod, declared in
setup with scrollSpeed.register: a parameter and a toMs conversion
to the native scroll time in milliseconds. It appears under its mod's name
in Settings → Scroll speed, with the key <modId>/<id>.
Prism in ms follows exactly this contract: it is not a choice hard-coded in
the interface. The complete examples are mods/scroll-prism,
scroll-osu, scroll-etterna and scroll-quaver, separate from the judgement mods.
Formulas and sources: Scroll speed.
export default defineMod({
id: "example.beat-scroll", name: "Beat scroll", version: "1.0.0", apiVersion: 2,
setup() {
scrollSpeed.register({
id: "beats", // a-z, 0-9, -, at most 32 characters
name: "Beats",
// `short` (optional): prefix attached to the value ("B4"); without it, the
// label, a space and the value ("Beats 4").
param: { label: "Beats", short: "B", min: 1, max: 8, step: 1, default: 4 },
// Scroll time in ms for a parameter value. `context.travel`:
// distance travelled during that time, in screen heights (0.8).
toMs: (beats, context) => beats * 150 * context.travel / 0.8,
});
},
});
toMs(value, context)runs only on the mods thread, within its budget. After loading, each suggested point frommintomaxbystepis resolved once (at most 4096 points). The published grid enables instant preview and switching systems at the nearest time.- Entered values are free-form and are neither clamped nor snapped to
step. An off-grid value is resolved asynchronously by the sametoMsbefore it can be played. The provider publishes its latest custom result (custom:value,scrollMsorerror); rapid requests are coalesced into the latest value. A launch reads only an exact result that has already been published, without running the mod. contextcontains onlytravel, the reference distance in screen heights. Conversions keep a screen-relative speed, without depending on the chart's BPM. Etterna's X-mod does not correspond to a single time and therefore has no place here.- A time must be positive, finite and representable in
f32microseconds by the renderer. There is no global 200–3000 ms bound. A conversion that throws an error or returns an invalid time remains explicitly unavailable; it never plays with a neighboring value. A custom failure that is already known is not retried in a loop. Conversion errors do not disable the mod, unlike repeated overruns of its budget. - A disabled, failed or uninstalled mod removes its systems
(
ScrollSpeedSystems::revisionincreases). If the chosen provider disappears, the interface offers the Prism mod at the same resolved time, with a notice, only if that mod is available. Without a usable provider, Play remains unavailable; no fake native system is added. - The game passes only the effective time to the renderer and to mods
(
game.settings().scrollTimeMs,game.playfield().scrollTimeUs), which replays record. - Limits: 8 systems per mod, ids unique within the mod; name of 1 to 64
characters;
labelof 1 to 32 characters,shortof 1 to 8,min < max,0 < step ≤ max − min,defaultin[min, max], at most 4096 values. An invalid declaration makes the mod'ssetupfail.
Converting an osu! skin: the skinImport permission
A mod that declares permissions: ["skinImport"] can offer to convert a skin that the player
chooses (folder or .osk) into a Prism skin. It touches no file and no pixel: the host reads the
source, transforms the images and writes the folder; the mod provides only values (never
code). The game's mods/skin-converter mod (pvng.skin-converter) is the complete example;
the design is in convertisseur-skin-osu.md.
skinImport.register({ id, name, description?, localized?, sources })(insetup, 4 per mod) adds a card to the Skins page;sources:folderand/orarchive. The card's button opens the game's file dialog (never the mod's), not during a play session.- Event
skinImport.opened { importerId, locale, source, error }: the source as read natively (folder: 4096 files, 6 levels, links ignored;.osk: 512 MiB, 8192 entries, 64 MiB per entry, 1 GiB in total).sourcecontains the parsedskin.ini(SkinIni:[Mania]sections merged byKeys, indexed columns,#rrggbbaacolors, out-of-range values rejected) and the image inventory (SourceImage: normalized name without@2xor-N, dimensions, scale, animation frames, blank image, peak color), without any bytes.localeisen,frorzh. skinImport.stage({ sourceId, skin, images })→ request number: the host validatesskin(aSkinDescription: sharedPlayfieldSpec, one layout per key count with itsLaneSpecs, HUD slots) with the skin rules, executes theImageOps (source,dest,animationorframe,padTop/padBottomsigned in source pixels,fitorstretch, PNG; an image without a transformation is copied as is) on the nativeskin-importthread, and responds withskinImport.staged: files, sizes, 900 KiB budget (overBudget, reported, not enforced).skinImport.create({ stageId })→skinImport.created { id }: the host generates a readableskin.ts, writes it into<data>/skins/(staging folder, free identifier-2,-3, the script is loaded before the rename) and never overwrites an existing skin.skinImport.panel({ title, status, detail?, sections, notes, actions }): the declarative card (text only, bounded: 6 sections of 24 lines, 48 notes, 4 actions). An action witheditSkin: "<id>"(a skin that this mod created) opens the skin editor; the others reach the mod throughskinImport.action { id }.skinImport.close()removes the card; "Close" sendsdismiss.
The errors from these functions are exceptions (missing permission, source that is not the player's, request already in progress). A host without a skins folder simply shows no importer.
.pvmod packages
A package is a ZIP archive of the mod folder, named <id>-<version>.pvmod
by the export. ModInstaller (see Integration):
- installs a package into
<mods folder>/<id>: the whole archive is verified before anything is written, extracted into a hidden folder inside the mods folder, then the mod is loaded in a throwaway engine to read itsdefineModdeclaration; finally the folder is put in place by renaming. A failed installation leaves the mods folder untouched; - replaces an installed mod with the same
idregardless of its version and reports the change: new installation, update (from), reinstallation, downgrade to an earlier version (from), or replacement of an unreadable mod. An<id>folder that contains a different mod is not replaced; - uninstalls a mod (its folder
<mods folder>/<id>); its storage is kept for a reinstallation; - exports a mod as a
.pvmod: hidden entries (.git…) andnode_modulesare skipped, every other file must be allowed.
All mods, including the game's own, can be exported and uninstalled: the Mods
page lists them identically. Uninstalling a mod from mods\ next to
prism.exe deletes its folder if it is writable, otherwise an
error names the path. Removing a mod means deleting its folder.
Package rules
| Rule | Value |
|---|---|
| Allowed files | .ts (including .d.ts), .ttf, .otf, .png, .json (data), .md, .txt |
| Paths | relative, separated by /, with no .., ., drive letter, :, Windows reserved name (con, nul…), or trailing dot or space; at most 200 characters and 8 levels; no two names that differ only by case |
| Rejected entries | symbolic links, encrypted entries, compression other than stored/deflate |
| Size | 64 MiB for the file, 16 MiB per extracted file, 64 MiB in total |
| Count | 1024 entries, 256 files |
| Root | main.ts or index.ts at the root of the archive, or inside a single folder that contains everything (archive of the folder itself) |
Each extracted file is held to the size declared by the archive: an archive
that lies cannot write more than what was verified. A .json is
never a manifest: a mod's only declaration is its defineMod.
Mods page
The interface's Mods page lists the mods (status, reason for a failure, stage
permission) and their messages. It installs a package (button or drag-and-drop
of a .pvmod onto the window), exports a mod to a chosen folder, and
uninstalls; these operations run off the interface thread and then
restart the host. Its commands (installMod, exportMod, uninstallMod,
reloadMods, openModsFolder) are handled on the Rust side; installing, exporting,
uninstalling, or reloading is refused during a play session.
Each mod has an enabled/disabled switch, usable from the keyboard
with Space. The choice is kept in Settings.disabledMods and reloads
the host without uninstalling the package or erasing its storage. A change
received during a play session or a replay waits until the player returns to the menu.
The switch represents the player's choice; the "Failed" or
"Disabled" status displayed next to it remains the actual result of execution.
A package whose declaration provides no valid identifier cannot
be disabled through this setting; its unavailable switch explains why.
The game's own mods have the same switch. Their judgements and other contributions disappear when they are disabled, and come back when they are re-enabled. Disabling osu!mania does not implicitly re-enable it to serve as a fallback: if no chosen game remains available, the selector says so and launching is refused until another available or custom judgement is chosen.
Mods folder
During development, a mod is simply a folder inside the mods folder:
%LOCALAPPDATA%\Prism\PrismNG\data\mods
C:\Users\<utilisateur>\AppData\Local\Prism\PrismNG\data\mods
Copy-Item -Recurse mods\score-counter "$env:LOCALAPPDATA\Prism\PrismNG\data\mods\"
The game creates this folder at startup. The PRISM_MODS_DIR environment
variable overrides only this root of the player's data; the game's own mods
are still read from mods\ next to prism.exe (or from mods/ in the repository
during development). Transpiled scripts are cached in
data\mod-cache, and mod storage is in data\mod-storage.
Security
A downloaded mod must not be able to do anything other than what the API gives it:
- its code runs in its own Rust-TS engine (QuickJS), on the
modsthread, never in the WebView: no access to the DOM, the interface's JavaScript, the game's IPC commands, the network, files, or timers; - it only draws by describing typed nodes (text, box, image, group), each of whose properties is bounded and validated in Rust; no HTML, no raw CSS, no script. The overlay translates each property into a specific CSS property and displays the text as text;
- it only reads files from its own folder (fonts and images, served by the game after verification) and its own storage;
- it only draws in the native rendering (
stage.*) if it declares it (permissions: ["stage"]) and the player has not revoked it, using typed and bounded elements, with no shaders; - it never accesses the network itself:
downloads.registeronly declares data (hosts, URL templates, JSON paths), requirespermissions: ["network"], which the player can revoke, and it is the game that makes the requests, only to the hosts shown to the player (Downloads); - a package is verified in full before being extracted (Package rules).
Budgets
- Each mod has its own engine: 16 MiB of QuickJS memory, 1 MiB of stack, and
30 ms of execution per load,
setup, event, or tick. - A mod is disabled after 3 consecutive failures (uncaught exception or budget overrun) or 3 budget overruns in total: its engine is released, its HUD removed, its storage written, and the reason reported, until the next host startup.
- Invalid declaration, transpilation error, exception on load or in
setup: the mod is not loaded and the error is reported; the other mods load normally. - Log lines are truncated at 1024 characters; the message queue holds 256 slots, beyond which messages are dropped and counted.
Threads
- All mod code runs on a single
modsthread, pinned by default to the last logical core; never on the input, render, audio, or window threads. Pinning confines the thread to that core without reserving it: the budget is wall-clock time. - The budget is cooperative: QuickJS interrupts the JavaScript; host functions are short and do not block.
- On the game side, a send is a
try_sendinto a bounded queue: no gameplay thread ever waits on mods. The entities of a play session (song, chart, judgements, playfield) are built before the session starts and shared as anArc.
Game-side integration (Rust)
use modding::{GameEvent, JudgementCheck, ModHost, ModHostOptions, ModInstaller, SceneDiff, SceneListener};
let mut options = ModHostOptions::new(data.join("mods"));
options.cache_dir = Some(data.join("mod-cache"));
options.storage_dir = Some(data.join("mod-storage"));
// Called on the mods thread after each publication: wakes the main thread.
options.on_scene = Some(SceneListener(Arc::new(move || wake_main_thread())));
// Selected skin (skin::Package::DEFAULT by default): it occupies the lowest layer.
options.skin = skin_package;
// Mods the player has excluded from native rendering (`stageDenied` setting).
options.stage_denied = settings.stage_denied.clone();
// Game validation applied to every resolved combination (the desktop passes the
// judge's `validate_set` here): a rejected combination is unavailable.
options.judgement_check = Some(JudgementCheck(Arc::new(|resolved| check(resolved))));
let host = Arc::new(ModHost::start(options)?);
// Starting a map, on a helper thread (never the render thread): the skin
// is reloaded, its setup(play) places the playfield and builds its HUD, then the
// map's script if it has one (see docs/map-scripts.md).
// The changes the skin makes during the game (`playfield.update`,
// `lanes.set` in its handlers) reach the renderer through `patches`, those of the
// map script through its own queue: one patch per transition and per step of the
// mods thread, never blocking.
let (patches, receiver) = skin::patch_channel();
let (chart_patches, chart_receiver) = skin::patch_channel();
let chart = modding::chart_script(&chart_file) // <chart>.script.ts, otherwise script.ts
.map(|script| modding::ChartLaunch { script, patches: chart_patches });
let pending = host.prepare_play(play_context, patches, chart); // PlayContext { mode, layout, layout_skin, columns, … }
let run = pending.wait(Duration::from_millis(1000)); // PlayRun { skin: SkinRun, chart: Option<ChartRun> }
// Skin failure: the first available skin replaces it (otherwise the engine's
// default values) and `run.skin.error` says why;
// script failure: `run.chart.error`, the game is played with the skin alone;
// timeout exceeded: `PlayfieldSpec::default()` without a script. `run.skin.images`:
// images to decode for the game.
// Game threads: never blocking, no allocation. `send_tracked` returns a
// ticket: `wait_handled(ticket, timeout)` waits (bounded) until the mods have
// processed the event and published the scene it produces, in order to send a
// judgement and the HUD change it causes together.
let ticket = host.send_tracked(GameEvent::Judgement(judgement));
// Render thread, on every frame: the native scene (lock-free read) and the
// judgements the frame shows; stage::StageRenderer evaluates animations, particles
// and triggers then emits quads into the playfield's instance batch.
// `host.deny_stage(ids)` removes native rendering from the mods chosen by the player.
let stages = host.stage(); // StageScene { revision, stages, images }
// Bridge to the overlay, at most 240 times per second: a diff groups all
// the changes since the displayed scene.
let scene = host.scene();
if scene.revision != shown.revision {
let diff = SceneDiff::between(&shown, &scene); // SceneDiff::full(&scene) on (re)load
overlay.send(serde_json::to_string(&diff)?);
shown = scene;
}
// Overlay files (fonts, images): checked on every request.
let asset = host.assets().read(mod_id, path)?; // bytes + mime
// Judgement sets and the tables of their parameter combinations, read without
// a lock (republished under a new `revision` when a mod that provides some is
// disabled or when a combination resolved on first use is kept).
// `JudgementSets::resolved`: `Some(Ok)` resolved, `Some(Err)` unavailable or unknown
// set, `None` not yet resolved (set too large): `resolve_judgement` then
// resolves it once on the mods thread and keeps it.
let sets = host.judgement_sets(); // pvng.osu/osu-mania, pvng.etterna/etterna, then those of the mods
let modifiers = host.gameplay_modifiers(); // pvng.auto/auto, then those of the mods (`gameplay.register`)
let resolved = match sets.resolved("pvng.osu/osu-mania", ¶ms) {
Some(resolved) => resolved,
None => host.resolve_judgement("pvng.osu/osu-mania", params).wait(Duration::from_secs(3)),
}?;
// Scroll speed systems and the grids of their values converted once
// (republished when a mod that provides some is disabled); the renderer receives
// only the time.
let systems = host.scroll_speed_systems(); // independent pvng.scroll-* providers and the player's mods
let scroll_ms = systems.get("pvng.scroll-etterna/c-mod").and_then(|cmod| cmod.scroll_time_ms(700.0)); // ≈ 514.286
// Free value: non-blocking request; read its exact conversion after republication.
host.resolve_scroll_speed("pvng.scroll-osu/osu-mania", 57.35);
let status = host.status();
for message in host.drain_messages() { /* display */ }
let installed = ModInstaller::new(data.join("mods")).install_package(&file)?;
The game provides:
| Event | When |
|---|---|
GameEvent::SongStart(SongStart { song, chart, judgements, playfield, time_us }) |
when a game starts (entities in Arc) |
GameEvent::Judgement(Judgement { column, time_us, offset_us, tier, combo, counts, accuracy }) |
each judgement, after the game lock is released; counts is an allocation-free TierCounts |
GameEvent::Pause / Resume(SongTime) |
pause, resume |
GameEvent::SongEnd(SongEnd { counts, max_combo, accuracy, aborted }) |
end of the game |
GameEvent::Playfield(Arc<Playfield>) |
resize during a game |
GameEvent::Settings(Arc<ModSettings>) |
settings applied, host restart |
The desktop application starts the host (apps/desktop/src/app/mods.rs), builds
these entities from game_core (apps/desktop/src/mod_events.rs) and passes to
the interface, at most every 250 ms, {type:"mods", dir, mods:[{id, name, version, author, description, homepage, permissions, state, reason}]},
{type:"judgementSets", sets}, {type:"gameplayMods", revision, mods:[{key, modId, name, description, kind}]} and {type:"modMessages", messages:[{modId, level, text}]}. Nothing is sent before mod loading has finished; the list of mods and
the sets are then sent when the host publishes new ones (mod disabled,
mods reloaded after an install, an uninstall or "Reload") and
on each ready from the interface. Package
operations respond with modInstalled ({id, name, version, change, from}, change: new, upgrade, reinstall, downgrade, replace),
modExported, modUninstalled or modError.
During a game, the WebView overlay (the overlay.html page, see
architecture) receives the scene diffs ({type:"sceneDiff", …}), the skin's layer below (z 0) then those of the
mods, and loads the skin's and mods' fonts and images through the
/mods/<id>/<path> route of the prism protocol, served by ModAssets::read
(including the skin folder). Native rendering draws the background, the playfield and the stage.* scenes
underneath.
prism.exe --bench-gameplay <s> loads the mods in the folder like a real play session
and displays the active mods and the texts of the final scene.
Scene diff format
SceneDiff serializes to JSON (serde_json):
{
"from": 41,
"to": 42,
"layers": [
{
"modId": "score-counter",
"z": 3,
"remove": ["combo"],
"upsert": [
{ "id": "panel", "kind": "group", "flow": { "direction": "column", "align": "end" },
"visible": true, "layout": { "x": 0.985, "y": 0.06, "anchor": "topRight" }, "style": {} },
{ "id": "score", "parent": "panel", "kind": "text", "text": "0375000",
"visible": true, "layout": {}, "style": { "size": 0.055, "color": "#ffffffff" } }
]
},
{
"modId": "default",
"z": 0,
"upsert": [
{ "id": "combo-value", "parent": "combo", "kind": "text", "text": "0",
"bind": { "kind": "combo" }, "visible": true, "layout": {},
"style": { "size": 0.057, "weight": 600, "color": "#edf0f6ff" } }
]
},
{ "modId": "lane-hints", "z": 2, "clear": true }
]
}
from: the revision the diff applies to;to: the resulting revision.reset: true(withfrom: 0): start from scratch (SceneDiff::full).- One layer per skin or mod (
modId), stacked byz(higher on top; the skin always hasz0, and mods follow in their load order);clear: the layer has lost all its nodes (mod disabled, etc.). remove: removed nodes, only the top of each removed subtree; an unknown id is ignored.upsert: created or modified nodes, parents before children. A known id is updated in place; an unknown id is created and appended to the end of its parent (the layer, if there is noparent). A node recreated elsewhere in the order is removed first.- Node:
id,parent?,kind(text+text+bind?,box+fill?,image+src+fit,group+flow?),visible,showWhen?,animate?,element?,layout,style; absent fields take the default values described above; bindings are resolved by the page (Bindings).srcis a path within the package of the layer's mod, to be requested from the game (ModAssets::read(modId, src)).
Applying: reset → clear; then for each layer clear, then remove,
then upsert in order. Diffs are computed between two immutable snapshots
by pointer comparison (Arc per layer and per node): the cost
scales with the size of the modified layers, never with the number of frames.
Examples
examples/mods/score-counter/: score (out of 1,000,000, each note being worth the weight of its tier; based on accuracy for Wife3), accuracy, combo and number of judgements per tier in the colors of the judgement configuration, on the right below the skin's combo.examples/mods/judgement-colors/: registers the "Spectrum 15" game (15 tiers, no parameters) and displays at the bottom a bar split between the tiers of the play session, each segment as wide as its share of the judgements.examples/mods/lane-hints/: under each receptor, an indicator that lights up as the lane's next note approaches, read page by page withgame.notesand positioned withgame.playfield().mods/osu/,mods/etterna/: the mods for the osu!mania and Etterna games (see Judgement games);mods/judgement-display/: last judgement and per-tier counters,accuracy/,combo/,counters/,progress-bar/,fps/,pause-status/: the rest of the default HUD (HUD mods), all as configurable elements.examples/mods/hit-bursts/: native rendering, a burst of sparks in the tier's color at the receptor of each hit note and a glow on the column while a hold note is held, declared once per play session and played by the render engine.
Generated reference
Declarations from mods/sdk/modding.d.ts, generated from the Rust contracts:
<!-- sdk:declarations:start -->
// Modding API version 2. Generated from crates/modding; do not edit.
// Regenerate with `cargo run -p modding --example write_sdk`.
type ModActionEvent = { id: string; pressed: boolean; timeUs: number; };
type ModActionDeclaration = { id: string; name: string; defaultKey: string; };
declare namespace controls {
export function register(input: ModActionDeclaration): void;
}
type Permission = "stage" | "skinImport" | "network";
type ChartManifest = { apiVersion: number; permissions?: Permission[]; images?: string[]; };
type BpmRange = { min: number; max: number; main: number; };
type TimingPoint = { timeUs: number; bpm: number; beatUs: number; meter: number; };
type Song = { title: string; artist: string; creator: string; difficulty: string; mode: string; layout: string; keys: number; durationUs: number; noteCount: number; ratings: Record<string, number>; holdCount: number; bpm?: BpmRange | null; timing: TimingPoint[]; };
type TierInfo = { index: number; id: string; name: string; color: string; gradient?: string[] | null; earlyMs?: number | null; lateMs?: number | null; weight?: number | null; breaksCombo: boolean; };
type JudgementConfig = { preset: string; tiers: TierInfo[]; };
type PlayContext = { mode: string; layout: string; layoutSkin: string; columns: number; screenWidth: number; screenHeight: number; song: Song; judgements: JudgementConfig; };
type ChartSetup = (play: PlayContext) => void | Promise<void>;
type ChartDefinition = { apiVersion: number; permissions?: Permission[]; images?: string[]; setup?: ChartSetup; };
declare function defineChart(input: ChartDefinition): ChartManifest;
type DependencySpec = { version: string; required?: boolean; feature?: string | null; };
type Dependency = string | DependencySpec;
type OptionKind = "number" | "integer" | "boolean" | "string" | "color" | "enum" | "colors";
type ElementOption = { type: OptionKind; min?: number | null; max?: number | null; values?: string[]; maxLength?: number | null; default?: unknown | null; label?: string | null; player?: boolean; };
type ElementDeclaration = { root?: string | null; options?: Record<string, ElementOption>; };
type Provides = { elements?: Record<string, ElementDeclaration>; };
type ModManifest = { id: string; name: string; version: string; apiVersion: number; author?: string | null; description?: string | null; homepage?: string | null; fonts?: Record<string, string>; uses?: Record<string, Dependency>; provides?: Provides; permissions?: Permission[]; images?: string[]; loadOrder?: number; };
type ModSetup = (mod: ModManifest) => void | Promise<void>;
type ModDefinition = { id: string; name: string; version: string; apiVersion: number; author?: string | null; description?: string | null; homepage?: string | null; fonts?: Record<string, string>; uses?: Record<string, Dependency>; provides?: Provides; permissions?: Permission[]; images?: string[]; loadOrder?: number; setup?: ModSetup; };
declare function defineMod(input: ModDefinition): ModManifest;
type SkinManifest = { id: string; name: string; version: string; apiVersion: number; author?: string | null; description?: string | null; homepage?: string | null; fonts?: Record<string, string>; uses?: Record<string, Dependency>; provides?: Provides; permissions?: Permission[]; images?: string[]; };
type SkinSetup = (play: PlayContext) => void | Promise<void>;
type SkinDefinition = { id: string; name: string; version: string; apiVersion: number; author?: string | null; description?: string | null; homepage?: string | null; fonts?: Record<string, string>; uses?: Record<string, Dependency>; provides?: Provides; permissions?: Permission[]; images?: string[]; setup?: SkinSetup; };
declare function defineSkin(input: SkinDefinition): SkinManifest;
type DownloadKind = "mirror" | "source";
type DownloadTarget = "osu";
type AuthKind = "none" | "token";
type AuthScope = "download" | "all";
type DownloadAuth = { kind: AuthKind; header?: string | null; scheme?: string | null; scope?: AuthScope | null; };
type PagingKind = "offset" | "page" | "cursor";
type PagingSpec = { kind: PagingKind; size: number; first?: number | null; };
type ResponseFormat = "osu" | "mapped";
type OnlySpec = { path: string; equals: string; };
type DifficultySpec = { path: string; name?: string | null; keys: string; stars?: string | null; length?: string | null; only?: OnlySpec | null; };
type ItemSpec = { id: string; title: string; artist?: string | null; creator?: string | null; status?: string | null; bpm?: string | null; playCount?: string | null; favourites?: string | null; cover?: string | null; difficulties?: DifficultySpec | null; };
type ResponseSpec = { format: ResponseFormat; results?: string | null; total?: string | null; nextCursor?: string | null; item?: ItemSpec | null; };
type SearchSpec = { url: string; params?: Record<string, string>; sorts?: Record<string, string>; statuses?: Record<string, string>; paging?: PagingSpec | null; response: ResponseSpec; };
type DownloadSpec = { url?: string | null; urlField?: string | null; };
type DownloadDeclaration = { id: string; name: string; description?: string | null; site?: string | null; kind: DownloadKind; target?: DownloadTarget | null; hosts: string[]; rateLimit?: number | null; auth?: DownloadAuth | null; search?: SearchSpec | null; download: DownloadSpec; };
declare namespace downloads {
export function register(input: DownloadDeclaration): void;
}
type BridgeDeclaration = { id: string; name: string; description?: string | null; site?: string | null; hosts: string[]; rateLimit?: number | null; auth?: DownloadAuth | null; };
type BridgeMethod = "GET" | "POST";
type BridgeExpect = "json" | "text" | "xml" | "html";
type BridgeRequest = { url: string; method?: BridgeMethod | null; headers?: Record<string, string> | null; body?: string | null; form?: Record<string, string> | null; json?: unknown | null; expect?: BridgeExpect | null; };
type ResultDetail = { label: string; value: string; };
type ResultAction = { id: string; label: string; };
type BridgeResult = { id: unknown; title: string; artist: string; creator?: string | null; coverUrl?: string | null; tags?: string[] | null; size?: string | null; keyCount?: number | null; details?: ResultDetail[] | null; actions?: ResultAction[] | null; data?: string | null; };
type BridgeFormat = "zip" | "osz" | "qp";
type BridgeDownload = { url: string; method?: BridgeMethod | null; headers?: Record<string, string> | null; body?: string | null; form?: Record<string, string> | null; json?: unknown | null; filename?: string | null; format: BridgeFormat; };
type BridgeStep = { request?: BridgeRequest | null; results?: BridgeResult[] | null; download?: BridgeDownload | null; error?: string | null; nextPage?: unknown | null; state?: unknown | null; };
type BridgeResponse = { status: number; headers: Record<string, string>; text: string; };
type BridgeActionResult = { id: string; title: string; artist: string; data?: string | null; };
type BridgeSearch = (query: string, page: unknown, state: unknown) => BridgeStep;
type BridgeOnResponse = (response: BridgeResponse, state: unknown) => BridgeStep;
type BridgeAction = (result: BridgeActionResult, actionId: string, state: unknown) => BridgeStep;
type BridgeDefinition = { id: string; name: string; description?: string | null; site?: string | null; hosts: string[]; rateLimit?: number | null; auth?: DownloadAuth | null; search: BridgeSearch; onResponse?: BridgeOnResponse; action?: BridgeAction; };
declare namespace downloads {
export function registerBridge(input: BridgeDefinition): void;
}
type ElementConfigure = { element: string; from: string; options: unknown; };
type Beat = { index: number; timeUs: number; bpm: number; meterBeat: number; };
type HitQuery = { limit?: number | null; };
type Hit = { offsetMs: number; tier: number; };
type HitList = Hit[];
declare namespace game {
export function hits(input: HitQuery): HitList;
}
type TierRef = { index: number; id: string; name: string; color: string; };
type JudgementEvent = { column: number; timeUs: number; offsetUs: number; tier: TierRef; combo: number; counts: number[]; accuracy: number; };
declare namespace game {
export function judgements(input: void): JudgementConfig | null;
}
type NoteQuery = { fromUs: number; toUs: number; column?: number | null; cursor?: number | null; limit?: number | null; };
type Note = { index: number; column: number; timeUs: number; endUs?: number | null; };
type NotePage = { notes: Note[]; next?: number | null; };
declare namespace game {
export function notes(input: NoteQuery): NotePage;
}
type SongTime = { timeUs: number; };
type PlayerState = { playing: boolean; paused: boolean; timeUs: number; combo: number; maxCombo: number; counts: number[]; judged: number; accuracy: number; };
declare namespace game {
export function player(input: void): PlayerState;
}
declare namespace game {
export function playfield(input: void): Playfield | null;
}
type Lane = { column: number; x: number; width: number; };
type Playfield = { keys: number; lanes: Lane[]; hitY: number; spawnY: number; scrollTimeUs: number; };
declare namespace game {
export function settings(input: void): ModSettings | null;
}
type ModSettings = { volume: number; showFps: boolean; scrollTimeMs: number; audioOffsetMs: number; ratingSystem: string; };
declare namespace game {
export function song(input: void): Song | null;
}
type SongEndEvent = { counts: number[]; maxCombo: number; accuracy: number; aborted: boolean; };
type SongStartEvent = { song: Song; judgements: JudgementConfig; timeUs: number; };
type TickEvent = { timeUs: number; hitCount: number; };
type GameplayModifierKind = "auto" | "ghost" | "mirror" | "random" | "noLn" | "fullLn";
type GameplayModifierDeclaration = { id: string; name: string; description: string; kind: GameplayModifierKind; group?: string | null; icon?: string | null; conflictsWith?: string[]; };
declare namespace gameplay {
export function register(input: GameplayModifierDeclaration): void;
}
type Fill = { kind: "songProgress"; } | { kind: "accuracy"; } | { kind: "tierShare"; tier: number; };
type Anchor = "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight";
type Layout = { x?: number | null; y?: number | null; width?: number | null; height?: number | null; anchor?: Anchor | null; };
type GradientStop = { color: string; at: number; };
type Gradient = { angle: number; stops: GradientStop[]; };
type Border = { width: number; color: string; };
type TextAlign = "start" | "center" | "end";
type Shadow = { x: number; y: number; blur: number; color: string; };
type Transform = { x?: number | null; y?: number | null; scale?: number | null; rotate?: number | null; };
type Style = { color?: string | null; background?: string | null; gradient?: Gradient | null; border?: Border | null; radius?: number | null; padding?: number | null; opacity?: number | null; font?: string | null; size?: number | null; weight?: number | null; italic?: boolean | null; align?: TextAlign | null; shadow?: Shadow | null; transform?: Transform | null; transitionMs?: number | null; };
type ShowWhen = "paused" | "running" | "showFps" | "judged" | "ghost";
type AnimateOn = "judgement" | "miss" | "hit";
type AnimateKind = "pop" | "popFade" | "flash";
type Animate = { on: AnimateOn; kind: AnimateKind; durationMs: number; };
type HudElement = "fps" | "accuracy" | "hits" | "misses" | "combo" | "timer" | "remaining" | "status" | "judgement" | "judgementCounts";
type BoxNode = { id: string; parent?: string | null; fill?: Fill | null; visible?: boolean | null; layout?: Layout | null; style?: Style | null; showWhen?: ShowWhen | null; animate?: Animate | null; element?: HudElement | null; };
declare namespace hud {
export function box(input: BoxNode): string;
}
declare namespace hud {
export function clear(input: void): void;
}
type FlowDirection = "row" | "column";
type FlowAlign = "start" | "center" | "end" | "stretch";
type FlowJustify = "start" | "center" | "end" | "spaceBetween";
type Flow = { direction: FlowDirection; gap?: number | null; align?: FlowAlign | null; justify?: FlowJustify | null; };
type GroupNode = { id: string; parent?: string | null; flow?: Flow | null; visible?: boolean | null; layout?: Layout | null; style?: Style | null; showWhen?: ShowWhen | null; animate?: Animate | null; element?: HudElement | null; };
declare namespace hud {
export function group(input: GroupNode): string;
}
type ImageFit = "contain" | "cover" | "fill";
type ImageNode = { id: string; parent?: string | null; src: string; fit?: ImageFit | null; visible?: boolean | null; layout?: Layout | null; style?: Style | null; showWhen?: ShowWhen | null; animate?: Animate | null; element?: HudElement | null; };
declare namespace hud {
export function image(input: ImageNode): string;
}
declare namespace hud {
export function remove(input: string): void;
}
type HudLabel = "hits" | "misses" | "combo" | "accuracy" | "paused" | "remaining" | "fps" | "early" | "late" | "ghost" | "difference";
type HudAction = "skipIntro";
type TextBinding = { kind: "combo"; } | { kind: "maxCombo"; } | { kind: "hits"; } | { kind: "misses"; } | { kind: "judged"; } | { kind: "remaining"; } | { kind: "accuracy"; decimals?: number | null; } | { kind: "ghostAccuracy"; decimals?: number | null; } | { kind: "ghostDelta"; decimals?: number | null; } | { kind: "ghostCombo"; } | { kind: "ghostName"; } | { kind: "tierCount"; tier: number; } | { kind: "tierName"; tier: number; } | { kind: "lastJudgement"; colors?: Record<string, string>; } | { kind: "elapsed"; } | { kind: "total"; } | { kind: "timer"; } | { kind: "fps"; } | { kind: "label"; label: HudLabel; } | { kind: "action"; action: HudAction; };
type TextNode = { id: string; parent?: string | null; text: string; bind?: TextBinding | null; visible?: boolean | null; layout?: Layout | null; style?: Style | null; showWhen?: ShowWhen | null; animate?: Animate | null; element?: HudElement | null; };
declare namespace hud {
export function text(input: TextNode): string;
}
type NodePatch = { id: string; text?: string | null; bind?: TextBinding | null; fill?: Fill | null; src?: string | null; fit?: ImageFit | null; flow?: Flow | null; visible?: boolean | null; layout?: Layout | null; style?: Style | null; showWhen?: ShowWhen | null; animate?: Animate | null; element?: HudElement | null; };
declare namespace hud {
export function update(input: NodePatch): void;
}
type JudgementParam = { label: string; min: number; max: number; step: number; default: number; short?: string | null; };
type SetAccuracy = "osuScoreV1" | "wife3" | "weights" | "continuous";
type SetHolds = "osuCombined" | "etterna" | "head" | "separate";
type SetDeclaration = { id: string; name: string; params?: Record<string, JudgementParam>; accuracy: SetAccuracy; holds: SetHolds; };
type JudgementTier = { id: string; name: string; color: string; gradient?: string[] | null; windowMs?: number | null; earlyMs?: number | null; lateMs?: number | null; weight?: number | null; breaksCombo?: boolean; };
type JudgementTiers = (params: Record<string, number>) => JudgementTier[];
type JudgementSetDefinition = { id: string; name: string; params?: Record<string, JudgementParam>; accuracy: SetAccuracy; holds: SetHolds; tiers: JudgementTiers; };
declare namespace judgements {
export function register(input: JudgementSetDefinition): void;
}
type SpriteSize = { width: number; height: number; };
type HoldMissedStyle = "hide" | "tint";
type LaneSpec = { note?: string | null; receptor?: string | null; receptorPressed?: string | null; holdBody?: string | null; holdEnd?: string | null; stageLight?: string | null; stageLightColor?: string | null; stageLightSize?: SpriteSize | null; stageLightOffsetY?: number | null; stageLightOn?: boolean | null; stageLightOpacity?: number | null; stageLightScale?: number | null; stageLightFadeMs?: number | null; keyLightOn?: boolean | null; keyLightOpacity?: number | null; keyLightScale?: number | null; keyLightFadeMs?: number | null; noteOffsetY?: number | null; holdEndOffset?: number | null; holdMatchNoteWidth?: boolean | null; holdWidthScale?: number | null; holdEndSize?: SpriteSize | null; holdEndScale?: number | null; holdEndFlip?: boolean | null; holdMissedStyle?: HoldMissedStyle | null; holdMissedColor?: string | null; holdMissedOpacity?: number | null; laneImage?: string | null; laneColor?: string | null; noteColor?: string | null; receptorColor?: string | null; pressedColor?: string | null; holdBodyColor?: string | null; holdEndColor?: string | null; offsetX?: number | null; offsetY?: number | null; width?: number | null; noteSize?: SpriteSize | null; receptorSize?: SpriteSize | null; };
type Easing = "linear" | "easeIn" | "easeOut" | "easeInOut";
type LaneUpdate = { column: number; lane: LaneSpec; transitionMs?: number | null; easing?: Easing | null; };
declare namespace lanes {
export function set(input: LaneUpdate): void;
}
type LeaderboardBestQuery = { chartId: number; };
declare namespace leaderboard {
export function best(input: LeaderboardBestQuery): number | null;
}
type LeaderboardQuery = { chartId: number; limit?: number | null; offset?: number | null; };
declare namespace leaderboard {
export function query(input: LeaderboardQuery): number | null;
}
type TierCount = { name: string; count: number; };
type LeaderboardEntry = { rank: number; replayId: string; playerName?: string | null; received: boolean; accuracy: number; performance?: number | null; performanceNonstandard: boolean; maxCombo: number; misses: number; tiers: TierCount[]; rate: number; modified: boolean; playedAtMs: number; };
type LeaderboardPerformance = { calculator: string; unit: string; };
type LeaderboardResult = { requestId: number; chartId: number; offset: number; total: number; entries: LeaderboardEntry[]; judgement: string; performance?: LeaderboardPerformance | null; unavailableReplays: number; error?: string | null; };
type ChartAdded = { chartId: number; };
type ChartsAdded = { chartIds: number[]; truncated: boolean; };
type LibraryFilterDeclaration = { id: string; name: string; table: string; column: string; version: number; unit?: string; };
declare namespace library {
namespace filters {
export function register(input: LibraryFilterDeclaration): void;
}
}
type LibraryReady = {};
declare namespace log {
export function info(input: string): void;
}
declare namespace log {
export function warn(input: string): void;
}
type MetronCalculator = { id: string; performance: boolean; version: number; };
type MetronCatalog = { calculators: MetronCalculator[]; };
declare namespace metron {
export function catalog(input: void): MetronCatalog;
}
type CalculatorRequest = { calculator: string; };
declare namespace metron {
export function difficulty(input: CalculatorRequest): number | null;
}
type PerformanceRequest = { calculator: string; accuracy: number; };
declare namespace metron {
export function performance(input: PerformanceRequest): number | null;
}
type PerformanceResult = { requestId: number; calculator: string; value?: number | null; unit: string; error?: string | null; };
type PlayfieldAnchor = "topLeft" | "top" | "topRight" | "left" | "center" | "right" | "bottomLeft" | "bottom" | "bottomRight";
type ScrollDirection = "down" | "up";
type HitLightOn = "all" | "off";
type TextureFilter = "linear" | "nearest";
type MeasureLineLength = "playfield" | "lanes";
type JudgementLineAt = "center" | "top" | "bottom";
type PlayfieldSpec = { x?: number | null; y?: number | null; anchor?: PlayfieldAnchor | null; rotation?: number | null; zoom?: number | null; laneWidth?: number | null; width?: number | null; laneGap?: number | null; laneHeight?: number | null; receptorY?: number | null; scroll?: ScrollDirection | null; noteSize?: SpriteSize | null; receptorSize?: SpriteSize | null; holdWidth?: number | null; holdEndSize?: SpriteSize | null; holdEndOffset?: number | null; holdMatchNoteWidth?: boolean | null; holdWidthScale?: number | null; holdEndScale?: number | null; holdEndFlip?: boolean | null; noteOffsetY?: number | null; stageLightSize?: SpriteSize | null; stageLightOffsetY?: number | null; hitLight?: string | null; hitLightOn?: HitLightOn | null; hitLightFollowJudgement?: boolean | null; stageLightOn?: boolean | null; stageLightOpacity?: number | null; stageLightScale?: number | null; stageLightFadeMs?: number | null; keyLightOn?: boolean | null; keyLightOpacity?: number | null; keyLightScale?: number | null; keyLightFadeMs?: number | null; hitLightOpacity?: number | null; hitLightScale?: number | null; holdLightOpacity?: number | null; holdLightScale?: number | null; hitLightFrames?: number | null; hitLightFps?: number | null; hitLightSize?: SpriteSize | null; hitLightOffsetY?: number | null; hitLightColor?: string | null; holdLight?: string | null; holdLightFrames?: number | null; holdLightFps?: number | null; holdLightSize?: SpriteSize | null; holdLightOffsetY?: number | null; holdLightColor?: string | null; textureFilter?: TextureFilter | null; holdMissedStyle?: HoldMissedStyle | null; holdMissedColor?: string | null; holdMissedOpacity?: number | null; laneCover?: boolean | null; laneCoverColor?: string | null; laneCoverOpacity?: number | null; laneCoverSize?: number | null; laneCoverFeather?: number | null; measureLines?: boolean | null; measureLineColor?: string | null; measureLineOpacity?: number | null; measureLineThickness?: number | null; measureLineLength?: MeasureLineLength | null; measureLineOvershoot?: number | null; measureLineEvery?: number | null; beatLines?: boolean | null; beatLineColor?: string | null; beatLineOpacity?: number | null; beatLineThickness?: number | null; borderWidth?: number | null; borderColor?: string | null; judgementLine?: boolean | null; judgementLineColor?: string | null; judgementLineThickness?: number | null; judgementLineAt?: JudgementLineAt | null; backgroundColor?: string | null; backgroundImage?: string | null; backgroundTint?: string | null; laneColor?: string | null; noteColor?: string | null; receptorColor?: string | null; pressedColor?: string | null; holdBodyColor?: string | null; holdEndColor?: string | null; note?: string | null; receptor?: string | null; receptorPressed?: string | null; holdBody?: string | null; holdEnd?: string | null; laneImage?: string | null; stageLight?: string | null; stageLightColor?: string | null; lanes?: LaneSpec[] | null; };
declare namespace playfield {
export function set(input: PlayfieldSpec): void;
}
type PlayfieldUpdate = { patch: PlayfieldSpec; transitionMs?: number | null; easing?: Easing | null; };
declare namespace playfield {
export function update(input: PlayfieldUpdate): void;
}
type BackfillRequest = { id: string; charts?: number[] | null; };
declare namespace ratings {
export function backfill(input: BackfillRequest): number | null;
}
type RatingProgress = { requestId: number; id: string; done: number; total: number; failed: number; finished: boolean; error?: string | null; ahead: number; };
type ChartMetric = "notes" | "holds" | "holdPercent" | "averageNps" | "peakNps" | "bpmMin" | "bpmMax" | "duration";
type RatingFieldSource = { kind: "column"; column: string; } | { kind: "chart"; metric: ChartMetric; };
type RatingField = { label: string; source: RatingFieldSource; unit?: string; decimals?: number; };
type RatingSeriesSource = "density" | "bpm";
type RatingSeries = { label: string; source: RatingSeriesSource; unit: string; };
type RatingPanel = { kind: "metrics"; title: string; fields: RatingField[]; } | { kind: "bars"; title: string; fields: RatingField[]; max?: number | null; } | { kind: "radar"; title: string; fields: RatingField[]; max?: number | null; } | { kind: "timeline"; title: string; series: RatingSeries[]; };
type RatingDeclaration = { id: string; name: string; calculator: string; unit: string; table: string; column: string; version: number; panels?: RatingPanel[]; };
declare namespace ratings {
export function register(input: RatingDeclaration): void;
}
type ScrollSpeedParam = { label: string; min: number; max: number; step: number; default: number; short?: string | null; };
type ScrollSpeedDeclaration = { id: string; name: string; param: ScrollSpeedParam; };
type ScrollSpeedContext = { travel: number; };
type ScrollSpeedToMs = (value: number, context: ScrollSpeedContext) => number;
type ScrollSpeedDefinition = { id: string; name: string; param: ScrollSpeedParam; toMs: ScrollSpeedToMs; };
declare namespace scrollSpeed {
export function register(input: ScrollSpeedDefinition): void;
}
type SkinImportAction = { id: string; };
declare namespace skinImport {
export function close(input: void): void;
}
type SkinImportCreate = { stageId: number; };
declare namespace skinImport {
export function create(input: SkinImportCreate): number | null;
}
type SkinImportCreated = { requestId: number; id?: string | null; error?: string | null; };
type SourceKind = "folder" | "archive";
type IniGeneral = { name?: string | null; author?: string | null; version?: string | null; };
type ManiaColumn = { noteImage?: string | null; noteImageH?: string | null; noteImageL?: string | null; noteImageT?: string | null; keyImage?: string | null; keyImageD?: string | null; colour?: string | null; colourLight?: string | null; };
type ManiaSection = { keys: number; columnStart?: number | null; columnWidth: number[]; columnSpacing: number[]; columnLineWidth: number[]; hitPosition?: number | null; lightPosition?: number | null; scorePosition?: number | null; comboPosition?: number | null; judgementLine?: boolean | null; upsideDown?: boolean | null; noteBodyStyle?: number | null; lightFramePerSecond?: number | null; barlineHeight?: number | null; colourColumnLine?: string | null; colourBarline?: string | null; colourJudgementLine?: string | null; colourHold?: string | null; stageLeft?: string | null; stageRight?: string | null; stageBottom?: string | null; stageHint?: string | null; stageLight?: string | null; lightingN?: string | null; lightingL?: string | null; columns: ManiaColumn[]; ignored: string[]; };
type SkinIni = { general: IniGeneral; mania: ManiaSection[]; fontsUsed: boolean; problems: string[]; };
type SourceImage = { name: string; frames: number; hasStill: boolean; scale: number; width: number; height: number; bytes: number; blank?: boolean | null; peakColor?: string | null; peakWidth?: number | null; };
type SkinSource = { sourceId: number; label: string; kind: SourceKind; ini?: SkinIni | null; images: SourceImage[]; truncated: boolean; totalBytes: number; problems: string[]; };
type SkinImportOpened = { importerId: string; locale: string; source?: SkinSource | null; error?: string | null; };
type PanelStatus = "working" | "ready" | "done" | "error";
type PanelRow = { label: string; value: string; };
type PanelSection = { heading: string; rows: PanelRow[]; };
type NoteLevel = "info" | "warning" | "error";
type PanelNote = { level: NoteLevel; text: string; };
type PanelAction = { id: string; label: string; primary?: boolean | null; editSkin?: string | null; };
type ImportPanelDefinition = { title: string; status: PanelStatus; detail?: string | null; sections?: PanelSection[]; notes?: PanelNote[]; actions?: PanelAction[]; };
declare namespace skinImport {
export function panel(input: ImportPanelDefinition): void;
}
type ImporterText = { name: string; description?: string | null; };
type ImporterDeclaration = { id: string; name: string; description?: string | null; localized?: Record<string, ImporterText>; sources: SourceKind[]; };
declare namespace skinImport {
export function register(input: ImporterDeclaration): void;
}
type SkinLayout = { keys: number; playfield?: PlayfieldSpec; lanes: LaneSpec[]; };
type SkinHud = { judgementY?: number | null; comboY?: number | null; };
type SkinDescription = { id: string; name: string; version?: string | null; author?: string | null; description?: string | null; playfield?: PlayfieldSpec; layouts: SkinLayout[]; hud?: SkinHud; };
type ImageBox = { maxWidth: number; maxHeight: number; };
type ImageSize = { width: number; height: number; };
type ImageOp = { source: string; dest: string; animation?: boolean; frame?: number | null; padTop?: number; padBottom?: number; fit?: ImageBox | null; stretch?: ImageSize | null; };
type StageRequest = { sourceId: number; skin: SkinDescription; images: ImageOp[]; };
declare namespace skinImport {
export function stage(input: StageRequest): number | null;
}
type StagedFile = { path: string; bytes: number; width: number; height: number; };
type Report = { files: StagedFile[]; totalBytes: number; budgetBytes: number; overBudget: boolean; problems: string[]; };
type SkinImportStaged = { requestId: number; stageId?: number | null; report?: Report | null; error?: string | null; };
declare namespace stage {
export function clear(input: void): void;
}
type StageLayer = "below" | "lanes" | "above";
type StageSpace = "screen" | "playfield" | "lane" | "receptor";
type StagePoint = { space?: StageSpace | null; column?: number | null; x?: number | null; y?: number | null; };
type StageEmitter = { id: string; layer?: StageLayer | null; at: StagePoint; image?: string | null; size: number; color?: string | null; endColor?: string | null; lifetimeMs: number; speed?: number | null; speedJitter?: number | null; direction?: number | null; spread?: number | null; gravity?: number | null; burst?: number | null; rate?: number | null; maxParticles: number; fade?: boolean | null; shrink?: boolean | null; };
declare namespace stage {
export function emitter(input: StageEmitter): string;
}
type StageCue = { target: string; animation: string; };
declare namespace stage {
export function play(input: StageCue): void;
}
type StageSize = { width: number; height: number; };
type StageProps = { x?: number | null; y?: number | null; scale?: number | null; rotation?: number | null; opacity?: number | null; color?: string | null; };
type StageAnimation = { durationMs: number; easing?: Easing | null; repeat?: boolean | null; from?: StageProps; to?: StageProps; };
type StageRect = { id: string; layer?: StageLayer | null; at: StagePoint; size: StageSize; color?: string | null; radius?: number | null; opacity?: number | null; rotation?: number | null; scale?: number | null; animations?: Record<string, StageAnimation> | null; };
declare namespace stage {
export function rect(input: StageRect): string;
}
declare namespace stage {
export function remove(input: string): boolean;
}
type StageSprite = { id: string; layer?: StageLayer | null; at: StagePoint; image: string; size: StageSize; color?: string | null; opacity?: number | null; rotation?: number | null; scale?: number | null; animations?: Record<string, StageAnimation> | null; };
declare namespace stage {
export function sprite(input: StageSprite): string;
}
declare namespace stage {
export function stop(input: StageCue): void;
}
type StageAlign = "start" | "center" | "end";
type StageText = { id: string; layer?: StageLayer | null; at: StagePoint; text: string; size: number; color?: string | null; align?: StageAlign | null; opacity?: number | null; rotation?: number | null; scale?: number | null; animations?: Record<string, StageAnimation> | null; };
declare namespace stage {
export function text(input: StageText): string;
}
type StageJudgementFilter = { tier?: number | null; column?: number | null; miss?: boolean | null; };
type StageColumn = { column?: number | null; };
type StageEvent = { judgement?: StageJudgementFilter | null; press?: StageColumn | null; release?: StageColumn | null; holdStart?: StageColumn | null; holdEnd?: StageColumn | null; };
type StageTrigger = { id: string; on: StageEvent; target: string; play?: string | null; stop?: string | null; };
declare namespace stage {
export function trigger(input: StageTrigger): string;
}
declare namespace storage {
export function clear(input: void): void;
}
declare namespace storage {
export function get(input: string): unknown | null;
}
declare namespace storage {
export function keys(input: void): string[];
}
declare namespace storage {
export function remove(input: string): void;
}
type StorageEntry = { key: string; value: unknown; };
declare namespace storage {
export function set(input: StorageEntry): void;
}
type ColumnKind = "number" | "text";
type ColumnDefinition = { name: string; kind: ColumnKind; indexed?: boolean; };
type TableDefinition = { id: string; columns: ColumnDefinition[]; };
declare namespace tables {
export function register(input: TableDefinition): void;
}
type ExtendableTab = "info" | "leaderboard" | "mods";
type TabSlot = "top" | "bottom";
type LeaderboardStat = "plays" | "bestPerformance" | "bestAccuracy";
type TabFieldSource = { kind: "column"; rating: string; column: string; } | { kind: "chart"; metric: ChartMetric; } | { kind: "leaderboard"; stat: LeaderboardStat; };
type TabField = { label: string; source: TabFieldSource; unit?: string; decimals?: number; };
type TabPanel = { kind: "metrics"; title: string; fields: TabField[]; } | { kind: "bars"; title: string; fields: TabField[]; max?: number | null; } | { kind: "radar"; title: string; fields: TabField[]; max?: number | null; } | { kind: "timeline"; title: string; series: RatingSeries[]; } | { kind: "leaderboard"; title: string; limit: number; } | { kind: "text"; title?: string | null; text: string; };
type TabExtensionDeclaration = { tab: ExtendableTab; slot: TabSlot; order?: number; panels: TabPanel[]; };
declare namespace tabs {
export function extend(input: TabExtensionDeclaration): void;
}
type TabDeclaration = { id: string; title: string; icon?: string | null; order?: number; panels: TabPanel[]; };
declare namespace tabs {
export function register(input: TabDeclaration): void;
}
type PopupActionEvent = { id: string; };
declare namespace ui {
export function dismiss(input: void): void;
}
type PopupAction = { id: string; label: string; };
type PopupDefinition = { title: string; detail: string; done: number; total: number; actions?: PopupAction[]; };
declare namespace ui {
export function popup(input: PopupDefinition): void;
}
type HostEvents = {
"controls.action": ModActionEvent;
"elements.configure": ElementConfigure;
"game.beat": Beat;
"game.judgement": JudgementEvent;
"game.pause": SongTime;
"game.playfieldChange": Playfield;
"game.resume": SongTime;
"game.settingsChange": ModSettings;
"game.songEnd": SongEndEvent;
"game.songStart": SongStartEvent;
"game.tick": TickEvent;
"leaderboard.result": LeaderboardResult;
"library.chartAdd": ChartAdded;
"library.chartsAdded": ChartsAdded;
"library.ready": LibraryReady;
"metron.performanceResult": PerformanceResult;
"ratings.progress": RatingProgress;
"skinImport.action": SkinImportAction;
"skinImport.created": SkinImportCreated;
"skinImport.opened": SkinImportOpened;
"skinImport.staged": SkinImportStaged;
"ui.action": PopupActionEvent;
};
declare const ctx: {
on<K extends keyof HostEvents>(event: K, handler: (payload: HostEvents[K]) => void | Promise<void>): void;
/** A package this one `uses` is present, compatible and running. */
has(id: string): boolean;
/** An element another package `provides`, named `"<package>/<element>"`: its options are checked against the provider's declaration and handed to the provider. */
element(id: string): { configure(options: Record<string, unknown>): void };
};
There's no Markdown fragment in your message after "Translate this Markdown fragment:" — it came through empty.
Please paste the French fragment you want translated, and I'll return the English version following the rules you've set. <!-- sdk:declarations:end -->