Record the phase 5 ScoreSaber merge and map OST hashes in the sync

ScoreSaber names official levels "ost_<LevelId>" where BeatLeader uses
"<levelid>"; ssMapHash applies that for leaderboard snapshots and maps.
The plan records the merge results and where it differs from the steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 14:49:55 -08:00
co-authored by Claude Opus 5.5
parent efaa2373dc
commit d0d746ec7f
3 changed files with 63 additions and 4 deletions
+51
View File
@@ -733,6 +733,57 @@ profile and scores, and a replay opens in ArcViewer.
### Phase 5: Convert ScoreSaber scores and merge
**Built and checked on a scratch copy 2026-11-09; committed, deploy
pending.** Server `50f7df6` (`Tools/ArchiveImporter/ScoreMerge.cs`, rules in
the fork's `MIRROR.md`), website `5522f245` (SS badge, `/watch/` for `.dat`),
exporter `ssMapHash` in this repo. All pushed. Result on the local archive: 3,710 leaderboards with one score
each (3,566 BL + 141 SS-only BL leaderboards + 3 DLC levels BL lacks). Winners: 3,564 BL main,
1 BL legacy, 139 SS legacy, 6 SS main; 2,022 SS scores were the same play as
a BL score and dropped; 20 BL scores got an improvement recomputed against an
earlier SS play. An unchanged rerun writes 0 rows. What differs from the
steps below:
- Step 1: **no new columns.** Provenance goes in `Platform`
(`scoresaber,<SS player id>`), and `Id` ≥ 1e9 marks SS scores. Columns
would have needed a models-fork change, a migration, and edits to the ~90
score projections before the website saw them; `Platform` already reaches
every score response.
- Step 2: OST levels are `ost_<LevelId>` on SS and `<levelid>` on BL; 5 of 8
resolve that way. Me Porto Bonito, Nuthin' But A "G" Thang and Stressed Out
(3 scores) aren't on BL (404), so the importer builds a song and leaderboard
from the SS item (`<LevelId><diff><mode>` ids, no ratings, accuracy 0).
The exporter now maps `ost_` hashes too (`scoresaber.ts` `ssMapHash`).
HMD and controller come from SS `deviceHmd`/`deviceControllerLeft`. SS
replays are served from `storage/ssreplays` → `archive/scoresaber`, so the
URL is `/cdn/ssreplays/<player>/replays/<id>.dat`.
- **Pp isn't upstream's.** Neither the fork's `rebalanced-pp` formula
(median 0.94x, range 0.69–1.16x) nor upstream `master`'s (0.81x)
reproduces the pp on archived BL scores; live BL's code is private. So the
fold compares every candidate by **locally computed** pp, which keeps the
order right, and BL winners keep upstream's pp. The 70 SS winners on
ranked maps show local pp, and improvement pp deltas are local on both
sides. Matching pp first sent two BL scores (KillerToy, Make Me) to worse
SS plays.
- Step 3: same-play duplicates (equal base score, ≤ 2 min apart; observed
−30 s…+9 s) keep the BL copy. Each upstream `scoreImprovement` is turned
back into the best it replaced and added as a candidate, so upstream's
improvement stays exactly when that rebuilt best is also the fold's previous
best. Older archived BL bests are candidates too.
- Step 5: the badge sits next to the rank in both `ScoreRank.svelte` (rank 0
already renders "-"). `/watch/` takes the map hash from the score
(`/score/{id}` → `song.hash`) for `.dat` replays, from `?scoreId=` or
1e9 + the id in the filename. Expanding an SS row shows its leaderboard and
no stats tab; the page logs a JSON error for the 404 score stats, harmless.
- Check: The Goddess Figure (Normal) is the case where a 2022 legacy SS
play beats the BL score; it shows with the SS badge, and the BL Hard score
there now reads "+76,821 vs 4 years ago" (vs the legacy SS play). An SS
replay redirects to ArcViewer with the `.dat` and local zip and loads the map;
watching it play still needs a real browser.
To deploy: nix-pleb bumps `plebleader-server`, `plebleader-website` and
`plebsaber-stream` (sync CLI); switch. The next `plebleader-sync`
run re-imports with the merge.
ScoreSaber scores become BeatLeader `Score` rows, and each leaderboard keeps
exactly one score. BeatLeader already shows how a score compares with the
previous best (`ScoreImprovement`), so after the merge that comparison covers
+3 -3
View File
@@ -21,7 +21,7 @@ import * as path from 'node:path';
import { ARCHIVE_DIR, BL_API, DEFAULT_MAPS_SINCE, LEGACY_PLAYER_ID, MAIN_PLAYER_ID, PLAYER_IDS, TOKENS_FILE, USER_AGENT, loadDotEnv, parseSince } from './config.ts';
import { getAccessToken, login, readTokens, refresh } from './auth.ts';
import { archivedScores, blHashes, exportLeaderboards, exportProfile, exportReplays, exportScoreStats, exportScores, newestScoreTime } from './beatleader.ts';
import { archivedSSScores, exportSSPlayer, exportSSReplays, exportSSScores, newestSSScoreTime, ssHashes } from './scoresaber.ts';
import { archivedSSScores, exportSSPlayer, exportSSReplays, exportSSScores, newestSSScoreTime, ssHashes, ssMapHash } from './scoresaber.ts';
import { exportMaps } from './beatsaver.ts';
import { archivePath, exists, listFiles, readState, updateState } from './store.ts';
@@ -83,7 +83,7 @@ function playedHashes(since: number): string[] {
function mapHashes(mapsSince: number): string[] {
const legacyWithReplay = archivedSSScores(LEGACY_PLAYER_ID, 0)
.filter((it) => exists(archivePath('scoresaber', LEGACY_PLAYER_ID, 'replays', `${it.score.id}.dat`)))
.map((it) => it.leaderboard.songHash.toLowerCase());
.map(ssMapHash);
return [...new Set([...playedHashes(mapsSince), ...legacyWithReplay])].sort();
}
@@ -137,7 +137,7 @@ async function sync(opts: { ratings: boolean; ssReplays: boolean }): Promise<num
await exportSSPlayer(MAIN_PLAYER_ID);
const ssSince = Math.max(0, newestSSScoreTime(MAIN_PLAYER_ID) - SYNC_OVERLAP_S);
const newSS = await exportSSScores(MAIN_PLAYER_ID, ssSince);
const newSSHashes = [...new Set(newSS.map((it) => it.leaderboard.songHash.toLowerCase()))].sort();
const newSSHashes = [...new Set(newSS.map(ssMapHash))].sort();
await exportLeaderboards(newSSHashes.filter((h) => !newBLHashes.includes(h)));
if (opts.ssReplays) await exportSSReplays(MAIN_PLAYER_ID, ssSince);
+9 -1
View File
@@ -102,6 +102,14 @@ function seedFromScraper(playerId: string, items: SSItem[]): void {
if (copied) console.log(` copied ${copied} replays from ${dir}`);
}
/**
* The map hash BeatLeader and the archive use for a ScoreSaber item: lowercase, and for official
* levels the level id without ScoreSaber's "ost_" prefix (BeatLeader: "beatsaber", SS: "ost_BeatSaber").
*/
export function ssMapHash(it: SSItem): string {
return it.leaderboard.songHash.toLowerCase().replace(/^ost_/, '');
}
export function ssHashes(playerId: string, sinceUnix = 0): string[] {
return [...new Set(archivedSSScores(playerId, sinceUnix).map((it) => it.leaderboard.songHash.toLowerCase()))].sort();
return [...new Set(archivedSSScores(playerId, sinceUnix).map(ssMapHash))].sort();
}