Design note written before the code (branch feature/mod-leaderboard-tabs). Two requests from the player:
- "Add leaderboard access in mods": mods can read the LOCAL leaderboard of a chart.
- "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
});
chartIdis the library's chart identifier (the one fromlibrary.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
nullwhen the limit is reached: 2 pending requests per mod, 16 across all mods. Same convention asmetron.performanceandratings.backfill. - The result arrives only at the requesting mod, through the
leaderboard.resultevent. A disabled or unloaded mod, or a replaced host, no longer receives it. - No call blocks: the
modsthread queues the request; acrates/libraryworker (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 inModHostOptions) and the channel of arrival to themodsthread (same mechanism asperformance_sender).crates/library/src/mod_scores.rs:ModScoreService(background pool with 1 thread, independent ofScoreService: 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 aScoreRequest, and callsLibrary::evaluate_page(new: one page of the full leaderboard, with the tier names).apps/desktop: the function provided toModHostOptionssends anEvent::ModLeaderboardto 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 toplimit(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/: themodTabsstore (sorted tabs with label and icon, extensions per tab and slot, values resolved for the selected chart) and thechart.panelaction, which now accepts the key of a published tab. No hard-coded mod identifier.SelectedMap.svelte:TabBarreceives the host tabs followed by the mod tabs; onetabpanelper 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.sveltealso renderstextandleaderboard: 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
- No permission for
leaderboard.*andtabs.*(local player data, read-only, bounded). - Result via the event
leaderboard.result(request number,nullif limited) rather than a promise: this is the pattern of the existing asynchronous calls; no SDK call is a promise. leaderboard.best(chartId)= rank 1 of the entire leaderboard (imported plays included,receivedflag). No separatepersonalBest: a mod filtersreceivedon the page it reads.- 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. - Mod tabs after those of the host, with
ordercomparing only mod tabs with each other. columnfields via a mod rating (no arbitrary table), because only ratings write to mod tables today.- Column values at the chosen rate for ratings that declare panels (like
Infos), otherwise base values; never multiplied by the rate.