Sur cette page

← Toute la documentation

Mods TypeScript

Le guide détaillé des mods TypeScript : événements, entités, HUD, stage, stockage, paquets .pvmod, sécurité, budgets.

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

  1. Chargement. Le code de premier niveau du point d'entrée (et des modules importés) s'exécute. Seuls defineMod et log.* 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 appeler setup.
  2. Déclaration. defineMod doit ê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).
  3. Vérifications. Les polices déclarées sont vérifiées sur le disque, le stockage du mod est ouvert.
  4. setup(mod). Appelée sous le budget d'exécution, après les setup des 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 sauf defineMod.

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 ; index est leur position.
  • limit vaut 64 par défaut, de 1 à 256 ; column filtre une colonne.
  • next est le curseur de la suite de l'intervalle, null quand 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.
  • endUs est la fin d'une hold, null pour 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 (le text du 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'index tier) ; { kind: "lastJudgement", colors? } (nom du dernier palier jugé, le nœud prend sa couleur, ou celle que colors donne 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é (le transform du nœud n'est pas touché).
  • element (tout nœud) : fps, accuracy, hits, misses, combo, timer, remaining, status, judgement ou judgementCounts ; la police que le joueur a choisie pour cet élément (réglage hudFonts) 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 groupe flow ;
  • layout.width, layout.height (dans [0, 4]) et toutes les longueurs de style : fractions de la hauteur de l'écran, pour garder les proportions à tout format. En 1920×1080, 0.05 vaut 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 .ttf ou .otf et 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 par burst), 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, puis id) ; 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>") vaut false, la fonction qui en dépend est sautée, et la page Mods affiche « feature "X" disabled: package Y missing » (feature nomme X ; sans lui : « features using Y disabled »).
  • required: true rend 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 : number et integer (min, max), boolean, string (maxLength, 256 au plus), color (#rgb, #rrggbb, #rrggbbaa), enum (values, 1 à 32), colors (couleurs par clé, 64 au plus). default est la valeur reçue quand l'utilisateur n'en donne pas. 16 éléments et 32 options au plus.
  • configure vé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çoit elements.configure ({ element, from, options }) après le script appelant. Configurer un paquet absent ne fait rien ; un paquet absent de uses lè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.font accepte "<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 un label (64 caractères au plus) et player: true pour être listée dans Réglages → Mods si l'élément n'a pas de place ; toutes les options d'un élément qui a x et y numériques (positions, tailles, visibilité, couleurs, décimales…) se règlent dans l'éditeur de skin, player ou 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 alors elements.configure avec from: "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 global elementOptions : seulement pour les options player: true d'un élément sans place (ni x ni y numé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_overrides les 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 le setup du mod : chaque paramètre prend les valeurs de min à max par pas de step (ici width = 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 tiers lè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 de tiers ne 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::revision augmente) 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. Sous wife3, les paliers n'ont pas de poids ; sinon chaque palier, Miss compris, en a un. osuCombined demande exactement 5 paliers de frappe.
  • continuous utilise 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.ts fournit un exemple complet sans paramètre.
  • separate produit 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 ; color reste 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, label de 1 à 32 caractères, short de 1 à 8 caractères, min < max, 0 < step ≤ max − min, default dans [min, max]. Une déclaration invalide fait échouer le setup du 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 parmi density et bpm.

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? }) : chartId est l'identifiant de chart de la bibliothèque (library.chartAdd, library.chartsAdded, tables des mods), limit de 1 à 100 (20 par défaut), offset de 0 à 100 000. Hors bornes, type faux ou champ inconnu : exception (validé en Rust). leaderboard.best({ chartId }) est query avec limit: 1 : le rang 1 du classement entier, replays importés compris (received).
  • Le retour est un numéro de demande, ou null quand 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 de crates/library ; le thread des mods ne l'attend jamais.
  • leaderboard.result arrive uniquement au mod demandeur : requestId, chartId, offset, total (taille du classement), entries, judgement (nom du jugement courant), performance ({ calculator, unit } ou null), unavailableReplays et error (message anglais, null si 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 (null pour un ancien replay sans nom : jamais attribué au joueur actuel), received, accuracy (pourcent), performance (null si 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.register et tabs.extend sont 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) et text (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), order de 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é de min à max par step est 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ême toMs avant de pouvoir jouer. Le fournisseur publie son dernier résultat personnalisé (custom : value, scrollMs ou error) ; 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.
  • context ne contient que travel, 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 f32 par 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::revision augmente). 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 ; label de 1 à 32 caractères, short de 1 à 8, min < max, 0 < step ≤ max − min, default dans [min, max], 4096 valeurs au plus. Une déclaration invalide fait échouer le setup du 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 }) (dans setup, 4 par mod) ajoute une carte sur la page Skins ; sources : folder et/ou archive. 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). source contient le skin.ini analysé (SkinIni : sections [Mania] fusionnées par Keys, colonnes indexées, couleurs #rrggbbaa, valeurs hors bornes refusées) et l'inventaire des images (SourceImage : nom normalisé sans @2x ni -N, dimensions, échelle, images d'animation, image vierge, couleur de crête), sans aucun octet. locale vaut en, fr ou zh.
  • skinImport.stage({ sourceId, skin, images }) → numéro de demande : l'hôte valide skin (un SkinDescription : PlayfieldSpec commun, une disposition par nombre de touches avec ses LaneSpec, places du HUD) avec les règles des skins, exécute les ImageOp (source, dest, animation ou frame, padTop/padBottom signés en pixels source, fit ou stretch, PNG ; une image sans transformation est copiée telle quelle) sur le fil natif skin-import, et répond par skinImport.staged : fichiers, tailles, budget de 900 Kio (overBudget, rapporté, pas imposé).
  • skinImport.create({ stageId }) → skinImport.created { id } : l'hôte génère un skin.ts lisible, 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 avec editSkin: "<id>" (un skin que ce mod a créé) ouvre l'éditeur de skin ; les autres arrivent au mod par skinImport.action { id }. skinImport.close() retire la carte ; « Fermer » envoie dismiss.

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éclaration defineMod ; 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 id quelle 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…) et node_modules sont 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.register ne déclare que des données (hôtes, modèles d'URL, chemins JSON), exige permissions: ["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_send dans 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 en Arc.

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", &params) {
    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 (avec from: 0) : repartir de rien (SceneDiff::full).
  • Un calque par skin ou mod (modId), empilé selon z (plus haut au-dessus ; le skin a toujours z 0, 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 sans parent). 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). src est 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 avec game.notes et placé avec game.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 -->

Source dans le dépôt du jeu : docs/modding.md