Seventeen complete recipes, ready to copy as-is into a main.ts (mod),
skin.ts or script.ts. Every ```ts block is checked with tsc --strict
against mods/sdk/modding.d.ts by node scripts/check-modding-docs.mjs; the
values and patterns are taken from the bundled mods (mods/*/main.ts,
examples/). The mod ids below (cookbook.*) are free-form: change them.
The path in the /// <reference …> line is that of a mod placed in the
repository's mods/ folder; elsewhere, copy mods/sdk/modding.d.ts next to it.
A reminder of the rules that matter for every recipe: apiVersion: 2; one
declaration per file; *.register calls go in setup; HUD lengths
are fractions of the screen height; no API exists outside
Mod API reference (version 2).
1. A judgement set (judgements.register)
A set only describes its tiers: the game judges natively. Here two parameters
derive the windows. Inspired by mods/osu/main.ts and
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",
// Called once per value of `width` right after setup: pure and synchronous.
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 },
// The last tier is the Miss; its window is the widest.
{ id: "miss", name: "Miss", color: "#ff7185", windowMs: 70 * width, weight: 0, breaksCombo: true },
],
});
},
});
Pitfalls: windows increase from the tightest to the widest; tier ids use
a-z 0-9 -; the set appears as <mod id>/tight in Settings → Judgement.
2. A scroll speed provider (scrollSpeed.register)
toMs returns the scroll time in milliseconds (positive, finite).
context.travel is the reference distance in screen heights. Modeled on
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. A HUD element with bindings (hud.* + bind)
The combo and accuracy update without going through the script: the mod only places the nodes when a play starts.
/// <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. A hit bar-style visualization (game.hits, game.tick)
Draws the latest timing offsets on a bar; re-reads the native history
only when hitCount changes. A reduced version of mods/hit-bar/main.ts
(which adds window bands, options and pause handling).
/// <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. A skin-configurable element (provides.elements)
Provide a widget that skin.ts (recipe 11) places with ctx.element(...).configure.
It receives the options on every configuration (elements.configure). A pattern common to
all the bundled HUD mods (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;
// Before any configuration, the element has its declared default values.
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. A gameplay modifier (gameplay.register)
A script does not create behavior: it declares a kind that the host
implements (auto, ghost, mirror, random, noLn, fullLn). Identical to
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. A key-bound action (controls.register)
The action appears in Settings → Controls under the mod's name. defaultKey is a
physical code (KeyH…). Events can be dropped under load: do not
derive any critical state from them.
/// <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. A table, a difficulty view with panels, a library filter
A mod declares its table (tables.register), a view that reads its column
(ratings.register, with panels) and a search filter
(library.filters.register) on the same column. The values come from a native
calculator (metron.catalog()), which ratings.backfill triggers. Based on
Difficulty ratings and mod tables and 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; // calculator missing: the mod declares nothing
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: "★",
});
// Computes missing or stale charts: at startup, then on every 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);
});
},
});
Pitfalls: the table and column must belong to this mod; the column must
be number for a view; version must equal calculator.version; an unavailable
value is displayed as "—" (never an invented zero).
9. A native effect with stage.* (permission)
Sparks on impact, frame-accurate. Requires permissions: ["stage"].
Everything is declared once: the renderer plays animations and triggers. Based on
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. A mod that keeps a record (storage.*)
A JSON file specific to the mod, kept between plays (and when uninstalling).
/// <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. A skin that configures elements (defineSkin + ctx.element)
A skin places the native playfield and configures the elements that mods
provide. In skins/<folder>/skin.ts. The element ids and their options
are those of the bundled mods (mods/combo, mods/accuracy, mods/judgement-display);
ctx.has avoids failing if the mod is absent. Based on 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` is the global of `ctx.on`: the launch context is called `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. A map script (defineChart)
Place it next to the chart: script.ts (all difficulties) or
<chart file>.script.ts. Visual only; no HUD or storage. Each
change is a transition animated by the renderer: the script only runs
on each beat. Reduced version of 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" });
}
// A flash right away, then a return before the next beat.
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. Reading the local leaderboard (leaderboard.best)
Asynchronous read: leaderboard.best returns a request number (or null
when 2 requests from the mod are already pending) and the response arrives via
leaderboard.result, to the requesting mod only. The chartId is the library's
(library.chartAdd, library.chartsAdded): game.song() does not carry one
(see LACUNES.md), so a "record for the current chart" HUD is not feasible with
these functions alone; for a display tied to the selected chart, use a
declarative tab (recipe 14). Here: a small menu popup with the best score of the
first chart of an 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: too many pending requests, give up (no automatic retry).
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; // no local score on this chart
const performance = best.performance == null ? "—" : `${best.performance.toFixed(2)} ${result.performance?.unit ?? ""}`;
ui.popup({
title: "Best local play",
// playerName is null for an old replay without a name: do not attribute it to the current player.
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. A selection tab with panels (tabs.register, tabs.extend)
Declarative: typed panels whose values the host reads (a column from a difficulty
view of the same mod, a chart statistic, a leaderboard). A tab appears
after Info, Leaderboard, Mods… ; tabs.extend adds sections to the existing
tabs. Based on Tabs in the selection screen: 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;
// The column read by a `column` field must come from a `ratings.register` view of THIS 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." },
],
});
// A strip below the content of the host's Info tab.
tabs.extend({ tab: "info", slot: "bottom", panels: [{ kind: "text", text: "Added by Skills tab." }] });
ctx.on("library.ready", () => { ratings.backfill({ id: "skills" }); });
},
});
15. A declarative download source (downloads.register)
network permission. The script only declares data (URL templates,
JSON paths, allowed hosts, rate); the game does all the HTTP. The
example.org hosts are fictional: replace them with a service you are allowed
to use. A reduced version of examples/mods/download-sources (branch
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() {
// One more mirror for osu! beatmapsets (responses in osu! API v2 format).
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}" },
});
// One more tab in Download, responses read field by field.
downloads.register({
id: "maps", name: "Example maps", kind: "source",
hosts: ["api.example.org", "files.example.org"],
rateLimit: 60,
auth: { kind: "token", scope: "download" }, // token entered by the player, never given to the script
search: {
url: "https://api.example.org/v1/search",
params: { q: "{query}", 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" },
});
},
});
Pitfalls: exact lowercase hosts (no scheme, port, wildcard, or IP address);
{id} only in download.url; URLs read from a response must
stay on a declared host; JSON keys containing a dot cannot be described.
16. A mod-written API bridge (downloads.registerBridge)
When the API cannot be described as data (here: a POST search, an XML response, then
a detail request to find the file). Three pure functions: they
return the next step (BridgeStep), the host makes the request and calls
onResponse back. XML and HTML arrive as text (no DOM parser). A trimmed-down
version of 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 } };
},
});
},
});
Pitfalls: an exception, a result that JSON cannot serialize, or exceeding
the budget disables the bridge; an invalid step (unknown field, undeclared
host, forbidden header) is an error that is displayed without disabling the bridge; at most 6 requests
per search or action; the token is never visible (replaced by
[token] in responses).
17. Converting an osu! skin (skinImport.*): usage note
skinImport permission, mods only. It is an event flow: the player
picks a source from your importer's card (Skins page) →
skinImport.opened (the parsed skin.ini and the image inventory, with no bytes at all) →
you compute a SkinDescription and ImageOps →
skinImport.stage → skinImport.staged (report, 900 KiB budget reported but
not enforced) → skinImport.create → skinImport.created (id of the skin written). The mod
writes nothing itself; the status card is described with skinImport.panel. A real
converter is long: the complete example is mods/skin-converter
(main.ts, convert.ts, strings.ts); below is the skeleton of the flow (the
description produced is deliberately empty: it shows the calls, not a useful
skin).
/// <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 }] : [],
});
});
},
});
What the recipes do not cover
Nothing here invents an API. For a need that is not in Mod API reference (version 2) (playing a sound, reading or writing settings, a HUD showing the record for the current chart, filling your own table…), see LACUNES.md: say that the API does not allow it rather than guessing.