On this page

← All documentation

TypeScript mods: the detailed guide

The detailed guide to TypeScript mods: events, entities, HUD, stage, storage, .pvmod packages, security, budgets.

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

  1. Loading. The top-level code of the entry point (and of the imported modules) runs. Only defineMod and log.* 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 calling setup.
  2. Declaration. defineMod must 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).
  3. Checks. The declared fonts are checked on disk, and the mod's storage is opened.
  4. setup(mod). Called under the execution budget, after the setup of the packages the mod uses (Dependencies); an exception makes the mod fail. After that, the mod receives events and can use everything except defineMod.

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; index is their position.
  • limit defaults to 64, ranging from 1 to 256; column filters on a single column.
  • next is the cursor for the rest of the interval, null when the page completes it. A query never copies more than one page: the chart stays shared (Arc) between the game and the mod thread.
  • endUs is the end of a hold, null for 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's text stays 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 index tier); { kind: "lastJudgement", colors? } (name of the last judged tier, the node takes its color, or the one that colors gives 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 with visible.
  • 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's transform is not touched).
  • element (any node): fps, accuracy, hits, misses, combo, timer, remaining, status, judgement or judgementCounts; the font the player chose for this element (hudFonts setting) 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 a flow group;
  • layout.width, layout.height (within [0, 4]) and all style lengths: fractions of the screen height, to keep proportions at any aspect ratio. At 1920×1080, 0.05 equals 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), .ttf or .otf extension 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 per burst), 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 #rgb to #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, then id); 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>") is false, the function that depends on it is skipped, and the Mods page displays "feature "X" disabled: package Y missing" (feature names X; without it: "features using Y disabled").
  • required: true makes 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: number and integer (min, max), boolean, string (maxLength, 256 at most), color (#rgb, #rrggbb, #rrggbbaa), enum (values, 1 to 32), colors (colors by key, 64 at most). default is the value received when the user provides none. 16 elements and 32 options at most.
  • configure checks the options against the declaration (unknown option, type, bounds: exception in the calling script), fills in the default values, then the provider receives elements.configure ({ element, from, options }) after the calling script. Configuring an absent package does nothing; a package missing from uses throws 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.font accepts "<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 a label (64 characters at most) and player: true to be listed in Settings → Mods if the element has no place; all the options of an element that has numeric x and y (positions, sizes, visibility, colors, decimals…) are adjusted in the skin editor, player or 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 receives elements.configure with from: "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 setting elementOptions: only for the player: true options of an element with no place (no numeric x or y, 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_overrides replaces 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's setup: each parameter takes the values from min to max in steps of step (here width = 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 tiers throws 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 from tiers does 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::revision increases) 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. Under wife3, tiers have no weight; otherwise each tier, Miss included, has one. osuCombined requires exactly 5 hit tiers.
  • continuous uses 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.ts provides a complete example with no parameters.
  • separate produces 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; color remains 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, label of 1 to 32 characters, short of 1 to 8 characters, min < max, 0 < step ≤ max − min, default within [min, max]. An invalid declaration makes the mod's setup fail.

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 from density and bpm.

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? }): chartId is the library chart identifier (library.chartAdd, library.chartsAdded, mod tables), limit from 1 to 100 (20 by default), offset from 0 to 100,000. Out of bounds, wrong type, or unknown field: exception (validated in Rust). leaderboard.best({ chartId }) is query with limit: 1: rank 1 of the entire leaderboard, imported replays included (received).
  • The return value is a request number, or null when requests pile up (2 pending per mod, 16 across all mods) or when the host cannot accept them. The computation runs on a crates/library worker; the mod thread never waits for it.
  • leaderboard.result arrives only at the requesting mod: requestId, chartId, offset, total (leaderboard size), entries, judgement (name of the current judgement), performance ({ calculator, unit } or null), unavailableReplays, and error (English message, null if 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 (null for an old replay without a name: never attributed to the current player), received, accuracy (percent), performance (null if 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.register and tabs.extend are reserved for setup; 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) and text (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), order from 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 from min to max by step is 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 same toMs before it can be played. The provider publishes its latest custom result (custom: value, scrollMs or error); rapid requests are coalesced into the latest value. A launch reads only an exact result that has already been published, without running the mod.
  • context contains only travel, 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 f32 microseconds 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::revision increases). 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; label of 1 to 32 characters, short of 1 to 8, min < max, 0 < step ≤ max − min, default in [min, max], at most 4096 values. An invalid declaration makes the mod's setup fail.

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 }) (in setup, 4 per mod) adds a card to the Skins page; sources: folder and/or archive. 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). source contains the parsed skin.ini (SkinIni: [Mania] sections merged by Keys, indexed columns, #rrggbbaa colors, out-of-range values rejected) and the image inventory (SourceImage: normalized name without @2x or -N, dimensions, scale, animation frames, blank image, peak color), without any bytes. locale is en, fr or zh.
  • skinImport.stage({ sourceId, skin, images }) → request number: the host validates skin (a SkinDescription: shared PlayfieldSpec, one layout per key count with its LaneSpecs, HUD slots) with the skin rules, executes the ImageOps (source, dest, animation or frame, padTop/padBottom signed in source pixels, fit or stretch, PNG; an image without a transformation is copied as is) on the native skin-import thread, and responds with skinImport.staged: files, sizes, 900 KiB budget (overBudget, reported, not enforced).
  • skinImport.create({ stageId }) → skinImport.created { id }: the host generates a readable skin.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 with editSkin: "<id>" (a skin that this mod created) opens the skin editor; the others reach the mod through skinImport.action { id }. skinImport.close() removes the card; "Close" sends dismiss.

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 its defineMod declaration; finally the folder is put in place by renaming. A failed installation leaves the mods folder untouched;
  • replaces an installed mod with the same id regardless 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…) and node_modules are 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 mods thread, 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.register only declares data (hosts, URL templates, JSON paths), requires permissions: ["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 mods thread, 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_send into 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 an Arc.

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", &params) {
    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 (with from: 0): start from scratch (SceneDiff::full).
  • One layer per skin or mod (modId), stacked by z (higher on top; the skin always has z 0, 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 no parent). 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). src is 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 with game.notes and positioned with game.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 -->

Source in the game repository: docs/modding.md