On this page

← All documentation

Mods: the local leaderboard and song-select tabs

The local leaderboard and the song-select tabs, written by mods.

Design note written before the code (branch feature/mod-leaderboard-tabs). Two requests from the player:

  1. "Add leaderboard access in mods": mods can read the LOCAL leaderboard of a chart.
  2. "Add adding and editing of tabs for mods in song select": a mod adds a tab to the selected chart's panel and completes the existing tabs.

Both follow the project's rules: TypeScript runs in-process on the budgeted mods thread, every value that enters Rust is validated in Rust, no SQL, DOM, or raw IPC for scripts, the game thread is never blocked, and the UI is a generic render (never if (mod === …)).

What mods already do (starting point read from the code)

Mechanism Where Shape
Synchronous, bounded, lock-free read game.hits (hits.rs), game.notes, game.song the state is already in memory (atomic ring, Arcs published before the play)
Heavy work off the mods thread metron.performance, ratings.backfill (api.rs, library.rs) the host function returns a request number (null if the limit is reached), a crates/library service computes on its workers, the result comes back through a bounded channel and the mods thread emits it as a typed event to the requesting mod only (metron.performanceResult, ratings.progress)
Static declaration validated in Rust judgements.register, scrollSpeed.register, gameplay.register, ratings.register, library.filters.register call allowed during setup only (registry "closed" afterwards), published lock-free (ArcSwap) by ModHost::…(), relayed to the page by a HostEvent, withdrawn when the mod is disabled / uninstalled (withdraw_ratings)
Declarative panels RatingPanel (metrics, bars, radar, timeline) in ratings.rs, rendered by MapAnalysis.svelte bounds: 4 panels, 1 to 16 fields, titles of 1 to 64 characters, sources = numeric columns of the mod's table or neutral chart statistics
Permissions permissions: ["stage"] (manifest.rs) only for native drawing that the player can refuse mod by mod

1. Local leaderboard readable by mods

API

// in setup() or an event handler (never at load time)
const id = leaderboard.query({ chartId, limit: 20, offset: 0 }); // number | null
const best = leaderboard.best(chartId);                          // number | null  (= query limit 1)

ctx.on("leaderboard.result", (result) => {
  // result.requestId === id ; result.chartId, offset, total, entries, judgement, performance, error
});
  • chartId is the library's chart identifier (the one from library.chartAdd, library.chartsAdded, mod tables). A mod therefore has no file path.
  • limit: integer from 1 to 100 (default 20). offset: integer from 0 to 100,000. Out-of-range values or a wrong type: exception (validated in Rust). Only mods (not skins or map scripts) call it.
  • The return value is a request number, or null when the limit is reached: 2 pending requests per mod, 16 across all mods. Same convention as metron.performance and ratings.backfill.
  • The result arrives only at the requesting mod, through the leaderboard.result event. A disabled or unloaded mod, or a replaced host, no longer receives it.
  • No call blocks: the mods thread queues the request; a crates/library worker (background pool with 1 thread, ModScoreService) rereads the replays, rejudges them, and ranks them.

What an entry returns

It is the same leaderboard as the UI: each replay of the chart is rejudged with the player's CURRENT judgement (choose_judgement), the performance is that of the chosen calculator (the performanceCalculator setting, otherwise the one associated with the judgement), and the order is that of rank_rows (performance first when there is one, then accuracy, combo, misses, date). Abandoned replays do not count.

Field Meaning
rank global rank starting at 1 (offset + position + 1)
replayId replay identifier
playerName name recorded at the time of the play, or null: an older replay without a name is honestly "unknown", never attributed to the current player
received true: replay imported from a file (.pvreplay/.osr), the name is the file's
accuracy accuracy as a percentage (0–100) under the current judgement
performance, performanceNonstandard calculator value or null (not computable / no calculator); nonstandard = accuracy from a model other than the calculator's, never presented as official pp/SSR
maxCombo, misses from the rejudgement
tiers [{ name, count }] in the order of the current judgement's tiers, Miss included only once
rate recorded rate (never the current setting)
modified played with chart modifiers (mirror, random, LN…)
playedAtMs recording date (Unix ms)

The response also carries total (leaderboard size), judgement (name of the current judgement), performance ({ calculator, unit } or null), unavailableReplays, and error (raw English text, null if all is well; unknown chart, library or workers unavailable…).

No permission

The local leaderboard is the player's own data, read-only, bounded, and without any path or file. Existing reads (game.song, metron.*, tables) have no permission; the project's only permission (stage) protects native drawing that the player may want to turn off. I am therefore not adding one (point to validate below).

Host side

  • crates/modding/src/leaderboard.rs: types (LeaderboardQuery, LeaderboardResult, LeaderboardEntry…), bounds validation, LeaderboardBackend (Arc<dyn Fn(LeaderboardJob) -> bool> provided in ModHostOptions) and the channel of arrival to the mods thread (same mechanism as performance_sender).
  • crates/library/src/mod_scores.rs: ModScoreService (background pool with 1 thread, independent of ScoreService: the UI leaderboard has a single cache invalidated by revision, and a mod request must neither cancel nor replace it). It reads the chart's source (db::mod_tables::source), decodes the chart (Song::decode_variant), builds a ScoreRequest, and calls Library::evaluate_page (new: one page of the full leaderboard, with the tier names).
  • apps/desktop: the function provided to ModHostOptions sends an Event::ModLeaderboard to the main thread, which attaches the current settings (judgement, calculator) and hands it to the service: the main thread only clones settings.
  • No cache: each request rejudges the chart (like each selection in the UI). This is why the limit is 2 requests per mod.

2. Mod tabs in song select

Principle

Same principle as the note panels: the mod declares (typed, validated in Rust, bounded), and the interface renders generically. The mod provides no HTML, no CSS, and no code; the displayed values are resolved by the host for the selected chart, like ratingFields.

// only during setup(), after ratings.register if the fields read a column
tabs.register({
  id: "skills", title: "Skills", icon: "gauge", order: 10,
  panels: [
    { kind: "metrics", title: "Rating", fields: [
      { label: "Overall", source: { kind: "column", rating: "etterna-515", column: "overall" }, decimals: 2 },
      { label: "Plays",   source: { kind: "leaderboard", stat: "plays" }, decimals: 0 } ] },
    { kind: "leaderboard", title: "Best plays", limit: 5 },
    { kind: "text", title: "Note", text: "Computed on the base chart, rate 1.0." },
  ],
});

// extend a host tab, without removing or rewriting anything
tabs.extend({ tab: "info", slot: "bottom", order: 10, panels: [ /* … */ ] });

Panel vocabulary (TabPanel)

The four existing panels keep their shape and rendering (metrics, bars, radar, timeline): same bounds (1 to 16 fields, radar 3 to 12, 1 to 2 series, max scale in (0, 1,000,000], titles 1 to 64). Only two additions, because nothing existing covers "list of rows" or "text":

  • leaderboard { title, limit }: the top limit (1 to 10) plays of the selected chart, using the same already-evaluated data as the Leaderboard tab (rank, name or "unknown", accuracy, performance, rate): no additional query.
  • text { title?, text }: static text from the mod, 1 to 280 characters, with no markup or links.

Field sources (TabFieldSource):

  • { kind: "column", rating, column }: numeric column of the table of a rating declared by the same mod (ratings.register). The host already reads these values (ratingFields, and the version at the chosen rate), so the tab adds no read path and no SQL. Mod tables are currently populated only by rating computations, hence the reference to a rating rather than to an arbitrary table.
  • { kind: "chart", metric }: neutral chart statistic (already used by ratings).
  • { kind: "leaderboard", stat }: plays (leaderboard size), bestPerformance, bestAccuracy. Exact or "unavailable", never approximated: the best accuracy is only known if the leaderboard is ordered by accuracy (no calculator) or fits on the first page.

Adding a tab / modifying a tab

Call Effect Bounds
tabs.register({ id, title, icon?, order, panels }) one more tab, <mod>/<id> 4 tabs per mod, 12 in total, title 1 to 24 characters, icon from a closed list (same technique as mod-icons.ts), order 0 to 1000, 1 to 8 panels
tabs.extend({ tab, slot, order, panels }) additional panels in info, leaderboard or mods, slot: "top" (before the host content) or "bottom" (after) 4 extensions per mod, 1 to 4 panels each, 24 sections in total

Never any removal or rewriting: the host content is rendered as is, and mod panels are inserted before/after it in the scroll region. Mod tabs are placed after the host tabs (Info, Leaderboard, Mods, Training, Editor), sorted by (order, key). Calls outside setup, by a skin or a map script, duplicates, unknown column or rating, exceeded bounds: Rust error.

Lifecycle

Published like ratings (ModHost::tabs(), ArcSwap, revision) and relayed via HostEvent::ModTabs { revision, tabs, extensions }. When a mod is disabled, crashes, or is uninstalled, its tabs and extensions disappear (same withdraw as its ratings). If the displayed tab disappears, the page falls back to Info. A host restart first sends an empty list.

Interface

  • apps/web/src/menu/: the modTabs store (sorted tabs with label and icon, extensions per tab and slot, values resolved for the selected chart) and the chart.panel action, which now accepts the key of a published tab. No hard-coded mod identifier.
  • SelectedMap.svelte: TabBar receives the host tabs followed by the mod tabs; one tabpanel per mod tab; extensions are inserted into #chart-info, #chart-mods (regions that already scroll) and, for the Leaderboard, into two bounded strips above and below the list (the list remains the only region that scrolls).
  • MapAnalysis.svelte also renders text and leaderboard: the eight interfaces (themes/ folders, token data) inherit the rendering because everything goes through the existing classes and tokens (pv-plate, --pv-*).
  • Interface texts (empty states, accessibility labels) in en.json, fr.json, zh.json. Texts declared by the mod (titles, labels) remain its own: they do not go through the catalogs.
  • The development mock (apps/web/src/mock/) publishes demo tabs for the Playwright tests.

Decisions to validate

  1. No permission for leaderboard.* and tabs.* (local player data, read-only, bounded).
  2. Result via the event leaderboard.result (request number, null if limited) rather than a promise: this is the pattern of the existing asynchronous calls; no SDK call is a promise.
  3. leaderboard.best(chartId) = rank 1 of the entire leaderboard (imported plays included, received flag). No separate personalBest: a mod filters received on the page it reads.
  4. No example in mods/: the mods in this folder are shipped and active, so a demo tab would show up for every player. The examples are in this note and in TypeScript mods: the detailed guide; the tests use temporary mods.
  5. Mod tabs after those of the host, with order comparing only mod tabs with each other.
  6. column fields via a mod rating (no arbitrary table), because only ratings write to mod tables today.
  7. Column values at the chosen rate for ratings that declare panels (like Infos), otherwise base values; never multiplied by the rate.

Source in the game repository: docs/mods-leaderboard-et-onglets.md