Sur cette page

← Toute la documentation

Livre de recettes

Recettes complètes et testées contre le SDK : HUD, rendu natif, réglages, jeux de jugement, notes de difficulté.

Dix-sept recettes complètes, à copier telles quelles dans un main.ts (mod), skin.ts ou script.ts. Chaque bloc ```ts est vérifié avec tsc --strict contre mods/sdk/modding.d.ts par node scripts/check-modding-docs.mjs ; les valeurs et les motifs reprennent les mods livrés (mods/*/main.ts, examples/). Les ids de mods ci-dessous (cookbook.*) sont libres : changez-les. Le chemin de la ligne /// <reference …> est celui d'un mod placé dans le dossier mods/ du dépôt ; ailleurs, copiez mods/sdk/modding.d.ts à côté.

Rappel des règles qui comptent pour chaque recette : apiVersion: 2 ; une déclaration par fichier ; les *.register se font dans setup ; les longueurs de HUD sont des fractions de la hauteur de l'écran ; aucune API n'existe en dehors de Référence de l'API des mods (version 2).

1. Un jeu de jugement (judgements.register)

Un jeu ne décrit que ses paliers : le jeu juge nativement. Ici deux paramètres dérivent les fenêtres. Inspiré de mods/osu/main.ts et de examples/mods/judgement-colors.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.tight-judge",
  name: "Tight judge",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    judgements.register({
      id: "tight",
      name: "Tight",
      params: { width: { label: "Width", short: "W", min: 1, max: 3, step: 1, default: 2 } },
      accuracy: "weights",
      holds: "head",
      // Appelée une fois par valeur de `width` juste après setup : pure et synchrone.
      tiers: ({ width }) => [
        { id: "perfect", name: "Perfect", color: "#8cecff", windowMs: 10 * width, weight: 1 },
        { id: "great", name: "Great", color: "#93e69b", windowMs: 25 * width, weight: 0.7 },
        { id: "good", name: "Good", color: "#ffe38a", windowMs: 45 * width, weight: 0.3, breaksCombo: true },
        // Le dernier palier est le Miss ; sa fenêtre est la plus large.
        { id: "miss", name: "Miss", color: "#ff7185", windowMs: 70 * width, weight: 0, breaksCombo: true },
      ],
    });
  },
});

Pièges : fenêtres croissantes du plus serré au plus large ; id de palier en a-z 0-9 - ; le jeu apparaît sous <id du mod>/tight dans Réglages → Jugement.

2. Un fournisseur de vitesse de défilement (scrollSpeed.register)

toMs renvoie le temps de défilement en millisecondes (positif, fini). context.travel est la distance de référence en hauteurs d'écran. Calqué sur mods/scroll-osu/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.beat-scroll",
  name: "Beat scroll",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    scrollSpeed.register({
      id: "beats",
      name: "Beats",
      param: { label: "Beats", short: "B", min: 1, max: 8, step: 1, default: 4 },
      toMs: (beats, context) => beats * 150 * context.travel / 0.8,
    });
  },
});

3. Un élément de HUD par liaisons (hud.* + bind)

Le combo et la précision se mettent à jour sans passer par le script : le mod ne fait que placer les nœuds quand une partie commence.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.hello-hud",
  name: "Hello HUD",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songStart", () => {
      hud.group({
        id: "panel",
        flow: { direction: "column", align: "end", gap: 0.004 },
        layout: { x: 0.98, y: 0.5, anchor: "topRight" },
      });
      hud.text({ id: "combo", parent: "panel", text: "", bind: { kind: "combo" },
        animate: { on: "hit", kind: "pop", durationMs: 150 }, style: { size: 0.06, weight: 700 } });
      hud.text({ id: "acc", parent: "panel", text: "", bind: { kind: "accuracy", decimals: 2 },
        style: { size: 0.03, color: "#c8d2ff" } });
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

4. Une visualisation façon hit bar (game.hits, game.tick)

Dessine les derniers écarts de timing sur une barre ; relit l'historique natif seulement quand hitCount change. Version réduite de mods/hit-bar/main.ts (qui y ajoute bandes de fenêtres, options et pause).

/// <reference path="../sdk/modding.d.ts" />

const TICKS = 20;
let tiers: TierInfo[] = [];
let extent = 1;
let lastCount = -1;

const place = (offsetMs: number) => Math.max(0, Math.min(1, 0.5 + offsetMs / (2 * extent)));

function build(config: JudgementConfig) {
  tiers = config.tiers;
  const hitWindows = tiers.flatMap((tier) => (tier.earlyMs != null && tier.lateMs != null ? [tier.earlyMs, tier.lateMs] : []));
  extent = Math.max(1, ...hitWindows);
  hud.clear();
  lastCount = -1;
  hud.group({ id: "bar", layout: { x: 0.5, y: 0.45, width: 0.4, height: 0.04, anchor: "top" } });
  hud.box({ id: "track", parent: "bar", layout: { y: 0.4, width: 0.4, height: 0.012 }, style: { background: "#080b12b3" } });
  for (let index = 0; index < TICKS; index++) {
    hud.box({ id: `tick-${index}`, parent: "bar", visible: false,
      layout: { x: 0.5, y: 0.1, anchor: "top", width: 0.0015, height: 0.03 } });
  }
}

export default defineMod({
  id: "cookbook.mini-hit-bar",
  name: "Mini hit bar",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songStart", (start) => build(start.judgements));
    ctx.on("game.tick", (tick) => {
      if (tick.hitCount === lastCount) return;
      lastCount = tick.hitCount;
      const hits = game.hits({ limit: TICKS });
      for (let index = 0; index < TICKS; index++) {
        const hit = hits[index];
        if (!hit) { hud.update({ id: `tick-${index}`, visible: false }); continue; }
        hud.update({ id: `tick-${index}`, visible: true, layout: { x: place(hit.offsetMs) },
          style: { background: tiers[hit.tier].color } });
      }
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

5. Un élément configurable par les skins (provides.elements)

Fournir un widget que skin.ts (recette 11) place avec ctx.element(...).configure. Reçoit les options à chaque configuration (elements.configure). Motif commun à tous les mods de HUD livrés (mods/combo, mods/accuracy…).

/// <reference path="../sdk/modding.d.ts" />

const OPTIONS = {
  x: { type: "number", min: 0, max: 1, default: 0.5, label: "Horizontal position" },
  y: { type: "number", min: 0, max: 1, default: 0.9, label: "Vertical position" },
  anchor: { type: "enum", values: ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"], default: "bottom", label: "Anchor" },
  size: { type: "number", min: 0.005, max: 0.3, default: 0.03, label: "Size" },
  visible: { type: "boolean", default: true, label: "Show" },
  textColor: { type: "color", default: "#edf0f6", label: "Color" },
} satisfies Record<string, ElementOption>;

type Options = { x: number; y: number; anchor: Anchor; size: number; visible: boolean; textColor: string };
const declared: Record<string, ElementOption> = OPTIONS;
// Avant toute configuration, l'élément a ses valeurs par défaut déclarées.
let options = Object.fromEntries(Object.entries(declared).map(([name, option]) => [name, option.default])) as Options;
let playing = false;

function build() {
  hud.remove("title");
  if (!playing || !options.visible) return;
  hud.text({ id: "title", text: "", bind: { kind: "total" }, element: "timer",
    layout: { x: options.x, y: options.y, anchor: options.anchor },
    style: { size: options.size, color: options.textColor } });
}

export default defineMod({
  id: "cookbook.duration-badge",
  name: "Duration badge",
  version: "1.0.0",
  apiVersion: 2,
  provides: { elements: { badge: { root: "title", options: OPTIONS } } },
  setup() {
    ctx.on("elements.configure", (configure) => {
      if (configure.element !== "badge") return;
      options = configure.options as Options;
      build();
    });
    ctx.on("game.songStart", () => { playing = true; build(); });
    ctx.on("game.songEnd", () => { playing = false; hud.remove("title"); });
  },
});

6. Un modificateur de jeu (gameplay.register)

Un script ne crée pas de comportement : il déclare un genre que l'hôte implémente (auto, ghost, mirror, random, noLn, fullLn). Identique à mods/mirror/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.my-mirror",
  name: "My mirror",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    gameplay.register({
      id: "mirror",
      name: "Mirror",
      description: "Flips the columns left to right. Scored and recorded like any other play.",
      kind: "mirror",
      group: "layout",
      icon: "flip-horizontal-2",
    });
  },
});

7. Une action sur touche (controls.register)

L'action apparaît dans Réglages → Commandes sous le nom du mod. defaultKey est un code physique (KeyH…). Les événements peuvent se perdre sous charge : n'en dérivez pas d'état critique.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.toggle-hud",
  name: "Toggle HUD",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    controls.register({ id: "toggle", name: "Show or hide the combo", defaultKey: "KeyH" });
    let visible = true;
    ctx.on("game.songStart", () => {
      visible = true;
      hud.text({ id: "combo", text: "", bind: { kind: "combo" }, layout: { x: 0.5, y: 0.3, anchor: "center" }, style: { size: 0.08 } });
    });
    ctx.on("controls.action", (action) => {
      if (action.id !== "toggle" || !action.pressed) return;
      visible = !visible;
      hud.update({ id: "combo", visible });
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

8. Une table, une vue de difficulté avec panneaux, un filtre de bibliothèque

Un mod déclare sa table (tables.register), une vue qui lit sa colonne (ratings.register, avec panels) et un filtre de recherche (library.filters.register) sur la même colonne. Les valeurs viennent d'un calculateur natif (metron.catalog()), que ratings.backfill déclenche. D'après Notes de difficulté et tables des mods et mods/metron/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.quaver-stars",
  name: "Quaver stars",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    const calculator = metron.catalog().calculators.find((entry) => entry.id === "quaver-2025");
    if (!calculator) return; // calculateur absent : le mod ne déclare rien
    tables.register({ id: "quaver_rating", columns: [{ name: "value", kind: "number", indexed: true }] });
    ratings.register({
      id: "stars", name: "Quaver stars", calculator: calculator.id, unit: "★",
      table: "quaver_rating", column: "value", version: calculator.version,
      panels: [
        { kind: "metrics", title: "Chart profile", fields: [
          { label: "Stars", source: { kind: "column", column: "value" }, unit: "★", decimals: 2 },
          { label: "Long notes", source: { kind: "chart", metric: "holdPercent" }, unit: "%", decimals: 1 },
        ] },
        { kind: "timeline", title: "Timeline", series: [{ label: "Density", source: "density", unit: "NPS" }] },
      ],
    });
    library.filters.register({
      id: "stars", name: "Quaver stars", table: "quaver_rating", column: "value",
      version: calculator.version, unit: "★",
    });
    // Calcule les charts manquants ou périmés : au démarrage, puis à chaque import.
    ctx.on("library.ready", () => { ratings.backfill({ id: "stars" }); });
    ctx.on("library.chartsAdded", (added) => { ratings.backfill({ id: "stars", charts: added.chartIds }); });
    ctx.on("ratings.progress", (progress) => {
      if (progress.id === "stars" && progress.error) log.warn(progress.error);
    });
  },
});

Pièges : la table et la colonne doivent appartenir à ce mod ; la colonne doit être number pour une vue ; version doit égaler calculator.version ; une valeur indisponible s'affiche « — » (jamais un zéro inventé).

9. Un effet natif avec stage.* (permission)

Des étincelles à l'impact, calées à l'image près. Exige permissions: ["stage"]. Tout est déclaré une fois : le rendu joue animations et déclencheurs. D'après examples/mods/hit-bursts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.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" });
    });
  },
});

10. Un mod qui garde un record (storage.*)

Fichier JSON propre au mod, conservé entre les parties (et à la désinstallation).

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.best-combo",
  name: "Best combo",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songEnd", (end) => {
      if (end.aborted) return;
      const best = Number(storage.get("bestCombo") ?? 0);
      if (end.maxCombo > best) {
        storage.set({ key: "bestCombo", value: end.maxCombo });
        log.info(`new best combo: ${end.maxCombo}`);
      }
    });
  },
});

11. Un skin qui configure des éléments (defineSkin + ctx.element)

Un skin place le playfield natif et configure les éléments que des mods fournissent. Dans skins/<dossier>/skin.ts. Les ids d'éléments et leurs options sont ceux des mods livrés (mods/combo, mods/accuracy, mods/judgement-display) ; ctx.has évite d'échouer si le mod est absent. D'après skins/default/skin.ts.

/// <reference path="../../mods/sdk/modding.d.ts" />

export default defineSkin({
  id: "cookbook.neon",
  name: "Cookbook Neon",
  version: "1.0.0",
  apiVersion: 2,
  uses: {
    "pvng.combo": { version: "^1", feature: "Combo" },
    "pvng.accuracy": { version: "^1", feature: "Accuracy" },
  },
  // `ctx` est le global de `ctx.on` : le contexte de lancement s'appelle `play`.
  setup(play: PlayContext) {
    playfield.set({ x: 0.5, y: 0.5, anchor: "center", laneWidth: 0.07, laneGap: 0.004, noteColor: "#ff4080", scroll: "down" });
    if (play.columns >= 7) playfield.set({ laneWidth: 0.06 });
    if (ctx.has("pvng.combo")) {
      ctx.element("pvng.combo/combo").configure({ x: 0.5, y: 0.3, anchor: "center", size: 0.08, textColor: "#ffffff" });
    }
    if (ctx.has("pvng.accuracy")) {
      ctx.element("pvng.accuracy/accuracy").configure({ x: 0.98, y: 0.02, anchor: "topRight", size: 0.035, decimals: 2 });
    }
  },
});

12. Un script de map (defineChart)

À placer à côté du chart : script.ts (toutes les difficultés) ou <fichier du chart>.script.ts. Visuel seulement ; pas de HUD ni de stockage. Chaque changement est une transition animée par le rendu : le script ne tourne qu'à chaque temps. Version réduite de examples/map-scripts/first-light/script.ts.

/// <reference path="../../../mods/sdk/modding.d.ts" />

const LANE = "#454d61";
const DOWNBEAT = "#a67cff";
const BEAT = "#7486b8";

export default defineChart({
  apiVersion: 2,
  setup(play: PlayContext) {
    playfield.set({ laneColor: LANE });
    let side = 1;
    ctx.on("game.beat", (beat: Beat) => {
      const beatMs = 60000 / beat.bpm;
      if (beat.meterBeat === 0) {
        side = -side;
        playfield.update({ patch: { x: 0.5 + side * 0.05, rotation: side * 3 }, transitionMs: Math.min(10000, 4 * beatMs), easing: "easeInOut" });
      }
      // Un éclat tout de suite, puis un retour avant le temps suivant.
      playfield.update({ patch: { laneColor: beat.meterBeat === 0 ? DOWNBEAT : BEAT } });
      playfield.update({ patch: { laneColor: LANE }, transitionMs: 0.9 * beatMs, easing: "easeIn" });
    });
    log.info(`map script for ${play.song.title} (${play.columns}K)`);
  },
});

13. Lire le classement local (leaderboard.best)

Lecture asynchrone : leaderboard.best renvoie un numéro de demande (ou null quand 2 demandes du mod sont déjà en attente) et la réponse arrive par leaderboard.result, au mod demandeur seulement. Le chartId est celui de la bibliothèque (library.chartAdd, library.chartsAdded) : game.song() n'en porte pas (voir LACUNES.md), donc un HUD « record du chart en cours » n'est pas faisable avec ces seules fonctions ; pour un affichage lié au chart sélectionné, utilisez un onglet déclaratif (recette 14). Ici : un petit popup de menu avec le meilleur score du premier chart d'un import.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.best-reader",
  name: "Best reader",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("library.chartsAdded", (added) => {
      const chartId = added.chartIds[0];
      if (chartId === undefined) return;
      // null : trop de demandes en attente, on abandonne (pas de nouvelle tentative automatique).
      if (leaderboard.best({ chartId }) === null) log.warn("leaderboard busy");
    });
    ctx.on("leaderboard.result", (result) => {
      if (result.error) { log.warn(result.error); return; }
      const best = result.entries[0];
      if (!best) return; // aucun score local sur ce chart
      const performance = best.performance == null ? "—" : `${best.performance.toFixed(2)} ${result.performance?.unit ?? ""}`;
      ui.popup({
        title: "Best local play",
        // playerName est null pour un ancien replay sans nom : ne l'attribuez pas au joueur actuel.
        detail: `#${best.rank} ${best.playerName ?? "unknown"} · ${best.accuracy.toFixed(2)}% · ${performance} (${result.judgement})`,
        done: 1, total: 1,
        actions: [],
      });
    });
    ctx.on("ui.action", (action) => { if (action.id === "dismiss") ui.dismiss(); });
  },
});

14. Un onglet de sélection avec panneaux (tabs.register, tabs.extend)

Déclaratif : des panneaux typés dont l'hôte lit les valeurs (colonne d'une vue de difficulté du même mod, statistique du chart, classement). Un onglet apparaît après Info, Leaderboard, Mods… ; tabs.extend ajoute des sections aux onglets existants. D'après Onglets dans la sélection : tabs.*.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.skills-tab",
  name: "Skills tab",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    const calculator = metron.catalog().calculators.find((entry) => entry.id === "etterna-515");
    if (!calculator) return;
    // La colonne lue par un champ `column` doit venir d'une vue `ratings.register` de CE mod.
    tables.register({ id: "skills_rating", columns: [{ name: "stream", kind: "number", indexed: true }] });
    ratings.register({
      id: "skills", name: "Skills", calculator: calculator.id, unit: "MSD",
      table: "skills_rating", column: "stream", version: calculator.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 },
          { label: "Best accuracy", source: { kind: "leaderboard", stat: "bestAccuracy" }, unit: "%", decimals: 2 },
        ] },
        { kind: "leaderboard", title: "Best plays", limit: 5 },
        { kind: "text", title: "Note", text: "Values of the base chart." },
      ],
    });
    // Une bande sous le contenu de l'onglet Info de l'hôte.
    tabs.extend({ tab: "info", slot: "bottom", panels: [{ kind: "text", text: "Added by Skills tab." }] });
    ctx.on("library.ready", () => { ratings.backfill({ id: "skills" }); });
  },
});

15. Une source de téléchargement déclarative (downloads.register)

Permission network. Le script déclare seulement des données (modèles d'URL, chemins JSON, hôtes autorisés, cadence) ; le jeu fait tout le HTTP. Les hôtes example.org sont fictifs : remplacez-les par un service que vous avez le droit d'utiliser. Version réduite de examples/mods/download-sources (branche feature/mod-download-sources).

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.maps-source",
  name: "Maps source",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["network"],
  setup() {
    // Un miroir de plus pour les beatmapsets osu! (réponses au format osu! API v2).
    downloads.register({
      id: "mirror", name: "Example mirror", kind: "mirror", target: "osu",
      hosts: ["mirror.example.org"],
      search: {
        url: "https://mirror.example.org/api/search",
        params: { query: "{query}[ cs>={keysMin}]", amount: "{limit}", offset: "{offset}" },
        paging: { kind: "offset", size: 20 },
        response: { format: "osu" },
      },
      download: { url: "https://mirror.example.org/d/{id}" },
    });
    // Un onglet de plus dans Télécharger, réponses lues champ par champ.
    downloads.register({
      id: "maps", name: "Example maps", kind: "source",
      hosts: ["api.example.org", "files.example.org"],
      rateLimit: 60,
      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}", order: "{sort}", offset: "{offset}", limit: "{limit}" },
        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", creator: "mapper",
            difficulties: { path: "charts", name: "name", keys: "keys", stars: "stars", only: { path: "mode", equals: "mania" } } },
        },
      },
      download: { urlField: "files.zip" },
    });
  },
});

Pièges : hôtes exacts en minuscules (pas de schéma, port, joker ni adresse IP) ; {id} seulement dans download.url ; les URL lues dans une réponse doivent rester sur un hôte déclaré ; les clés JSON avec un point ne sont pas décrivables.

16. Un pont d'API écrit par le mod (downloads.registerBridge)

Quand l'API ne se décrit pas en données (ici : recherche en POST, réponse XML, puis une requête de détail pour trouver le fichier). Trois fonctions pures : elles renvoient la prochaine étape (BridgeStep), l'hôte fait la requête et rappelle onResponse. XML et HTML arrivent en texte (pas de parseur DOM). Version réduite de examples/mods/download-bridge.

/// <reference path="../sdk/modding.d.ts" />

interface State { page?: number; detailOf?: string }

export default defineMod({
  id: "cookbook.maps-bridge",
  name: "Maps bridge",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["network"],
  setup() {
    downloads.registerBridge({
      id: "maps", name: "Example maps",
      hosts: ["maps.example.org", "files.example.org"],
      auth: { kind: "token", header: "Authorization", scope: "download" },
      search(query, page): BridgeStep {
        return {
          request: { url: "https://maps.example.org/cgi/find", method: "POST", form: { terms: query, page: String(page) }, expect: "xml" },
          state: { page: Number(page) },
        };
      },
      onResponse(response, state): BridgeStep {
        const carried = (state ?? {}) as State;
        if (response.status !== 200) return { error: `the site answered ${response.status}` };
        if (carried.detailOf !== undefined) {
          const detail = JSON.parse(response.text) as { file?: string };
          if (!detail.file) return { error: "this map has no file" };
          return { download: { url: `https://files.example.org${detail.file}`, format: "osz" } };
        }
        const rows = [...response.text.matchAll(/<map id="(\d+)"><title>([^<]*)<\/title><artist>([^<]*)<\/artist><\/map>/g)];
        return {
          results: rows.map((row) => ({ id: row[1], title: row[2], artist: row[3], actions: [{ id: "download", label: "Download" }] })),
          nextPage: rows.length >= 2 ? (carried.page ?? 1) + 1 : undefined,
          state: carried,
        };
      },
      action(result): BridgeStep {
        return { request: { url: `https://maps.example.org/maps/${result.id}.json`, expect: "json" }, state: { detailOf: result.id } };
      },
    });
  },
});

Pièges : une exception, un résultat que JSON ne sait pas écrire ou un dépassement de budget désactive le pont ; une étape invalide (champ inconnu, hôte non déclaré, en-tête interdit) est une erreur affichée sans le désactiver ; 6 requêtes au plus par recherche ou action ; le jeton n'est jamais visible (remplacé par [token] dans les réponses).

17. Convertir un skin osu! (skinImport.*) : note d'usage

Permission skinImport, mods seulement. C'est un flux d'événements : le joueur choisit une source depuis la carte de votre importeur (page Skins) → skinImport.opened (le skin.ini analysé et l'inventaire des images, sans aucun octet) → vous calculez une SkinDescription et des ImageOp → skinImport.stage → skinImport.staged (rapport, budget de 900 Kio rapporté mais non imposé) → skinImport.create → skinImport.created (id du skin écrit). Le mod n'écrit rien lui-même ; la carte d'état se décrit avec skinImport.panel. Un vrai convertisseur est long : l'exemple complet est mods/skin-converter (main.ts, convert.ts, strings.ts) ; ci-dessous le squelette du flux (la description produite est volontairement vide : elle montre les appels, pas un skin utile).

/// <reference path="../sdk/modding.d.ts" />

let sourceId = 0;

export default defineMod({
  id: "cookbook.skin-import",
  name: "Skin import skeleton",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["skinImport"],
  setup() {
    skinImport.register({ id: "skeleton", name: "Skeleton importer", sources: ["folder", "archive"] });
    ctx.on("skinImport.opened", (opened) => {
      if (opened.error || !opened.source) {
        skinImport.panel({ title: "Skeleton importer", status: "error", detail: opened.error ?? "empty source" });
        return;
      }
      sourceId = opened.source.sourceId;
      skinImport.panel({
        title: opened.source.ini?.general.name ?? opened.source.label,
        status: "ready",
        sections: [{ heading: "Source", rows: [{ label: "Images", value: String(opened.source.images.length) }] }],
        actions: [{ id: "convert", label: "Convert", primary: true }],
      });
    });
    ctx.on("skinImport.action", (action) => {
      if (action.id === "dismiss") { skinImport.close(); return; }
      if (action.id !== "convert") return;
      skinImport.panel({ title: "Skeleton importer", status: "working" });
      skinImport.stage({ sourceId, skin: { id: "skeleton-skin", name: "Skeleton skin", layouts: [{ keys: 4, lanes: [] }] }, images: [] });
    });
    ctx.on("skinImport.staged", (staged) => {
      if (staged.error || staged.stageId == null) {
        skinImport.panel({ title: "Skeleton importer", status: "error", detail: staged.error ?? "nothing staged" });
        return;
      }
      skinImport.create({ stageId: staged.stageId });
    });
    ctx.on("skinImport.created", (created) => {
      skinImport.panel({
        title: "Skeleton importer",
        status: created.error ? "error" : "done",
        detail: created.error ?? `Created ${created.id}`,
        actions: created.id ? [{ id: "edit", label: "Edit skin", editSkin: created.id }] : [],
      });
    });
  },
});

Ce que les recettes ne couvrent pas

Rien ici n'invente d'API. Pour un besoin qui n'est pas dans Référence de l'API des mods (version 2) (jouer un son, lire ou écrire les réglages, un HUD du record du chart en cours, remplir sa propre table…), voir LACUNES.md : dites que l'API ne le permet pas plutôt que de la deviner.

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