Auteur de mod ? Commencez par le guide des auteurs (démarrage rapide, concepts, référence, recettes, contexte pour assistants IA, lacunes). Cette page reste la référence détaillée de l'hôte.
Un mod est un petit programme TypeScript que les joueurs s'échangent sous la forme
d'un seul fichier (.pvmod). Il lit l'état du jeu à la demande (chanson, notes du
chart, joueur, jugements…), reçoit les événements de la partie et construit un HUD
en nœuds typés que le jeu affiche dans la surcouche WebView au-dessus du playfield
natif. Avec la permission stage, il dessine aussi dans le rendu natif, à l'image
près avec les notes (stage.*). Il peut enregistrer des
jeux de jugement et exposer des
éléments configurables à d'autres paquets.
Les mods sont distincts des skins (skin.ts, qui placent le playfield
et construisent le HUD du jeu) et des scripts de map, qui
appartiennent à une map. Les trois partagent le même hôte et la même API.
La crate crates/modding fournit l'hôte, l'API, les paquets et le SDK ;
crates/stage valide et évalue le rendu natif des scripts. L'application desktop
démarre l'hôte, lui envoie les événements de jeu et affiche sa scène dans la
surcouche WebView pendant une partie.
Créer un mod
Dossier d'un mod
mods/
sdk/ déclarations générées ; pas de main.ts, donc pas un mod
modding.d.ts
modding.ts
osu/ etterna/ prism/ mods du jeu : jugements (dossiers ordinaires, comme tout mod)
scroll-*/ metron/ ... scrolls, ratings, HUD : un dossier par mod
score-counter/
main.ts
mon-mod/
main.ts point d'entrée (sinon index.ts)
lib/format.ts importé par main.ts
fonts/Title.ttf
images/star.png
Un dossier est un mod s'il contient main.ts (ou, à défaut, index.ts) et que
son nom ne commence pas par . ; un dossier sans main.ts (comme mods/sdk) est
ignoré en silence. Les mods du jeu ne sont pas compilés dans l'exécutable : ce
sont des dossiers ordinaires de mods/, chargés comme les vôtres. Racines,
dans cet ordre : mods\ à côté de prism.exe (en développement, le dossier
mods/ du dépôt, trouvé en remontant depuis l'exécutable jusqu'au dossier de
Cargo.lock), puis le dossier des mods du joueur. Ordre
des mods : les fournisseurs avant leurs dépendants (uses), sinon loadOrder
puis id ; cet ordre est aussi celui de la livraison des événements et de
l'empilement des calques HUD.
Le point d'entrée est chargé comme projet Rust-TS : imports relatifs, paths de
tsconfig.json et node_modules du dossier sont résolus, jamais hors du
dossier ; import() dynamique est refusé. Le chargement transpile sans vérifier
les types : vérifiez vos mods avec tsc (voir Typage et SDK).
Déclarer le 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: "Un gros score en haut à droite.",
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" } });
});
},
});
| Champ | Requis | Règle |
|---|---|---|
id |
oui | 1 à 64 caractères parmi a-z, 0-9, ., -, _, commençant et finissant par une lettre ou un chiffre, sans .. ni nom réservé Windows (score-counter, com.exemple.score) ; stable. Aucun préfixe n'est réservé ; si deux dossiers déclarent le même id, le premier chargé gagne et un message l'indique |
name |
oui | 1 à 64 caractères |
version |
oui | version sémantique (1.2.0, 2.0.0-beta.1), comparée lors d'un remplacement |
apiVersion |
oui | version de l'API pour laquelle le mod est écrit ; doit valoir celle du jeu (2) |
loadOrder |
non | entier de 0 à 10000 (défaut 1000) qui trie les mods sans dépendance entre eux par (loadOrder, id) ; les mods du jeu s'en servent pour garder l'ordre des sélecteurs |
author |
non | 1 à 64 caractères |
description |
non | 1 à 1024 caractères, retours à la ligne permis |
homepage |
non | adresse https:// ou http://, 256 caractères au plus |
fonts |
non | polices fournies : { nom: "chemin/relatif.ttf" }, voir Polices |
uses |
non | paquets utilisés : { "<id>": "^1.2" } ou { "<id>": { version, required, feature } }, voir Dépendances |
provides |
non | éléments que d'autres paquets configurent : { elements: { <nom>: { options } } }, voir Éléments |
permissions |
non | ["stage"] pour dessiner dans le rendu natif, voir stage.* ; ["network"] pour déclarer des miroirs et sources de téléchargement, voir downloads.register |
images |
non | PNG du dossier que ses éléments stage.sprite et stage.emitter affichent (64 au plus) |
setup |
non | fonction appelée une fois la déclaration validée, avec le manifeste validé |
Un champ inconnu fait échouer la déclaration. Un mod écrit pour une autre version
de l'API est refusé avec un message clair :
`x` requires modding API version 1; this game provides version 2.
Chargement et phases
- Chargement. Le code de premier niveau du point d'entrée (et des modules
importés) s'exécute. Seuls
defineModetlog.*sont disponibles ;ctx.on(...)peut déjà enregistrer des gestionnaires. Toute autre fonction hôte lève une exception (is not available while the mod loads). Le chargement n'a donc aucun effet : c'est aussi ainsi que l'installateur lit la déclaration d'un paquet, sans jamais appelersetup. - Déclaration.
defineModdoit être appelé exactement une fois ; une déclaration invalide, un id déjà pris ou un second appel font échouer le mod (même si l'exception est rattrapée). - Vérifications. Les polices déclarées sont vérifiées sur le disque, le stockage du mod est ouvert.
setup(mod). Appelée sous le budget d'exécution, après lessetupdes paquets que le mod utilise (Dépendances) ; une exception fait échouer le mod. Ensuite le mod reçoit les événements et peut tout utiliser saufdefineMod.
Les mods se chargent en arrière-plan au démarrage de l'hôte : le jeu ne les
attend pas. L'application crée son premier hôte après réception des réglages
persistés de l'interface. ModHostOptions.disabled_mods contient les ids
désactivés : leur déclaration est lue pour conserver leurs métadonnées, mais
ni leur setup ni leurs gestionnaires ne s'exécutent. Ils ne fournissent
aucun élément aux dépendances, aucun HUD, jugement, système de défilement
ou vue de difficulté.
Typage et SDK
Les fonctions sont des globales ; pour les typer, référencez les déclarations générées (un commentaire, sans effet à l'exécution) :
/// <reference path="../sdk/modding.d.ts" />
Ce chemin vaut pour un mod écrit dans mods/ du dépôt ; ailleurs, copiez
mods/sdk/modding.d.ts et adaptez le chemin. mods/sdk/modding.ts est un module
optionnel (raccourcis game.onJudgement(...), aides models) ; un mod qui
l'utilise le copie dans son propre dossier, puisqu'un paquet ne peut rien importer
hors de lui.
Les deux fichiers sont générés depuis les contrats Rust (tous les types exposés
dérivent TsSchema) et versionnés dans mods/sdk/, comme le bloc
Référence générée de ce document. Après un changement de
l'API :
cargo run -p modding --example write_sdk
Le test committed_sdk_matches_the_rust_contracts (cargo test -p modding --test sdk) échoue si les fichiers ou ce bloc ne correspondent plus aux contrats ;
examples_type_check vérifie avec le TypeScript de l'interface les mods de
mods/ et les skins de skins/. Pour un seul mod :
node apps/web/node_modules/typescript/bin/tsc --noEmit --strict --target es2022 --lib es2022 --module esnext --moduleResolution bundler mods/score-counter/main.ts
Exemple minimal
Un dossier mods/combo-corner/ (à créer) avec ce main.ts :
/// <reference path="../sdk/modding.d.ts" />
export default defineMod({
id: "combo-corner",
name: "Combo Corner",
version: "1.0.0",
apiVersion: 2,
setup() {
// Le combo en haut à droite, mis à jour par la surcouche à chaque jugement.
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}`));
},
});
Copiez le dossier dans le dossier des mods, puis cliquez
sur Recharger dans la page Mods : le mod apparaît dans la liste, ses messages
(log.info) sous elle. Pour le partager, exportez-le en
paquet .pvmod.
Événements
S'abonner avec ctx.on(nom, handler), au premier niveau ou dans setup.
| Événement | Contenu |
|---|---|
game.songStart |
song, judgements (configuration de la partie), timeUs (temps au départ, négatif pendant un pré-roll) ; le chart et le playfield deviennent lisibles |
game.judgement |
column, timeUs, offsetUs (entrée − note ; entrée − fin pour un relâchement ; 0 pour un miss), tier ({ index, id, name, color }), combo, counts, accuracy |
game.pause, game.resume |
timeUs |
game.tick |
timeUs, hitCount (compteur natif de frappes non-Miss, remis à zéro à chaque partie) |
game.beat |
index (temps depuis le premier point de tempo, à travers ses changements ; négatif avant lui), timeUs (temps du battement), bpm, meterBeat (place dans la mesure, 0 sur le premier temps ; chaque point de tempo commence une mesure) |
game.songEnd |
counts, maxCombo, accuracy, aborted |
game.playfieldChange |
Playfield |
game.settingsChange |
ModSettings |
elements.configure |
element, from, options : un autre paquet configure un élément du mod (Éléments configurables) |
controls.action |
id, pressed, timeUs : action déclarée par ce mod, touche physique enfoncée ou relâchée pendant la partie |
Les temps sont des temps de chanson en microsecondes. combo, counts et
accuracy d'un jugement sont les totaux du moteur de jugement après cette note ;
un mod peut se resynchroniser même s'il a manqué des événements. game.songEnd
porte le résultat recalculé depuis les entrées quand le chart se termine, les
totaux en direct quand le joueur quitte (aborted).
game.tick arrive 30 fois par seconde, uniquement pendant qu'un chart joue et
n'est pas en pause ; le temps est extrapolé depuis le dernier songStart ou
resume. Un tick en retard est sauté.
Le compteur est échantillonné avant le callback : un mod peut éviter une
nouvelle lecture de game.hits lorsqu'il est inchangé. Un impact arrivé
pendant la lecture reste détectable au tick suivant ; le compteur ne dépend
pas de la livraison des événements de jugement.
game.beat arrive à l'heure de chaque battement du chart (Song.timing), sans
attendre un tick : le thread des mods se réveille au temps du battement,
uniquement pendant que le chart joue et n'est pas en pause. Après une pause ou
un saut, il reprend au battement suivant ; en retard, il ne livre que le
dernier battement atteint, jamais une rafale. Un skin pulse ainsi sur la
musique en lançant une transition à chaque battement, sans travail par image.
Le jeu n'attend jamais les mods : les événements passent par une file de 1024 places ; si elle est pleine, l'événement est abandonné et compté. Envoyer un événement n'alloue rien sur les threads de jeu.
Entités
Lecture à la demande, une fois le mod lancé (setup, gestionnaires). Les temps
de chanson sont en microsecondes ; Hit.offsetMs est en millisecondes réelles.
| Fonction | Résultat |
|---|---|
game.song() |
Song | null : title, artist, creator, difficulty, mode (id du catalogue, "keys"), layout ("4" à "7"), keys, durationUs, noteCount, holdCount, bpm ({ min, max, main } ou null sans point de BPM), timing (changements de tempo triés, au plus 1024 : { timeUs, bpm, beatUs, meter }). La dernière chanson lancée, null avant la première |
game.notes(query) |
NotePage : une page bornée des notes dont la tête est dans [fromUs, toUs), voir ci-dessous |
game.hits({ limit: 50 }) |
Hit[] : derniers impacts natifs non-Miss, du plus ancien au plus récent ; chaque Hit contient seulement offsetMs et 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 et tiers ({ index, id, name, color, gradient?, earlyMs, lateMs, weight, breaksCombo }) |
game.settings() |
ModSettings | null : volume, showFps, scrollTimeMs, audioOffsetMs ; null tant que le jeu ne les a pas envoyés |
Notes du chart
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);
- Les notes sont triées par temps puis colonne ;
indexest leur position. limitvaut 64 par défaut, de 1 à 256 ;columnfiltre une colonne.nextest le curseur de la suite de l'intervalle,nullquand la page le termine. Une requête ne copie jamais plus d'une page : le chart reste partagé (Arc) entre le jeu et le thread des mods.endUsest la fin d'une hold,nullpour une note simple ; les mines ne sont pas des notes.
Derniers impacts
const hits = game.hits({ limit: 50 });
const latest = hits.at(-1); // { offsetMs: number, tier: number }, ou undefined
offsetMs est négatif en avance, positif en retard, zéro pour un impact
exact. tier est l'index dans game.judgements().tiers, où se trouvent le
nom, la couleur et les fenêtres du palier. Aucun timestamp ni score recopié
n'est ajouté à cette petite structure. Les têtes et relâchements jugés
séparément produisent chacun leur vrai impact.
limit est un entier de 1 à 256 ; {} demande 50. Les Miss sont exclus,
jamais remplacés par un faux impact à zéro. L'ordre et les doublons réels
sont conservés, notamment pour les accords. Chaque partie reçoit un nouvel
historique borné. Le natif le publie sans allocation par hit, sous le verrou
de jugement existant ; les lecteurs atomiques du thread des mods ne bloquent
pas le jeu. La perte d'une notification dans la file des mods ne perd pas
l'historique. Une contention persistante du lecteur est signalée explicitement,
sans renvoyer de tableau partiel.
mods/hit-bar/main.ts est un exemple complet : le mod autonome
pvng.hit-bar dessine des hud.box, hud.group et hud.text ordinaires.
Il choisit lui-même sa longueur, ses bandes asymétriques et la position des
impacts. Il conserve les nœuds et ne modifie que ceux dont les valeurs changent,
sur les ticks dont hitCount change. Il n'existe pas de widget natif hud.hitBar.
Les skins placent son élément pvng.hit-bar/bar indépendamment du dernier
jugement et des compteurs de pvng.judgement-display.
Actions de lecture
Le mod pvng.playback-controls (mods/playback-controls/) fournit le bouton d'introduction,
indépendamment du skin. Il utilise un hud.text avec
bind: { kind: "action", action: "skipIntro" } : le moteur contrôle sa
disponibilité et son action, la surcouche traduit son texte et masque
automatiquement le nœud hors de sa période d'utilisation. Le script conserve
la composition et le style ordinaires du HUD, sans accès DOM ni IPC.
Touches d'action des mods
Un mod peut déclarer pendant son setup une action avec une touche physique
par défaut. Le jeu l'affiche sous le nom du mod dans Réglages → Commandes :
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 });
});
}
Les identifiants enregistrés sont liés au mod (<modId>/<id>). Les choix du
joueur persistent même si le mod est désactivé ; seules les actions des mods
actifs sont proposées. Échap et F2 restent réservées. Plusieurs actions et
colonnes peuvent utiliser la même touche : les entrées de jeu et les replays
ne sont jamais remplacés. Les modifications de touches prennent effet à la
prochaine partie. Les événements arrivent par une file bornée sur le thread
des mods avec son budget habituel, sans bloquer la capture ou le jugement natifs ;
une file saturée peut perdre des événements, donc ne pas gérer une hold de
scoring ni un état critique à partir de ce callback. Le bouton Skip intro
reste une action du moteur, avec sa propre touche configurable au même endroit.
Modificateurs de jeu : gameplay.register
Un mod déclare pendant son setup un modificateur de jeu que le joueur active
par partie depuis l'onglet Mods du chart sélectionné :
setup() {
gameplay.register({
id: "auto",
name: "Auto",
description: "Le jeu joue le chart parfaitement tout seul.",
kind: "auto",
});
}
Seuls les types connus de l'hôte existent ("auto", "ghost", et les modificateurs
de chart "mirror", "random", "noLn", "fullLn", voir docs/game.md) : le
comportement est natif, le script ne fait que déclarer. Il ne peut ni injecter
des entrées ni modifier le jugement ; le mod pvng.auto (voir
mods/auto/main.ts) n'est que cette déclaration. Rust valide
l'identifiant (a-z, 0-9, -), le nom (32 caractères), la description (200),
refuse les doublons et plus de 8 modificateurs par mod, ainsi que tout appel
hors du setup. La clé publiée est <modId>/<id>.
Une déclaration peut aussi porter, tous optionnels et validés par Rust : group (un de assist, layout, longNotes,
timing, difficulty, other, sinon celui du genre), icon (un nom d'icône lucide d'une liste fixe de 28 :
MODIFIER_ICONS dans crates/modding/src/modifiers.rs, la même liste que apps/web/src/lib/mod-icons.ts, qu'un test compare, sinon l'icône du
genre) et conflictsWith (au plus 8 clés <modId>/<id> de modificateurs incompatibles ; l'interface désactive les autres quand on
en active un, l'hôte applique de toute façon sa propre règle si une sélection contient les deux). Les paramètres d'un modificateur
ne viennent jamais du script : l'hôte les déclare pour le genre (GameplayModifierKind::params() ; seul fullLn en a, voir
docs/game.md). L'événement gameplayMods publie pour chaque modificateur { key, modId, name, description, kind, group, icon, conflictsWith, params } où params est une liste de ModParamInfo (id, label, description, section, kind
slider|choice|columnMask, min, max, step, default, unit, choices, showWhen). Un paramètre columnMask est un masque de bits (bit 0 = première colonne, max = toutes) que l'interface rend en une bascule
par touche de la map sélectionnée. Les valeurs choisies sont le réglage
gameplayModParams, validé (bornes, pas, choix déclarés) quand une partie démarre. Les textes anglais de l'hôte sont des repli ;
l'interface traduit chartparam.<id>.label|description|choice.<valeur>, chartparam.section.<section> et chartgroup.<group>.
La liste (ModHost::gameplay_modifiers(), GameplayModifiers { revision, mods })
est envoyée à l'interface avec l'événement gameplayMods et suit le cycle de vie
des mods comme les jeux de jugement : un mod désactivé, en échec ou désinstallé
retire ses modificateurs et la révision augmente. Le réglage gameplayMods
contient les clés choisies ; une clé que plus aucun mod ne fournit n'agit pas.
Un jeu avec Auto ne crée ni score ni replay.
Téléchargements : downloads.register
Un mod avec la permission network (permissions: ["network"] dans defineMod) peut ajouter un miroir à la
source osu! (kind: "mirror", target: "osu") ou une source entière (kind: "source", un onglet de plus
dans la page Télécharger), pendant son setup seulement (4 déclarations par mod, 64 en tout). Le script ne fait
que déclarer ; tout le HTTP est fait par crates/downloader, sur ses propres threads, jamais par le thread
mods. Une recherche que les données ne décrivent pas s'écrit en pont (section suivante) : des fonctions pures, jamais d'E/S.
Exemple complet : examples/mods/download-sources/main.ts (hôtes fictifs example.org, exécuté par les tests
contre des réponses enregistrées).
downloads.register({
id: "maps", name: "Ma source", kind: "source",
hosts: ["api.example.org", "files.example.org"], // liste blanche, montrée au joueur
rateLimit: 60, // requêtes par minute (1 à 120, défaut 30)
auth: { kind: "token", scope: "download" }, // jeton saisi par le joueur, jamais donné au 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" },
});
Modèles d'URL. Variables typées en liste fermée : {query} (texte, toujours encodé par le jeu), {status} et
{sort} (valeurs des tables statuses et sorts), {offset}, {page}, {limit}, {cursor}, {keysMin},
{keysMax}, {starsMin}, {starsMax}, {bpmMin}, {bpmMax}, {lengthMin}, {lengthMax}, {genre}, {language} ;
{id} seulement dans download.url. Un paramètre dont une variable n'a pas de valeur est omis ; un groupe
[ …] n'est écrit que si toutes ses variables en ont une ; \[ \] \{ \} écrivent le caractère. Les filtres dont
les variables sont dans la requête partent au serveur, les autres ne font que réduire les résultats chargés (la page
le dit, clientFilters).
Réponses. Chemins JSON simples (a.b[0].c, 8 niveaux, sans filtre ni script) vers les champs d'un résultat
(id, title, artist, creator, status, bpm, playCount, favourites, cover, difficultés avec only) ;
format: "osu" lit tel quel un tableau d'objets beatmapset osu! API v2 (ce que répondent les miroirs osu!). Chaque
valeur est typée et bornée par Rust ; un résultat illisible est ignoré, rien n'est inventé. Pagination offset,
page (à partir de 0 ou 1) ou cursor (le curseur est lu dans response.nextCursor) ; un miroir pagine par
offset ou page seulement.
Sécurité. HTTPS seulement ; hôtes exacts (1 à 8 noms DNS en minuscules, sans port, joker, adresse IP ni nom local) ;
chaque URL (modèle, URL lue dans une réponse, couverture) est vérifiée avant l'envoi ; redirections suivies à la main
(3 au plus), chaque saut revérifié, en-tête du jeton retiré dès que l'hôte change ; un nom qui se résout vers une adresse
privée, locale ou de lien est refusé ; jeton gardé par l'hôte (DPAPI, un fichier par source), envoyé seulement dans
l'en-tête déclaré (Authorization, X-API-Key ou X-Auth-Token), jamais dans un événement, une erreur ou un
journal ; mêmes bornes de taille et extraction sûre que les sources intégrées ; cadence tenue par une fenêtre glissante
par source. Le joueur voit les hôtes sur la page Mods, dans la page Télécharger et dans les Réglages, peut retirer la
permission mod par mod (réglage networkDenied) et couper chaque miroir ou source (downloadDisabled) ; l'ordre des
miroirs est le réglage downloadMirrorOrder (un miroir de mod arrive après les miroirs du jeu). Désactiver, faire
échouer ou désinstaller un mod retire ses déclarations aussitôt (ModHost::download_sources(), deny_network).
Ce qu'une déclaration ne sait PAS décrire (le jeu refuse la déclaration plutôt que de deviner ; les requêtes POST,
les réponses XML ou HTML, les recherches en plusieurs requêtes et les URL calculées se font avec un pont, ci-dessous) :
clés JSON contenant un point ou un caractère hors A-Za-z0-9_-, miroir pour une source autre qu'osu!.
Ce que ni une déclaration ni un pont ne savent faire (limites réelles, à coder dans crates/downloader si besoin) :
connexion OAuth ou par formulaire, cookies, réseau écrit dans le script (jamais), archives que le jeu ne sait pas importer
(rar, 7z, fichier isolé), packs à la Etterna avec contenu déplié (une source de mod liste des sets, une archive
zip = un dossier), analyse DOM/XML native.
Ponts d'API : downloads.registerBridge
Quand une API ne se décrit pas en données (requête POST, réponse XML ou HTML, détail à aller chercher avant le fichier,
URL à calculer), le mod écrit un pont : des fonctions pures et synchrones qui calculent la prochaine étape, sans
jamais faire d'entrée-sortie. Le jeu fait toutes les requêtes (HTTP natif, mêmes garde-fous que downloads.register),
puis rappelle le mod avec la réponse. Le script n'a toujours aucun accès au réseau ; la permission network reste
nécessaire, refusable mod par mod, et le joueur voit les hôtes. Les déclarations de downloads.register marchent
comme avant. Exemple complet : examples/mods/download-bridge/main.ts (site fictif en POST + XML, puis requête de
détail ; exécuté par crates/modding/tests/bridge.rs contre des réponses enregistrées).
downloads.registerBridge({
id: "maps", name: "Ma source", hosts: ["maps.example.org", "files.example.org"], rateLimit: 30,
auth: { kind: "token", header: "Authorization", scope: "download" }, // optionnel, jamais montré au script
search(query, page, state) { // page 1 la première fois ; renvoie une étape
return { request: { url: "https://maps.example.org/cgi/find", method: "POST", form: { terms: query }, expect: "xml" }, state: { page } };
},
onResponse(response, state) { // { status, headers, text } ; renvoie l'étape suivante
return { results: [{ id: "7", title: "…", artist: "…", actions: [{ id: "download", label: "Download" }] }], nextPage: 2, state };
},
action(result, actionId, state) { // un bouton d'un résultat ; finit par { download }
return { request: { url: `https://maps.example.org/maps/${result.id}.json`, expect: "json" }, state: { id: result.id } };
},
});
Étapes. Chaque fonction renvoie une étape : { request, state } (le jeu fait la requête puis appelle
onResponse(response, state)), { results, nextPage?, state } (résultats typés, fin de la recherche ou de la page),
{ download: { url, method?, headers?, filename?, format }, state? } (fichier à télécharger ; format : zip, osz
ou qp, les archives que le jeu sait importer) ou { error }. request : url, method (GET ou POST),
headers (liste blanche : accept, accept-language, content-type, referer, origin, x-requested-with,
x-api-key, x-auth-token, authorization, if-none-match), un corps form / json / text (64 Kio) et expect
(json, text, xml, html). state est du JSON (8 Kio) que le jeu rend tel quel à l'appel suivant.
Réponses en texte seulement. response.text est le texte de la réponse (2 Mio au plus ; au-delà, c'est une erreur,
pas une troncature), status et quelques en-têtes (content-type, content-length, retry-after, x-total-count, etag, last-modified, content-disposition).
XML et HTML arrivent comme texte : pas de DOM ni de parseXml natif, on utilise JSON.parse, des expressions
régulières et des chaînes. Les 4xx/5xx (sauf 429, et 401/403 quand un jeton est configuré, que le jeu traduit lui-même)
sont livrés au script, qui décide.
Résultats typés. { id, title, artist, creator?, coverUrl?, tags?, size?, keyCount?, details?: [{ label, value }], actions: [{ id, label }] } (100 résultats par page ; chaque texte est borné et nettoyé par Rust). Le mod choisit ce qui
s'affiche ; l'interface le dessine avec un rendu générique commun aux 8 thèmes (étiquettes, taille, détails, boutons),
jamais du balisage. coverUrl doit être sur un hôte déclaré (sinon ignorée) et passe par la route de couvertures du jeu.
Un résultat sans bouton garde l'icône de téléchargement (action download) ; un bouton relance action(result, id, null)
au moment du téléchargement, sur un thread de téléchargement.
Bornes. 6 requêtes par recherche ou par action, 60 s au total, 2 Mio de texte par réponse, 256 Kio par étape.
Chaque appel du script passe par le thread mods sous le budget d'exécution habituel. Une exception, un résultat que
JSON ne sait pas écrire ou un dépassement de budget désactive le pont : la source quitte la page Télécharger et le
journal du mod dit pourquoi ; les autres mods et sources continuent. Une étape qui ne respecte pas le schéma (champ
inconnu, format: "rar", hôte non déclaré, en-tête interdit) est une erreur affichée, sans désactiver le pont.
Jeton. Gardé par le jeu (DPAPI), injecté par l'hôte dans l'en-tête déclaré (selon scope) ou à la place de
{token} dans l'URL, les en-têtes ou le corps (seulement si auth est déclaré). Le script ne le voit ni en argument,
ni dans la réponse (si le site le renvoie, le jeu le remplace par [token]), ni dans un événement, une erreur ou un
journal. Pas de cookies, pas d'OAuth. Téléchargement. Le jeu télécharge lui-même (plafond de taille), extrait sans
sortir du dossier cible (zip-slip refusé) et exige au moins une carte lisible par le jeu. Le bouton envoie la commande
downloadStart avec action.
Tiers de jugement
L'index 0 est la fenêtre la plus serrée ; le tier de miss vient en dernier
(earlyMs et lateMs à null). earlyMs est la fenêtre avant la note,
lateMs celle après. counts (jugements, joueur, fin de chanson) suit le même
ordre. id est stable dans un jeu : perfect, great… pour le jeu de
pvng.osu, ceux du mod pour ses jeux, t0…tN pour les presets
personnalisés, miss pour le miss. weight vaut null quand la précision
n'utilise pas de poids (Wife3). Voir Jugement.
Playfield
Unités du HUD : x en fraction de la largeur de l'écran (centre de la voie),
width, hitY et spawnY en fraction de la hauteur. Une note est à la hauteur
hitY + (spawnY - hitY) × (temps de la note − temps actuel) / scrollTimeUs.
game.playfieldChange signale un changement (redimensionnement).
HUD
Chaque mod possède un arbre de nœuds retenu : un nœud reste affiché jusqu'à
hud.remove, hud.clear ou la désactivation du mod. Les nœuds d'un mod forment
un calque ; les calques s'empilent au-dessus de celui du skin (qui construit le
HUD du jeu avec la même API, voir skins), dans l'ordre de chargement
des mods.
| Fonction | Rôle |
|---|---|
hud.text({ id, parent?, text, bind?, visible?, showWhen?, animate?, element?, layout?, style? }) |
texte brut (\n pour aller à la ligne), ou valeur du jeu avec bind |
hud.box({ id, parent?, fill?, visible?, showWhen?, animate?, element?, layout?, style? }) |
rectangle : fond, dégradé, bordure, arrondi, ombre ; rempli en partie avec fill |
hud.image({ id, parent?, src, fit?, visible?, showWhen?, animate?, element?, layout?, style? }) |
image .png du dossier du mod ; fit : contain (défaut), cover, fill |
hud.group({ id, parent?, flow?, visible?, showWhen?, animate?, element?, layout?, style? }) |
conteneur ; avec flow, ses enfants sont disposés en ligne ou en colonne |
hud.update({ id, text?, bind?, src?, fit?, flow?, fill?, visible?, showWhen?, animate?, element?, layout?, style? }) |
modifie un nœud existant |
hud.remove(id) |
retire le nœud et ses descendants |
hud.clear() |
retire tous les nœuds du mod |
Les fonctions de création renvoient l'id, qui est la poignée du nœud. Réutiliser
un id remplace le nœud à sa place (même parent obligatoire) ; un nœud identique
ne change rien. Les enfants suivent leur ordre de création. Dans hud.update, les
champs donnés de layout et style remplacent ceux du nœud, les autres restent ;
text/bind, src/fit, flow et fill n'existent que pour le type de nœud
correspondant (sur un autre type, l'appel lève une exception).
Liaisons
Une valeur qui change à chaque jugement ou à chaque image ne doit pas passer par le script : la surcouche la met à jour elle-même à la réception de l'événement, sans aller-retour par le thread des mods. Le script ne fait que placer les nœuds.
bind(texte) remplace le texte pendant une partie (letextdu nœud reste affiché hors partie) :{ kind: "combo" },"maxCombo","hits"(jugés moins les misses),"misses","judged","remaining"(notes restantes) — des entiers ;{ kind: "accuracy", decimals? }(pourcentage traduit, 0 à 4 décimales, 2 par défaut) ;{ kind: "tierCount", tier }et{ kind: "tierName", tier }(compteur et nom traduit du palier d'indextier) ;{ kind: "lastJudgement", colors? }(nom du dernier palier jugé, le nœud prend sa couleur, ou celle quecolorsdonne pour son id ou son index :{ "perfect": "#fff", "3": "#4fc3f7" }, 64 au plus) ;"elapsed","total"(m:ss),"timer"(« écoulé / total ») ;"fps";{ kind: "label", label }(mot traduit :hits,misses,combo,accuracy,paused,remaining,fps).fill(boîte) coupe la boîte horizontalement à une fraction, depuis la gauche :{ kind: "songProgress" }(suit l'horloge à chaque image),{ kind: "accuracy" },{ kind: "tierShare", tier }(part des jugements).showWhen(tout nœud) :"paused","running","showFps"(réglage du joueur),"judged"(après le premier jugement) ; s'ajoute àvisible.animate(tout nœud) :{ on: "judgement" | "hit" | "miss", kind: "pop" | "popFade" | "flash", durationMs }(1 à 5000 ms), relancée à chaque occurrence, sur l'échelle et l'opacité (letransformdu nœud n'est pas touché).element(tout nœud) :fps,accuracy,hits,misses,combo,timer,remaining,status,judgementoujudgementCounts; la police que le joueur a choisie pour cet élément (réglagehudFonts) remplace celle du nœud et de ses 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" } });
Unités
layout.x,layout.y: fractions de la largeur et de la hauteur du parent (de l'écran au premier niveau), dans [-10, 10] ;(0, 0)en haut à gauche ; ignorés dans un groupeflow;layout.width,layout.height(dans [0, 4]) et toutes les longueurs destyle: fractions de la hauteur de l'écran, pour garder les proportions à tout format. En 1920×1080,0.05vaut 54 px ;layout.anchor: point du nœud placé en(x, y):topLeft(défaut),top,topRight,left,center,right,bottomLeft,bottom,bottomRight;- sans
width/height, le nœud prend la taille de son contenu. Donnez une taille à un groupe dont les enfants se placent en fractions.
Style
| Propriété | Valeurs | Effet (CSS de la surcouche) |
|---|---|---|
color |
couleur | couleur du texte (blanc par défaut) |
background |
couleur | background-color |
gradient |
{ angle, stops: [{ color, at }] }, 2 à 8 arrêts, at dans [0, 1] |
linear-gradient(angle deg, …) |
border |
{ width, color }, width dans [0, 1] |
bordure pleine |
radius |
[0, 1] | border-radius |
padding |
[0, 1] | padding |
opacity |
[0, 1] | opacity |
font |
nom de police | font-family (voir Polices) |
size |
]0, 1], 0,04 par défaut | taille de police (hauteur de ligne) |
weight |
100 à 900 par pas de 100 | font-weight |
italic |
booléen | font-style |
align |
start, center, end |
text-align |
shadow |
{ x, y, blur, color }, x/y dans [-1, 1], blur dans [0, 1] |
text-shadow d'un texte, box-shadow sinon |
transform |
{ x?, y?, scale?, rotate? }, translation dans [-4, 4], scale dans [0, 10], rotate en degrés dans [-3600, 3600] |
translate, scale, rotate après le placement |
transitionMs |
[0, 10000] | durée de transition des changements |
flow d'un groupe : { direction: "row" | "column", gap?, align?, justify? }
(gap dans [0, 1] ; align : start, center, end, stretch ; justify :
start, center, end, spaceBetween).
Couleurs : #rgb, #rgba, #rrggbb ou #rrggbbaa (sRGB, alpha non
prémultiplié) ; la scène publiée les porte toutes en #rrggbbaa. Toute valeur
hors bornes, champ inconnu ou couleur illisible lève une exception TypeError
avec la raison ; le mod peut la rattraper.
Limites par mod : 256 nœuds, 256 caractères par texte (sans caractère de contrôle
autre que \n et \t), id de 1 à 64 caractères, 8 niveaux de groupes.
Polices
fonts: { title: "fonts/Title-Bold.ttf" } dans defineMod :
- chemin relatif à l'intérieur du dossier du mod (liens symboliques et jonctions
compris), extension
.ttfou.otfet signature TrueType/OpenType, 8 Mio au plus, 8 polices au plus ; - nom : 1 à 64 caractères parmi lettres et chiffres ASCII, espace,
-,_,., sans espace au début ou à la fin.
Une police invalide empêche le chargement du mod (font `nom`: …).
style.font :
- un nom déclaré par le mod désigne sa police, publiée sous la famille
<id du mod>/<nom>(big-score/title) ; il masque un nom global identique pour ce mod seulement ; "<paquet>/<police>"désigne une police qu'un autre paquet fournit, transmise telle quelle (un élément configurable peut ainsi prendre la police de son utilisateur) ;- tout autre nom est global et transmis tel quel (
"default", la police du jeu, ou une police du joueur).
La surcouche utilise la police du jeu pour une famille qu'elle ne connaît pas.
Images
src est le chemin d'un .png du dossier du mod (8 Mio au plus), vérifié à
l'appel : chemin relatif, fichier réel dans le dossier, signature PNG. La scène
porte ce chemin ; la surcouche le demande au jeu, qui le vérifie encore avant de
le servir (ModAssets::read).
Rendu natif : stage.*
Un mod peut dessiner de deux façons :
hud.*: des nœuds dans la surcouche WebView, au-dessus de tout ;stage.*: des éléments dessinés par le moteur de rendu natif (wgpu) du jeu, dans la même image que les notes, pour ceux qui veulent des effets calés à l'image près : gerbes d'étincelles à l'impact, halos de voie, sprites qui suivent le playfield.
Le skin et les scripts de map y ont accès de la même façon. Les
types, la validation, les budgets et l'évaluation sont dans crates/stage.
Permission
Il faut la déclarer : defineMod({ …, permissions: ["stage"] }) (de même dans
defineSkin et defineChart). La page Mods signale ces mods (« Dessine dans le
rendu natif ») et le joueur peut le leur retirer mod par mod ; le réglage
stageDenied (liste d'ids de mods, validée en Rust : ids valides, sans doublon,
256 au plus) le retient. Sans la permission, ou si le joueur l'a retirée, chaque
appel stage.* lève une erreur ; retirer la permission efface aussitôt la scène du
mod. Un mod qui construit sa scène dans setup la retrouve après un
rechargement (bouton Recharger) ; celui qui la construit à game.songStart la
retrouve dès la partie suivante.
Éléments
Chaque élément appartient au script qui l'a créé et porte un id : le recréer
avec le même id le remplace (à la même place, ses animations et particules en
cours gardées).
| Fonction | Élément |
|---|---|
stage.sprite({ id, at, image, size, color?, opacity?, rotation?, scale?, layer?, animations? }) |
une image PNG du paquet, déclarée dans images |
stage.rect({ id, at, size, color?, radius?, … }) |
un rectangle plein, coins arrondis de radius hauteurs d'écran (la moitié du petit côté : un cercle ou une pilule) |
stage.text({ id, at, text, size, color?, align?, … }) |
une ligne (128 caractères au plus) dans la police du jeu, size hauteurs d'écran |
stage.emitter({ id, at, size, lifetimeMs, maxParticles, color?, endColor?, speed?, speedJitter?, direction?, spread?, gravity?, burst?, rate?, fade?, shrink?, image? }) |
un émetteur de particules : burst particules à chaque "burst", rate par seconde en continu |
stage.trigger({ id, on, target, play? | stop? }) |
un déclencheur (voir plus bas) |
stage.play({ target, animation }), stage.stop({ target, animation }) |
lance ou arrête une animation depuis un gestionnaire |
stage.remove(id), stage.clear() |
retire un élément ou un déclencheur, ou tout |
Les images d'un mod sont celles de defineMod({ images: [...] }) (64 au plus,
PNG du dossier du mod) ; toutes sont décodées au lancement de chaque map, avec
les mêmes limites que les images de skin, et rangées dans l'atlas du playfield.
Le skin et les scripts de map utilisent leur propre images.
Position (at) : { space, column?, x?, y? }.
space |
Origine | x, y |
|---|---|---|
screen (défaut) |
coin haut-gauche de l'écran | fractions de la largeur et de la hauteur |
playfield |
centre de la boîte des colonnes | hauteurs d'écran, vers la droite et vers le bas |
lane |
centre de la colonne column |
idem |
receptor |
récepteur de la colonne column |
idem |
Toute position autre que screen suit le playfield : elle se déplace, tourne et
change d'échelle avec lui, y compris quand un script de map le fait bouger
(rotation, zoom, offsetX des colonnes). Un émetteur sans column placé sur
lane ou receptor projette ses particules sur la colonne de l'événement qui le
déclenche.
Calques (layer) : below (derrière le playfield, sur le fond de la map),
lanes (sur les colonnes et récepteurs, sous les notes), above (défaut, sur les
notes). Tous sont sous la surcouche HUD. Dans un calque, les éléments se
dessinent dans l'ordre de création des scripts, puis de leurs éléments.
Animations et déclencheurs
Rien ne s'exécute dans le script à chaque image. Un élément déclare ses animations
une fois : animations: { nom: { durationMs, easing?, repeat?, from, to } } (8 au
plus, 10 s au plus), où from et to donnent x, y (décalage en hauteurs
d'écran), scale, rotation (degrés, sens horaire), opacity, color ; les
valeurs absentes restent celles de l'élément, qui reviennent quand l'animation
finit. Avec repeat, elle recommence jusqu'à stop.
Un déclencheur lance (play) ou arrête (stop) une animation de l'élément
target quand on arrive ; "burst" fait jaillir un émetteur. on contient un
seul champ :
on |
Arrive quand |
|---|---|
{ judgement: { tier?, column?, miss? } } |
une note est jugée (filtres absents : tout) |
{ press: { column? } }, { release: { column? } } |
une touche de colonne est enfoncée, relâchée |
{ holdStart: { column? } }, { holdEnd: { column? } } |
une colonne commence, cesse de tenir une note longue |
Le thread de rendu évalue animations, particules et déclencheurs à chaque image, d'après l'horloge de la chanson (une pause les fige) et les jugements que l'image affiche : un jugement fait par le thread d'entrées est mis en file, sans verrou ni attente, avant que l'état de la partie ne le montre, si bien que la gerbe part dans l'image même où la note disparaît.
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" });
});
},
});
Budgets du rendu natif
- Par script : 128 éléments (émetteurs compris), 16 émetteurs, 1024 particules
en tout (somme des
maxParticles, 512 au plus par émetteur, 256 parburst), 64 déclencheurs ; au-delà, l'appel lève une erreur. - Chaque valeur est bornée et validée en Rust (tailles 0 à 4 hauteurs d'écran,
décalages ±4, opacité 0 à 1, échelle 0 à 16, couleurs
#rgbà#rrggbbaa, colonne < 32, images déclarées) ; une valeur invalide lève une erreur. - Tout est dessiné dans le lot d'instances unique du playfield, avec le shader
du jeu (pas de shader personnalisé) : une image de particule, de rectangle ou de
sprite est un quad, un texte un quad par glyphe. Le banc
(
--bench-gameplay) affiche les instances par image et la part des scènes natives. - Une image sans changement de scène n'alloue rien ; une particule morte libère sa place, la plus ancienne est remplacée quand l'émetteur est plein.
Stockage
| Fonction | Rôle |
|---|---|
storage.get(key) |
valeur JSON enregistrée, null sinon |
storage.set({ key, value }) |
enregistre une valeur JSON |
storage.remove(key) |
retire une clé |
storage.keys() |
clés, triées |
storage.clear() |
vide le stockage |
Chaque mod a son propre fichier data\mod-storage\<id>.json, choisi par l'hôte
d'après l'id validé : un mod ne peut pas lire le stockage d'un autre. Limites :
256 Kio (clés plus valeurs en JSON), 256 clés de 1 à 128 caractères. Les
changements sont écrits au plus une fois par seconde (fichier temporaire puis
renommage) et à l'arrêt de l'hôte ; la désinstallation ne les efface pas.
Dépendances entre paquets
Un mod ou un skin déclare les paquets qu'il utilise avec uses : l'id du
paquet et un intervalle de versions semver ("^1", ">=1.2, <2"), ou
{ version, required, feature }.
- Les mods se préparent (
setup) après les paquets qu'ils utilisent, sinon dans l'ordre de chargement (loadOrder, puisid) ; une dépendance circulaire refuse tous les paquets du cycle (dependency cycle: a → b → a). - Une dépendance est facultative par défaut : absente, d'une version qui ne
correspond pas ou en échec, le paquet se charge quand même,
ctx.has("<id>")vautfalse, la fonction qui en dépend est sautée, et la page Mods affiche « feature "X" disabled: package Y missing » (featurenomme X ; sans lui : « features using Y disabled »). required: truerend la dépendance obligatoire : sans elle le paquet ne se charge pas (requires Y ^1: package Y missing).- 16 paquets au plus ; un paquet ne peut pas s'utiliser lui-même. Un skin résout ses dépendances à chaque lancement de map, contre les mods actifs.
Éléments configurables
Un paquet expose des éléments que d'autres placent et habillent, sans que les scripts s'appellent entre eux : chacun garde son moteur et son budget.
// Fournisseur : déclare ses éléments et leurs options, et reçoit les réglages.
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 }) => { /* redessiner */ });
},
});
// Utilisateur (mod ou skin) : déclare le fournisseur dans `uses`, puis configure.
defineSkin({
id: "mon-skin", /* … */
uses: { "badges": "^1" },
setup(play: PlayContext) {
if (ctx.has("badges")) {
ctx.element("badges/badge").configure({ x: 0.9, label: "Hi" });
}
},
});
- Types d'options :
numberetinteger(min,max),boolean,string(maxLength, 256 au plus),color(#rgb,#rrggbb,#rrggbbaa),enum(values, 1 à 32),colors(couleurs par clé, 64 au plus).defaultest la valeur reçue quand l'utilisateur n'en donne pas. 16 éléments et 32 options au plus. configurevérifie les options contre la déclaration (option inconnue, type, bornes : exception dans le script appelant), complète avec les valeurs par défaut, puis le fournisseur reçoitelements.configure({ element, from, options }) après le script appelant. Configurer un paquet absent ne fait rien ; un paquet absent deuseslève une exception.- Le dernier réglage de chaque utilisateur est gardé : un fournisseur qui redémarre (un skin à chaque lancement) le reçoit de nouveau. Quand un skin est remplacé, les éléments qu'il avait configurés reviennent à leurs valeurs par défaut.
- Un élément peut prendre une police de son utilisateur :
style.fontaccepte"<paquet>/<police>"(Polices). - Une déclaration d'élément peut avoir
root(id du nœud racine du HUD du fournisseur, que l'éditeur retrouve à l'écran ; seul l'id est vérifié) et chaque option unlabel(64 caractères au plus) etplayer: truepour être listée dans Réglages → Mods si l'élément n'a pas de place ; toutes les options d'un élément qui axetynumériques (positions, tailles, visibilité, couleurs, décimales…) se règlent dans l'éditeur de skin,playerou non. - Le joueur a la dernière couche : sa configuration du skin actif
(
skinConfig[skin].elements, écrite par l'éditeur de skin :<paquet>/<élément>→{ option: valeur }) se superpose aux options complètes que le fournisseur recevrait (configure()du skin ou d'un mod, sinon défauts). Un seul ordre : défauts < skin < éditeur du joueur ; une valeur globale ne l'emporte jamais sur l'éditeur. Le fournisseur reçoit alorselements.configureavecfrom: "player"et toutes les options. Une valeur que la déclaration refuse (option inconnue, type, bornes, énumération) est ignorée et signalée dans les messages du fournisseur. Les valeurs d'un fournisseur absent ou désactivé sont conservées et ignorées ; elles s'appliquent quand il tourne de nouveau, à chaque redémarrage du fournisseur et après le remplacement d'un skin. Réglage globalelementOptions: seulement pour les optionsplayer: trued'un élément sans place (nixniynumériques, que l'éditeur ne peut donc pas placer), sous la configuration du skin ; les valeurs qu'une ancienne version y avait enregistrées pour un élément que l'éditeur place sont reprises une fois dans la configuration du skin actif et des skins déjà configurés (sans écraser une valeur du skin), puis effacées. - Côté hôte,
ModHost::elements()publie le catalogue des éléments des paquets actifs (ElementCatalog { revision, elements }: clé, mod,root, options avec libellé, bornes, défaut et valeur effective), republié sous une nouvelle révision quand les mods chargent ou que les valeurs du joueur changent.ModHost::set_element_overridesles remplace sans redémarrer aucun mod.
Le mod pvng.judgement-display (mods/judgement-display/)
affiche le dernier jugement dans la couleur de son palier (liaison
lastJudgement, courte animation) et le nombre de jugements de chaque palier
(liaisons tierName/tierCount), mis à jour par la surcouche sans passer par
un script. Il fournit deux éléments :
| Élément | Options |
|---|---|
judgement |
x, y, anchor, size, font, visible, animation (popFade, pop, flash, none), durationMs, colors (par id ou index de palier) |
counts |
x, y, anchor, size, font, visible, textColor, background, colors |
Mods du HUD
Tout widget du HUD est un mod, un dossier ordinaire de mods/ (exportable,
désinstallable, supprimable comme les autres), qui fournit des éléments ; le skin par défaut
(skins/default/skin.ts) les déclare dans uses (optionnels) et les place avec
ctx.element(…).configure sans créer un seul nœud hud.*
(skins). Chacun reprend le patron de judgement-display :
provides.elements avec root, des options x, y (0 à 1), anchor, size,
visible, des couleurs, et des nœuds portant leur element pour que le réglage
hudFonts continue de s'appliquer. Toutes leurs options (positions, tailles,
visibilité, couleurs, décimales…) se règlent dans l'éditeur de skin, skin par skin ;
player: true ne les place plus dans Réglages → Mods (voir ci-dessus).
| Mod | Éléments (clé mod/élément) |
Options propres |
|---|---|---|
pvng.accuracy |
accuracy |
textColor, decimals (joueur, 0 à 4, 2 par défaut) |
pvng.combo |
combo |
showLabel, textColor, labelColor |
pvng.counters |
timer, remaining, hits, misses |
textColor (timer) ; width, labelColor, valueColor (lignes) |
pvng.progress-bar |
progress |
width, height, radius, direction (leftToRight, rightToLeft), trackColor, fillColor, fillEndColor (dégradé), showTime, timeSize, timeColor |
pvng.fps |
fps |
textColor, always (joueur : ignore le réglage « Afficher les FPS ») |
pvng.pause-status |
status |
textColor, background |
Les largeurs et hauteurs sont en hauteurs d'écran, comme toute mise en page :
une barre sur toute la largeur d'un écran 16:9 mesure environ 1,73. La barre de
progression est une démonstration de ce que fait un mod avec des primitives
ordinaires : fill: { kind: "songProgress" } reste une liaison que la
surcouche rogne depuis l'horloge, le sens inverse est la même boîte tournée d'un
demi-tour autour de son centre, et le temps est un hud.text lié à timer.
Les liaisons hits, misses et remaining restent disponibles pour tout mod.
Les mods laissent leurs éléments sans nœud tant qu'aucune partie ne tourne et
les retirent à la fin (game.songEnd) ; un élément visible: false (comme
hits et misses dans le skin par défaut) n'est pas dessiné.
Jeux de jugement
Chaque jeu de jugement vient d'un mod. Un mod enregistre un jeu de jugement
dans son setup avec judgements.register (nulle part ailleurs :
l'enregistrement se ferme une fois les mods chargés). Le jeu apparaît, sous le
nom du mod, dans la catégorie Réglages → Jugement et dans le sélecteur rapide de
la sélection de map, à côté des jeux des autres mods et des
presets personnalisés du joueur (qui ne
demandent aucun mod), sous la clé <id du mod>/<id du jeu>, avec une réglette
par paramètre déclaré. Un mod qui ne fait que cela est un pack de jugements
partageable : installé, ses jeux apparaissent sans redémarrer le jeu.
Les jeux d'osu!mania et d'Etterna sont écrits ainsi, dans des dossiers
ordinaires de mods/, présentés comme tous les autres : pvng.osu
(mods/osu/main.ts, jeu osu-mania, paramètre od) et pvng.etterna
(mods/etterna/main.ts, jeu etterna, paramètre judge, abrégé J,
Wife3). Détail des règles : Jugement.
export default defineMod({
id: "judges", name: "Judges", version: "1.0.0", apiVersion: 2,
setup() {
judgements.register({
id: "tight", // a-z, 0-9, -, 32 caractères au plus
name: "Tight",
// `short` (facultatif) : préfixe collé à la valeur quand la place manque (« W2 ») ;
// sans lui, le libellé, une espace et la valeur (« 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"
// Paliers de frappe du plus serré au plus large, puis le Miss. `windowMs` vaut
// pour les deux côtés ; `earlyMs` (avant la note) et `lateMs` (après) les séparent.
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)s'exécute sur le thread des mods, dans le budget du mod, une fois par combinaison de paramètres, juste après lesetupdu mod : chaque paramètre prend les valeurs deminàmaxpar pas destep(iciwidth= 1, 2, 3), normalisées comme celles du joueur (bornées, arrondies au pas). Elle doit être synchrone et renvoyer les paliers. Le jeu garde les paliers résolus de chaque combinaison : parties et aperçu des réglages les lisent sans rappeler le mod, et un mod n'a pas à mettre ses paliers en cache. Voir le cache des combinaisons.- Bornes : au plus 4096 combinaisons par jeu et 1 s de calcul pour tous les jeux pendant le chargement ; au-delà, chaque combinaison se résout à sa première utilisation (choix du joueur ou lancement), une seule fois, puis reste gardée ; le mod en reçoit un message d'information.
- Une combinaison dont
tierslève une exception, renvoie une forme invalide ou des paliers que le jeu refuse est indisponible : un seul message par jeu la signale (« 1 of 3 parameter combinations are unavailable (first: width=3: …) »), elle n'est jamais réessayée et les sélecteurs la sautent ; un jeu sans combinaison valide n'est pas proposé. Une exception detiersne compte pas dans les erreurs qui désactivent le mod ; un dépassement du budget d'exécution, si. - Un mod désactivé en cours de session (erreurs ou dépassements de budget
répétés) retire ses jeux : l'hôte republie la liste (
JudgementSets::revisionaugmente) et l'interface la reçoit aussitôt. Un jeu choisi qui disparaît (mod désactivé, en échec, désinstallé) ou une combinaison indisponible laisse place au jeu par défaut au lancement, et le joueur en est averti. - Le jeu ne décrit que ses paliers : la précision (
accuracy) et les holds (holds) sont des règles natives qu'il choisit. Souswife3, les paliers n'ont pas de poids ; sinon chaque palier, Miss compris, en a un.osuCombineddemande exactement 5 paliers de frappe. continuousutilise les fenêtres et les poids comme points de la courbe : plateau dans le premier palier, puis interpolation linéaire native entre les bornes, séparément en avance et en retard. Les poids doivent être finis et décroissants au sens large ; le Miss garde sa pénalité déclarée.mods/prism/main.tsfournit un exemple complet sans paramètre.separateproduit deux objets de score par LN, de même poids : tête puis relâchement physique. Chaque événement garde son vrai offset ; l'expiration d'une queue tenue trop longtemps est un Miss, pas un relâchement inventé. Les autres modèles de hold conservent leurs règles.gradient?: string[]sur un palier déclare 2 à 8 couleurs hexadécimales simultanées pour son texte. Cette métadonnée est validée et conservée dans les tables résolues et les replays ;colorreste sa couleur simple.- Le jeu valide les paliers renvoyés
(
validate_set: 2 à 33 paliers, fenêtres croissantes de chaque côté, ids uniques, couleurs, poids) ; le résultat résolu est enregistré tel quel dans les replays, qui se rejugent sans le mod. - Limites : 8 jeux par mod, ids uniques dans le mod ; nom de 1 à 64
caractères ; 8 paramètres au plus, noms de 1 à 32 lettres et chiffres ASCII
commençant par une lettre,
labelde 1 à 32 caractères,shortde 1 à 8 caractères,min < max,0 < step ≤ max − min,defaultdans[min, max]. Une déclaration invalide fait échouer lesetupdu mod.
Notes de difficulté et tables des mods
chartset et chart stockent les dossiers et les difficultés ; ils ne
possèdent ni étoile ni MSD. Un mod déclare ses colonnes dans setup avec
tables.register, puis une vue avec ratings.register qui désigne une
colonne numérique de sa propre table. Le mod pvng.ratings en
donne l'exemple complet dans mods/metron/main.ts : etterna_rating
contient le MSD MinaCalc 515 et les sept skillsets ; osu_rating conserve
les étoiles d'osu!mania Current. Cinq autres tables versionnées fournissent
osu!mania 2016, Quaver 2025, Interlude 2025, Daniel 2026 et SunnyXXY 2024.
Leurs calculs natifs sont demandés après le chargement du menu et après un
import, avec au plus deux rattrapages en cours pour le mod. Une erreur de
calcul propre à un chart est conservée comme indisponibilité, sans interrompre
les autres vues ni produire une note artificielle.
Le mod choisit aussi la présentation avec panels. Pour Etterna, il déclare
un radar de skillsets avec barres de remplissage et une timeline ; pour osu, un profil du chart
(part des longues notes, densité moyenne et maximale) et une timeline.
Ce sont des statistiques neutres réellement calculées, pas une imitation
de skillsets, de strain osu ou de pp. pvng.osu et pvng.etterna restent
les mods indépendants des jeux de jugement.
panels est une liste ordonnée, vide par défaut, d'au plus quatre panneaux :
metrics: valeurs lisibles côte à côte ;bars: valeurs et barres comparatives ;radar: axes nommés et barres de remplissage, sans nombre d'axes imposé par un calculateur ;timeline: séries du chart, choisies parmidensityetbpm.
Les trois premiers ont title et fields. Chaque champ déclare label,
source, unit (vide par défaut) et decimals (1 par défaut).
source: { kind: "column", column: "value" } lit une colonne numérique de
la table de cette vue, y compris sa colonne principale.
source: { kind: "chart", metric: "holdPercent" } lit une statistique neutre :
notes, holds, holdPercent, averageNps, peakNps, bpmMin, bpmMax
ou duration (secondes). holdPercent vaut holds / notes × 100,
zéro pour un chart vide ; une analyse absente reste indisponible.
bars et radar acceptent aussi max, une borne fixe finie, strictement
positive et inférieure ou égale à 1 000 000, validée par Rust. Sans cette
borne (ou avec null), l'échelle reste automatique selon les valeurs
du panneau, avec un maximum d'au moins 1. Avec max: 40, déclaré par le
mod de notes pour les skillsets Etterna, 10 remplit 25 %, 20 remplit 50 %
et toute valeur supérieure ou égale à 40 remplit 100 %. La géométrie des
barres et du radar est bornée entre 0 et max ; la valeur numérique affichée
reste intacte. Une valeur indisponible n'a aucun remplissage. Les axes portent
les noms complets fournis par le mod, sans index numérique à décoder.
Une timeline a title et series: [{ label, source, unit }].
Les valeurs viennent de l'analyse existante et de son binSeconds ;
les graphiques ne lancent aucun calcul sur le thread d'interface.
Le thème dessine ces déclarations sans connaître l'identifiant du mod ou
du calculateur. Changer de note remplace les panneaux et leurs sources ;
une valeur absente s'affiche « — », jamais comme un zéro inventé.
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: "Mes étoiles", 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: "Mes étoiles", 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: "Mes étoiles", detail: result.error ?? `${result.done} / ${result.total}`,
done: result.done, total: result.total,
actions: result.error ? [{ id: "retry", label: "Réessayer" }] : [],
});
});
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 arrive après le chargement de l'interface et des mods :
aucun chart n'est reparcouru au démarrage ; c'est le contrôle global des ratings
manquants ou périmés (aussi déclenché par le Rescan explicite et le bouton
« Recalculer les ratings »). library.chartAdd transmet chartId après un scan, un
par chart ; library.chartsAdded transmet les mêmes ids en un seul événement
(chartIds, 8192 au plus, truncated au-delà) : un mod y répond avec
ratings.backfill({ id, charts: chartIds }), qui n'examine que ces charts (rien
d'autre n'est calculé, c'est ce que fait l'installation d'un pack).
ratings.backfill({ id }) interroge les charts manquants
ou dont la version, la date ou la taille a changé, puis lance le calcul natif
sur les workers de crates/library. Il renvoie un identifiant de demande
(number | null) ; ratings.progress fournit done, total, failed,
finished, error et ahead uniquement au mod demandeur. Le résultat reste en
SQLite. Toutes les demandes de l'hôte passent dans UNE file FIFO, un calcul à la fois :
une demande arrivée pendant une autre attend (ahead = nombre de calculs devant elle,
0 dès qu'elle tourne ; done et total restent à 0 d'ici là), sans
bloquer le thread des mods. Une demande dont plus personne n'écoute (le mod a été
déchargé ou l'hôte des mods remplacé) est abandonnée au prochain rapport : un calcul
orphelin n'empêche donc jamais les demandes de l'hôte vivant (les lignes déjà calculées restent).
ui.popup dessine le titre, le texte, l'avancement
et jusqu'à quatre actions déclaratives ; l'interface ne traite jamais son
texte comme du HTML. Fermer envoie ui.action avec id: \"dismiss\" ;
ui.dismiss() enlève la fenêtre. Un mod n'accède ni au DOM ni au SQL brut.
Filtres de bibliothèque déclarés par les mods
library.filters.register({ id, name, table, column, version, unit? })
ajoute un critère au catalogue de recherche. Comme les vues de notes,
l'enregistrement est réservé à setup et exige une table déjà déclarée
par ce mod. Le type number ou text vient de la colonne validée ;
le script ne transmet ni SQL, ni expression, ni fonction exécutée par chart.
L'exemple ci-dessus rend les étoiles Quaver réellement filtrables, avec
les résultats du calcul natif existant, sous la clé ratings-pack/stars.
Le mod pvng.ratings déclare aussi ses filtres globaux et ses sept
skillsets sur ses propres colonnes indexées.
Limites : 32 filtres par mod, identifiants uniques conformes aux identifiants
de contributions, nom de 1 à 64 caractères, unité facultative de 0 à 16
caractères sans contrôles, version entière strictement positive. Cette version
est celle des lignes attendues dans la table ; pour une table alimentée par
Metron, utilisez calculator.version. indexed: true sur les colonnes
concernées permet à SQLite d'exploiter leurs index.
Le préfixe host/ est réservé aux champs neutres ; un mod nommé host
ne peut pas déclarer de filtres. Les clés reçues sont bornées à 256 octets,
sans caractères de contrôle.
L'hôte publie le catalogue complet libraryFilters (champs neutres et mods)
après le chargement et retire les contributions d'un mod arrêté. Une interface
envoie un LibraryQuery typé : texte limité à 512 caractères et 16 clauses
maximum. Une clause numérique a au moins une borne finie, inclusive, avec
minimum ≤ maximum ; les décimales signées sont acceptées sans arrondi imposé.
Une clause texte non vide fait au plus 256 caractères, ignore les espaces
extérieurs et choisit correspondance exacte ou sous-chaîne littérale.
Les mots libres et toutes les clauses se combinent par ET sur le même chart,
jamais sur plusieurs difficultés d'un dossier.
La requête native revalide le propriétaire, la colonne et son type, puis
exige la version, la date et la taille du chart enregistrées avec la ligne.
Valeurs absentes, erreurs de calcul et lignes périmées ne correspondent pas,
même à une borne zéro. Toutes les valeurs sont liées comme paramètres SQLite.
Les pages arbitraires sont bornées à 80 sets et ne contiennent que les
difficultés correspondantes ; le compte, la page et les rangs d'ancrage utilisent
un même instantané. libraryPage accepte au plus deux identifiants de dossiers
dans anchors ; la réponse donne leur rang filtré exact, ou null uniquement
s'ils ne correspondent plus. L'interface retrouve ainsi le dossier visible et
le dossier ouvert après un rattrapage, même déplacés de plusieurs pages,
sans charger toute la bibliothèque. L'ordre des dossiers repose sur leur
premier index d'origine, pas sur l'index de leur première difficulté survivante.
Un filtre indisponible provoque une erreur explicite, pas la suppression
silencieuse d'une condition. Il ne nécessite aucun accès DOM, plugin natif,
décodage de chart pendant la recherche ou callback de mod par difficulté.
Les identifiants de table et de colonne sont bornés et validés, les tables
sont isolées par mod et leurs lignes disparaissent avec leur chart. Une
colonne numérique peut être indexée pour les filtres. Le moteur refuse une
vue qui cible la table d'un autre mod, une colonne non numérique ou une
version différente de metron.catalog(). Les résultats non calculables sont
enregistrés comme erreur et affichés « — », sans note artificielle.
ratings.register est réservé à setup : au plus 16 vues par mod, ids
uniques, nom de 1 à 64 caractères, unité de 1 à 16 caractères.
Rust valide les panneaux : titre et libellés de 1 à 64 caractères, unité
de 0 à 16 caractères sans caractères de contrôle, précision de 0 à 3 ;
1 à 16 champs pour metrics/bars, 3 à 12 pour radar, 1 ou 2 sources
distinctes pour timeline. Les colonnes appartiennent obligatoirement
à la table numérique déclarée. HTML, CSS, fonctions et sources arbitraires
ne sont pas acceptés.
Calculateurs disponibles pour les mods (metron.catalog()) : osu-2016, osu-2018,
osu-current, etterna-515, quaver-2025, interlude-2025,
daniel-2026, sunnyxxy-2024. MinaCalc 515 produit le MSD à 1,00× pour
4K/6K/7K, pas pour 5K. Pendant une partie,
metron.difficulty({ calculator }) lit la valeur native préchargée pour
le chart (number | null) ; game.song()?.ratings donne les valeurs
disponibles indexées par identifiant du calculateur, tandis que
game.settings().ratingSystem donne la clé <mod>/<vue> choisie.
Désactiver ou désinstaller un mod retire ses vues en direct ; le choix
revient à pvng.ratings/etterna-515 (ou la première vue disponible) avec
un avis. Masquer une vue ne désactive pas le mod qui fournit le jugement.
Changer de note n'affecte pas le jugement.
Pour un compteur de performance, metron.performance({ calculator, accuracy })
reçoit une fraction de score [0, 1] propre au calculateur et
rend un identifiant de demande (number | null, null si le service est
occupé). Seuls osu-2018 (pp) et etterna-515 (SSR) ont une performance ;
osu-2016 n'est disponible que pour la difficulté. osu-2018 reste un calculateur
de performance, mais pvng.ratings ne le déclare plus comme vue de difficulté
(sa difficulté est exactement celle d'osu-2016). Etterna calcule le SSR,
pas des pp osu! L'hôte ne convertit pas implicitement la précision du jeu de
jugement actif en score osu! ou Etterna : le mod fournit la précision appropriée.
Le résultat metron.performanceResult (uniquement au mod demandeur) contient
requestId, calculator, value, unit (pp ou SSR) et error. Un
nouveau chart invalide les anciennes réponses ; les demandes sont limitées à
deux par mod et 64 au total. Le chart décodé est partagé sans recopier ses
notes, Metron tourne dans le pool de calcul, et les threads de jeu et de mods
n'attendent jamais le calcul.
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}` });
}
});
// Avec un score osu! 2018 calculé par le mod :
function scoreUpdated(osuScoreFraction: number) {
metron.performance({ calculator: "osu-2018", accuracy: osuScoreFraction });
}
osu-current, Quaver, Interlude, Daniel et SunnyXXY ne fournissent pas
de performance dans Metron : toute demande est refusée, aucun pp n'est dérivé
des étoiles. La reconnaissance de motifs Leyna n'est pas une note scalaire.
Classement local : leaderboard.*
Un mod lit le classement LOCAL d'un chart : lecture seule, typée, bornée, sans permission (c'est la donnée du joueur ; aucun chemin, fichier ni entrée de replay n'est transmis). Conception : Mods : accès au classement local et onglets de la sélection.
export default defineMod({
id: "best-plays", name: "Best plays", version: "1.0.0", apiVersion: 2,
setup() {
ctx.on("game.songStart", () => { /* chartId vient de library.chartAdd, de vos tables… */ });
ctx.on("library.chartsAdded", ({ chartIds }) => {
const id = leaderboard.query({ chartId: chartIds[0], limit: 5 }); // number | null
if (id === null) return; // trop de demandes en attente
});
ctx.on("leaderboard.result", result => {
if (result.error) { log.warn(result.error); return; }
for (const entry of result.entries) {
const who = entry.playerName ?? "inconnu"; // ancien replay sans nom
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? }):chartIdest l'identifiant de chart de la bibliothèque (library.chartAdd,library.chartsAdded, tables des mods),limitde 1 à 100 (20 par défaut),offsetde 0 à 100 000. Hors bornes, type faux ou champ inconnu : exception (validé en Rust).leaderboard.best({ chartId })estqueryaveclimit: 1: le rang 1 du classement entier, replays importés compris (received).- Le retour est un numéro de demande, ou
nullquand elles s'accumulent (2 en attente par mod, 16 pour tous) ou que l'hôte ne peut pas les prendre. Le calcul tourne sur un worker decrates/library; le thread des mods ne l'attend jamais. leaderboard.resultarrive uniquement au mod demandeur :requestId,chartId,offset,total(taille du classement),entries,judgement(nom du jugement courant),performance({ calculator, unit }ounull),unavailableReplayseterror(message anglais,nullsi tout va bien : chart inconnu, bibliothèque ou travailleurs indisponibles). Un mod désactivé ou déchargé n'en reçoit plus.- Une entrée :
rank(1-based dans le classement entier),replayId,playerName(nullpour un ancien replay sans nom : jamais attribué au joueur actuel),received,accuracy(pourcent),performance(nullsi non calculable),performanceNonstandard,maxCombo,misses,tiers([{ name, count }], Miss une seule fois),rate(cadence enregistrée),modified(modificateurs de chart),playedAtMs. - C'est le classement de l'interface : chaque replay est rejugé avec le jugement COURANT du joueur, noté par le calculateur de performance choisi, classé par performance quand il y en a une (précision, combo, misses, date ne départagent qu'ensuite). Il n'y a pas de cache : chaque demande rejuge le chart, d'où une page à la fois et la limite de 2.
Onglets dans la sélection : tabs.*
Un mod ajoute un onglet au panneau du chart choisi (après Info, Leaderboard, Mods, Training, Editor) ou des sections aux onglets Info, Leaderboard et Mods, de façon déclarative : Rust valide et borne tout, l'interface est un rendu générique (les huit interfaces l'héritent), jamais de balisage, de style ni de code du mod. Conception : Mods : accès au classement local et onglets de la sélection.
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.registerettabs.extendsont réservés àsetup; les skins et scripts de map ne les appellent pas. Panneaux :metrics,bars,radar,timeline(comme les notes) et deux nouveaux,leaderboard(1 à 10 meilleurs plays du chart, le classement déjà évalué de l'onglet Leaderboard) ettext(1 à 280 caractères, sans balisage).- Sources de champ :
column(colonne numérique de la table d'une note déclarée par le même mod, déclarée avant),chart(statistique neutre),leaderboard(plays,bestPerformance,bestAccuracy: exact ou indisponible ; l'unité est celle du classement). - Bornes : 4 onglets et 4 extensions par mod, 12 onglets et 24 extensions au total, titre d'onglet
de 1 à 24 caractères, icône dans une liste fermée (
layers,info,trophy,star,gauge,activity,chart-bar,list,flame,music,clock,target,sparkles,bookmark,heart,users),orderde 0 à 1000, 1 à 8 panneaux par onglet et 1 à 4 par extension. tabs.extend({ tab: "info" | "leaderboard" | "mods", slot: "top" | "bottom", order?, panels })ajoute avant ou après le contenu de l'hôte, sans jamais le retirer ni le réécrire ; sur Leaderboard, deux bandes bornées entourent la liste (seule la liste défile).- Les onglets suivent le mod en direct (désactivation, panne, désinstallation) ; si l'onglet affiché disparaît, la page revient sur Info.
Vitesse de défilement
Chaque système fourni avec le jeu est un mod indépendant, déclaré dans
setup avec scrollSpeed.register : un paramètre et une conversion toMs
vers le temps de défilement natif en millisecondes. Il apparaît sous le nom
de son mod dans Réglages → Scroll speed, avec la clé <modId>/<id>.
Prism en ms suit exactement ce contrat : ce n'est pas un choix codé dans
l'interface. Les exemples complets sont mods/scroll-prism,
scroll-osu, scroll-etterna et scroll-quaver, distincts des mods de jugement.
Formules et sources : Vitesse de défilement.
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, -, 32 caractères au plus
name: "Beats",
// `short` (facultatif) : préfixe collé à la valeur (« B4 ») ; sans lui, le
// libellé, une espace et la valeur (« Beats 4 »).
param: { label: "Beats", short: "B", min: 1, max: 8, step: 1, default: 4 },
// Temps de défilement en ms pour une valeur du paramètre. `context.travel` :
// distance parcourue pendant ce temps, en hauteurs d'écran (0,8).
toMs: (beats, context) => beats * 150 * context.travel / 0.8,
});
},
});
toMs(value, context)s'exécute uniquement sur le thread des mods, dans son budget. Après le chargement, chaque point suggéré deminàmaxparstepest résolu une fois (4096 points au plus). La grille publiée permet l'aperçu instantané et le changement de système au temps le plus proche.- Les valeurs saisies sont libres et ne sont ni bornées ni rabattues sur
step. Une valeur hors grille est résolue asynchroniquement par le mêmetoMsavant de pouvoir jouer. Le fournisseur publie son dernier résultat personnalisé (custom:value,scrollMsouerror) ; les demandes rapides sont regroupées vers la dernière valeur. Un lancement lit seulement un résultat exact déjà publié, sans exécuter le mod. contextne contient quetravel, la distance de référence en hauteurs d'écran. Les conversions conservent une vitesse relative à l'écran, sans dépendre du BPM du chart. L'X-mod d'Etterna ne correspond pas à un temps unique et n'a donc pas sa place ici.- Un temps doit être positif, fini et représentable en microsecondes
f32par le rendu. Il n'existe pas de borne globale 200–3000 ms. Une conversion qui lève une erreur ou renvoie un temps invalide reste explicitement indisponible ; elle ne joue jamais avec une valeur voisine. Un échec personnalisé déjà connu n'est pas rappelé en boucle. Les erreurs de conversion ne désactivent pas le mod, contrairement aux dépassements répétés de son budget. - Un mod désactivé, en échec ou désinstallé retire ses systèmes
(
ScrollSpeedSystems::revisionaugmente). Si le fournisseur choisi disparaît, l'interface propose le mod Prism au même temps résolu, avec un avis, seulement si ce mod est disponible. Sans fournisseur utilisable, Jouer reste indisponible ; aucun faux système natif n'est ajouté. - Le jeu ne transmet au rendu et aux mods que le temps effectif
(
game.settings().scrollTimeMs,game.playfield().scrollTimeUs), que les replays enregistrent. - Limites : 8 systèmes par mod, ids uniques dans le mod ; nom de 1 à 64
caractères ;
labelde 1 à 32 caractères,shortde 1 à 8,min < max,0 < step ≤ max − min,defaultdans[min, max], 4096 valeurs au plus. Une déclaration invalide fait échouer lesetupdu mod.
Convertir un skin osu! : la permission skinImport
Un mod qui déclare permissions: ["skinImport"] peut proposer de convertir un skin que le joueur
choisit (dossier ou .osk) en un skin de Prism. Il ne touche ni fichier ni pixel : l'hôte lit la
source, transforme les images et écrit le dossier ; le mod ne donne que des valeurs (jamais
de code). Le mod du jeu mods/skin-converter (pvng.skin-converter) en est l'exemple complet ;
la conception est dans convertisseur-skin-osu.md.
skinImport.register({ id, name, description?, localized?, sources })(danssetup, 4 par mod) ajoute une carte sur la page Skins ;sources:folderet/ouarchive. Le bouton de la carte ouvre le dialogue de fichier du jeu (jamais celui du mod), pas pendant une partie.- Événement
skinImport.opened { importerId, locale, source, error }: la source lue en natif (dossier : 4096 fichiers, 6 niveaux, liens ignorés ;.osk: 512 Mio, 8192 entrées, 64 Mio par entrée, 1 Gio au total).sourcecontient leskin.inianalysé (SkinIni: sections[Mania]fusionnées parKeys, colonnes indexées, couleurs#rrggbbaa, valeurs hors bornes refusées) et l'inventaire des images (SourceImage: nom normalisé sans@2xni-N, dimensions, échelle, images d'animation, image vierge, couleur de crête), sans aucun octet.localevauten,frouzh. skinImport.stage({ sourceId, skin, images })→ numéro de demande : l'hôte valideskin(unSkinDescription:PlayfieldSpeccommun, une disposition par nombre de touches avec sesLaneSpec, places du HUD) avec les règles des skins, exécute lesImageOp(source,dest,animationouframe,padTop/padBottomsignés en pixels source,fitoustretch, PNG ; une image sans transformation est copiée telle quelle) sur le fil natifskin-import, et répond parskinImport.staged: fichiers, tailles, budget de 900 Kio (overBudget, rapporté, pas imposé).skinImport.create({ stageId })→skinImport.created { id }: l'hôte génère unskin.tslisible, l'écrit dans<données>/skins/(dossier de préparation, identifiant libre-2,-3, le script est chargé avant le renommage) et ne remplace jamais un skin existant.skinImport.panel({ title, status, detail?, sections, notes, actions }): la carte déclarative (texte seulement, bornée : 6 sections de 24 lignes, 48 notes, 4 actions). Une action aveceditSkin: "<id>"(un skin que ce mod a créé) ouvre l'éditeur de skin ; les autres arrivent au mod parskinImport.action { id }.skinImport.close()retire la carte ; « Fermer » envoiedismiss.
Les erreurs de ces fonctions sont des exceptions (permission absente, source qui n'est pas celle du joueur, demande déjà en cours). Un hôte sans dossier de skins n'affiche simplement aucun importeur.
Paquets .pvmod
Un paquet est une archive ZIP du dossier du mod, nommée <id>-<version>.pvmod
par l'export. ModInstaller (voir Intégration) :
- installe un paquet dans
<dossier des mods>/<id>: toute l'archive est vérifiée avant d'écrire quoi que ce soit, extraite dans un dossier caché du dossier des mods, puis le mod est chargé dans un moteur jetable pour lire sa déclarationdefineMod; enfin le dossier est mis en place par renommage. Une installation qui échoue laisse le dossier des mods tel quel ; - remplace un mod installé avec le même
idquelle que soit sa version et indique le changement : nouvelle installation, mise à jour (from), réinstallation, retour à une version antérieure (from), ou remplacement d'un mod illisible. Un dossier<id>qui contient un autre mod n'est pas remplacé ; - désinstalle un mod (son dossier
<dossier des mods>/<id>) ; son stockage est conservé pour une réinstallation ; - exporte un mod en
.pvmod: les entrées cachées (.git…) etnode_modulessont ignorées, tout autre fichier doit être autorisé.
Tous les mods, ceux du jeu compris, s'exportent et se désinstallent : la page
Mods les liste à l'identique. Désinstaller un mod de mods\ à côté de
prism.exe supprime son dossier s'il est accessible en écriture, sinon une
erreur nomme le chemin. Retirer un mod, c'est supprimer son dossier.
Règles d'un paquet
| Règle | Valeur |
|---|---|
| Fichiers autorisés | .ts (dont .d.ts), .ttf, .otf, .png, .json (données), .md, .txt |
| Chemins | relatifs, séparés par /, sans .., ., lecteur, :, nom réservé Windows (con, nul…), point ou espace final ; 200 caractères et 8 niveaux au plus ; pas deux fois le même nom à la casse près |
| Entrées refusées | liens symboliques, entrées chiffrées, compression autre que stockée/deflate |
| Taille | 64 Mio pour le fichier, 16 Mio par fichier extrait, 64 Mio au total |
| Nombre | 1024 entrées, 256 fichiers |
| Racine | main.ts ou index.ts à la racine de l'archive, ou dans un unique dossier qui contient tout (archive du dossier lui-même) |
Chaque fichier extrait est tenu à la taille annoncée par l'archive : une archive
qui ment ne peut pas écrire plus que ce qui a été vérifié. Un .json n'est
jamais un manifeste : la seule déclaration d'un mod est son defineMod.
Page Mods
La page Mods de l'interface liste les mods (état, raison d'un échec, permission
stage) et leurs messages. Elle installe un paquet (bouton ou glisser-déposer
d'un .pvmod sur la fenêtre), exporte un mod vers un dossier choisi et
désinstalle ; ces opérations tournent hors du thread de l'interface puis
relancent l'hôte. Ses commandes (installMod, exportMod, uninstallMod,
reloadMods, openModsFolder) sont traitées côté Rust ; installer, exporter,
désinstaller ou recharger est refusé pendant une partie.
Chaque mod possède un interrupteur activé/désactivé, utilisable au clavier
avec Espace. Le choix est conservé dans Settings.disabledMods et recharge
l'hôte sans désinstaller le paquet ni effacer son stockage. Une modification
reçue pendant une partie ou un replay attend son retour au menu.
L'interrupteur représente le choix du joueur ; l'état « Échec » ou
« Désactivé » affiché à côté reste le résultat réel de l'exécution.
Un paquet dont la déclaration ne fournit aucun identifiant valide ne peut
pas être désactivé par ce réglage ; son interrupteur indisponible l'explique.
Les mods du jeu ont le même interrupteur. Leurs jugements et autres contributions disparaissent quand ils sont désactivés, et reviennent à leur réactivation. Désactiver osu!mania ne le réactive pas implicitement pour servir de repli : s'il ne reste aucun jeu choisi disponible, le sélecteur l'indique et le lancement est refusé jusqu'au choix d'un autre jugement disponible ou personnalisé.
Dossier des mods
Pendant le développement, un mod est simplement un dossier du dossier des mods :
%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\"
Le jeu crée ce dossier au démarrage. La variable d'environnement
PRISM_MODS_DIR remplace uniquement cette racine des données du joueur ; les mods
du jeu restent lus dans mods\ à côté de prism.exe (ou dans mods/ du dépôt
en développement). Les scripts transpilés sont mis en cache dans
data\mod-cache, le stockage des mods est dans data\mod-storage.
Sécurité
Un mod téléchargé ne doit rien pouvoir faire d'autre que ce que l'API lui donne :
- son code tourne dans son propre moteur Rust-TS (QuickJS), sur le thread
mods, jamais dans la WebView : pas d'accès au DOM, au JavaScript de l'interface, aux commandes IPC du jeu, au réseau, aux fichiers ou aux timers ; - il ne dessine qu'en décrivant des nœuds typés (texte, boîte, image, groupe) dont chaque propriété est bornée et validée en Rust ; ni HTML, ni CSS brut, ni script. La surcouche traduit chaque propriété vers une propriété CSS précise et affiche le texte comme texte ;
- il ne lit que les fichiers de son propre dossier (polices et images, servies par le jeu après vérification) et son propre stockage ;
- il ne dessine dans le rendu natif (
stage.*) que s'il le déclare (permissions: ["stage"]) et que le joueur ne le lui a pas retiré, avec des éléments typés et bornés, sans shader ; - il ne fait jamais de réseau lui-même :
downloads.registerne déclare que des données (hôtes, modèles d'URL, chemins JSON), exigepermissions: ["network"]que le joueur peut retirer, et c'est le jeu qui fait les requêtes, sur les hôtes montrés au joueur seulement (Téléchargements) ; - un paquet est vérifié entièrement avant d'être extrait (Règles d'un paquet).
Budgets
- Chaque mod a son propre moteur : 16 Mio de mémoire QuickJS, 1 Mio de pile et
30 ms d'exécution par chargement,
setup, événement ou tick. - Un mod est désactivé après 3 échecs consécutifs (exception non rattrapée ou dépassement du budget) ou 3 dépassements du budget au total : son moteur est libéré, son HUD retiré, son stockage écrit et la raison signalée, jusqu'au prochain démarrage de l'hôte.
- Déclaration invalide, erreur de transpilation, exception au chargement ou dans
setup: le mod n'est pas chargé et l'erreur est signalée ; les autres mods se chargent normalement. - Les lignes de log sont coupées à 1024 caractères ; la file des messages compte 256 places, au-delà les messages sont abandonnés et comptés.
Threads
- Tout le code des mods s'exécute sur un seul thread
mods, épinglé par défaut au dernier cœur logique ; jamais sur les threads d'entrées, de rendu, audio ou de la fenêtre. L'épinglage borne le thread à ce cœur sans le réserver : le budget est du temps réel. - Le budget est coopératif : QuickJS interrompt le JavaScript ; les fonctions hôte sont courtes et ne bloquent pas.
- Côté jeu, un envoi est un
try_senddans une file bornée : aucun thread de gameplay n'attend les mods. Les entités d'une partie (chanson, chart, jugements, playfield) sont construites avant la partie et partagées enArc.
Intégration côté jeu (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"));
// Appelé sur le thread des mods après chaque publication : réveille le thread principal.
options.on_scene = Some(SceneListener(Arc::new(move || wake_main_thread())));
// Skin choisi (skin::Package::DEFAULT par défaut) : il occupe le calque le plus bas.
options.skin = skin_package;
// Mods privés du rendu natif par le joueur (réglage `stageDenied`).
options.stage_denied = settings.stage_denied.clone();
// Validation du jeu appliquée à chaque combinaison résolue (le desktop y passe
// `validate_set` du juge) : une combinaison refusée est indisponible.
options.judgement_check = Some(JudgementCheck(Arc::new(|resolved| check(resolved))));
let host = Arc::new(ModHost::start(options)?);
// Lancement d'une map, sur un thread d'aide (jamais le thread de rendu) : le skin
// est rechargé, son setup(play) place le playfield et construit son HUD, puis le
// script de la map s'il en a un (voir docs/map-scripts.md).
// Les changements que le skin fait pendant la partie (`playfield.update`,
// `lanes.set` dans ses gestionnaires) arrivent au rendu par `patches`, ceux du
// script de la map par sa propre file : un patch par transition et par pas du
// thread des mods, jamais bloquant.
let (patches, receiver) = skin::patch_channel();
let (chart_patches, chart_receiver) = skin::patch_channel();
let chart = modding::chart_script(&chart_file) // <chart>.script.ts, sinon 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> }
// Échec du skin : le premier skin disponible le remplace (sinon les valeurs par défaut
// du moteur) et `run.skin.error` dit pourquoi ;
// échec du script : `run.chart.error`, la partie se joue avec le skin seul ;
// délai dépassé : `PlayfieldSpec::default()` sans script. `run.skin.images` :
// images à décoder pour la partie.
// Threads de jeu : jamais bloquant, sans allocation. `send_tracked` rend un
// ticket : `wait_handled(ticket, délai)` attend (borné) que les mods aient
// traité l'événement et publié la scène qu'il produit, pour envoyer un jugement
// et le changement de HUD qu'il cause ensemble.
let ticket = host.send_tracked(GameEvent::Judgement(judgement));
// Thread de rendu, à chaque image : la scène native (lecture sans verrou) et les
// jugements que l'image montre ; stage::StageRenderer évalue animations, particules
// et déclencheurs puis émet des quads dans le lot d'instances du playfield.
// `host.deny_stage(ids)` retire le rendu natif aux mods choisis par le joueur.
let stages = host.stage(); // StageScene { revision, stages, images }
// Pont vers la surcouche, au plus 240 fois par seconde : un diff regroupe tous
// les changements depuis la scène affichée.
let scene = host.scene();
if scene.revision != shown.revision {
let diff = SceneDiff::between(&shown, &scene); // SceneDiff::full(&scene) au (re)chargement
overlay.send(serde_json::to_string(&diff)?);
shown = scene;
}
// Fichiers de la surcouche (polices, images) : vérifiés à chaque requête.
let asset = host.assets().read(mod_id, path)?; // bytes + mime
// Jeux de jugement et tables de leurs combinaisons de paramètres, lues sans
// verrou (republiées sous une nouvelle `revision` quand un mod qui en fournit est
// désactivé ou qu'une combinaison résolue à la première utilisation est gardée).
// `JudgementSets::resolved` : `Some(Ok)` résolu, `Some(Err)` indisponible ou jeu
// inconnu, `None` pas encore résolu (jeu trop grand) : `resolve_judgement` le
// résout alors une fois sur le thread des mods et le garde.
let sets = host.judgement_sets(); // pvng.osu/osu-mania, pvng.etterna/etterna, puis ceux des mods
let modifiers = host.gameplay_modifiers(); // pvng.auto/auto, puis ceux des mods (`gameplay.register`)
let resolved = match sets.resolved("pvng.osu/osu-mania", ¶ms) {
Some(resolved) => resolved,
None => host.resolve_judgement("pvng.osu/osu-mania", params).wait(Duration::from_secs(3)),
}?;
// Systèmes de vitesse de défilement et grilles de leurs valeurs converties une fois
// (republiés quand un mod qui en fournit est désactivé) ; le rendu ne reçoit que
// le temps.
let systems = host.scroll_speed_systems(); // fournisseurs indépendants pvng.scroll-* et mods du joueur
let scroll_ms = systems.get("pvng.scroll-etterna/c-mod").and_then(|cmod| cmod.scroll_time_ms(700.0)); // ≈ 514,286
// Valeur libre : demande non bloquante ; lire sa conversion exacte après republication.
host.resolve_scroll_speed("pvng.scroll-osu/osu-mania", 57.35);
let status = host.status();
for message in host.drain_messages() { /* afficher */ }
let installed = ModInstaller::new(data.join("mods")).install_package(&file)?;
Le jeu fournit :
| Événement | Quand |
|---|---|
GameEvent::SongStart(SongStart { song, chart, judgements, playfield, time_us }) |
au lancement d'une partie (entités en Arc) |
GameEvent::Judgement(Judgement { column, time_us, offset_us, tier, combo, counts, accuracy }) |
chaque jugement, après relâchement du verrou de partie ; counts est un TierCounts sans allocation |
GameEvent::Pause / Resume(SongTime) |
pause, reprise |
GameEvent::SongEnd(SongEnd { counts, max_combo, accuracy, aborted }) |
fin de partie |
GameEvent::Playfield(Arc<Playfield>) |
redimensionnement pendant une partie |
GameEvent::Settings(Arc<ModSettings>) |
réglages appliqués, redémarrage de l'hôte |
L'application desktop démarre l'hôte (apps/desktop/src/app/mods.rs), construit
ces entités depuis game_core (apps/desktop/src/mod_events.rs) et transmet à
l'interface, au plus toutes les 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}]} et {type:"modMessages", messages:[{modId, level, text}]}. Rien ne part avant la fin du chargement des mods ; la liste des mods et
les jeux sont ensuite envoyés quand l'hôte en publie de nouveaux (mod désactivé,
mods rechargés après une installation, une désinstallation ou « Recharger ») et
à chaque ready de l'interface. Les
opérations de paquet répondent par modInstalled ({id, name, version, change, from}, change : new, upgrade, reinstall, downgrade, replace),
modExported, modUninstalled ou modError.
Pendant une partie, la surcouche WebView (page overlay.html, voir
architecture) reçoit les diffs de
scène ({type:"sceneDiff", …}), calque du skin en dessous (z 0) puis ceux des
mods, et charge polices et images du skin et des mods par la route
/mods/<id>/<chemin> du protocole prism, servie par ModAssets::read
(dossier du skin compris). Le rendu natif dessine en
dessous le fond, le playfield et les scènes stage.*.
prism.exe --bench-gameplay <s> charge les mods du dossier comme une vraie partie
et affiche les mods actifs et les textes de la scène finale.
Format des diffs de scène
SceneDiff se sérialise en 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: révision à laquelle le diff s'applique ;to: révision obtenue.reset: true(avecfrom: 0) : repartir de rien (SceneDiff::full).- Un calque par skin ou mod (
modId), empilé selonz(plus haut au-dessus ; le skin a toujoursz0, les mods suivent dans leur ordre de chargement) ;clear: le calque a perdu tous ses nœuds (mod désactivé…). remove: nœuds retirés, seulement le haut de chaque sous-arbre retiré ; un id inconnu est ignoré.upsert: nœuds créés ou modifiés, parents avant enfants. Un id connu est mis à jour sur place ; un id inconnu est créé et ajouté à la fin de son parent (le calque sansparent). Un nœud recréé ailleurs dans l'ordre est d'abord retiré.- Nœud :
id,parent?,kind(text+text+bind?,box+fill?,image+src+fit,group+flow?),visible,showWhen?,animate?,element?,layout,style; les champs absents prennent les valeurs par défaut décrites plus haut ; les liaisons sont résolues par la page (Liaisons).srcest un chemin du paquet du mod du calque, à demander au jeu (ModAssets::read(modId, src)).
Appliquer : reset → vider ; puis pour chaque calque clear, puis remove,
puis upsert dans l'ordre. Les diffs se calculent entre deux instantanés
immuables par comparaison de pointeurs (Arc par calque et par nœud) : le coût
suit la taille des calques modifiés, jamais le nombre d'images.
Exemples
examples/mods/score-counter/: score (sur 1 000 000, chaque note valant le poids de son tier ; selon la précision pour Wife3), précision, combo et nombre de jugements par tier aux couleurs de la configuration de jugement, à droite sous le combo du skin.examples/mods/judgement-colors/: enregistre le jeu « Spectrum 15 » (15 tiers, sans paramètre) et affiche en bas une barre partagée entre les tiers de la partie, chaque segment aussi large que sa part des jugements.examples/mods/lane-hints/: sous chaque récepteur, un voyant qui s'allume à mesure que la prochaine note de la voie approche, lu page par page avecgame.noteset placé avecgame.playfield().mods/osu/,mods/etterna/: les mods des jeux osu!mania et Etterna (voir Jeux de jugement) ;mods/judgement-display/: dernier jugement et compteurs par palier,accuracy/,combo/,counters/,progress-bar/,fps/,pause-status/: le reste du HUD par défaut (mods du HUD), tous en éléments configurables.examples/mods/hit-bursts/: rendu natif, une gerbe d'étincelles à la couleur du tier au récepteur de chaque note touchée et un halo sur la colonne tant qu'une note longue est tenue, déclarés une fois par partie et joués par le moteur de rendu.
Référence générée
Déclarations de mods/sdk/modding.d.ts, générées depuis les contrats Rust :
<!-- 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 };
};
<!-- sdk:declarations:end -->