feat: kill tis (#1528)

This commit is contained in:
zk
2026-05-20 19:54:10 +01:00
committed by GitHub
parent 7232308bce
commit 00d76747e2
11 changed files with 1092 additions and 25 deletions
+8
View File
@@ -107,6 +107,7 @@
"react-router-dom": "catalog:",
"react-select": "catalog:",
"rg-stats": "catalog:",
"sql.js": "^1.14.1",
"sync-fetch": "catalog:",
"tachi-common": "workspace:*",
"vite-plugin-html": "catalog:",
@@ -119,6 +120,7 @@
"@types/react-dom": "catalog:",
"@types/react-router-bootstrap": "catalog:",
"@types/react-router-dom": "catalog:",
"@types/sql.js": "^1.4.11",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"bootstrap-scss": "catalog:",
@@ -1210,6 +1212,8 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint__js": ["@types/eslint__js@8.42.3", "", { "dependencies": { "@types/eslint": "*" } }, "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw=="],
@@ -1288,6 +1292,8 @@
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="],
"@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="],
"@types/supertest": ["@types/supertest@2.0.11", "", { "dependencies": { "@types/superagent": "*" } }, "sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q=="],
@@ -2636,6 +2642,8 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
+3 -1
View File
@@ -176,7 +176,9 @@
"codemirror": "^6.0.2",
"comlink": "^4.4.2",
"fast-json-patch": "^3.1.1",
"cron-parser": "5.0.2"
"cron-parser": "5.0.2",
"@types/sql.js": "^1.4.11",
"sql.js": "^1.14.1"
},
"overrides": {
"@types/node": "20.19.37",
+3 -1
View File
@@ -62,6 +62,7 @@
"react-router-dom": "catalog:",
"react-select": "catalog:",
"rg-stats": "catalog:",
"sql.js": "catalog:",
"sync-fetch": "catalog:",
"tachi-common": "workspace:*",
"vite-plugin-html": "catalog:"
@@ -74,11 +75,12 @@
"@types/react-dom": "catalog:",
"@types/react-router-bootstrap": "catalog:",
"@types/react-router-dom": "catalog:",
"@types/sql.js": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "catalog:",
"bootstrap-scss": "catalog:",
"eslint-config-tachi": "workspace:*",
"sass": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
},
@@ -1,20 +1,33 @@
import TISInfo from "#components/imports/TISInfo";
import ImportSQLiteForm from "#components/imports/ImportSQLiteForm";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import Divider from "#components/util/Divider";
import Muted from "#components/util/Muted";
import { TachiConfig } from "#lib/config";
import { convertLR2Db } from "#util/db-converters/lr2";
import React from "react";
export default function LR2DBPage() {
useSetSubheader(["Import Scores", "LR2 Database File"]);
return (
<>
<TISInfo name="LR2 Database" />
<ImportSQLiteForm
convert={(dbs) => {
const { k7, k14, warnings } = convertLR2Db(dbs.score!, dbs.chart!);
const results = [k7, k14].filter((r) => r !== null);
return { results, warnings };
}}
fileInputs={[
{ key: "score", label: "LR2 Scores/<username>.db" },
{ key: "chart", label: "LR2 song.db" },
]}
name="LR2 Database Import"
/>
<Divider />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
Note: If you submit a score on a chart that the server doesn't recognise, you'll
need to wait until at least 2 other players submit scores for that chart before
it'll show up. This is to combat accidental IR spam.
</Muted>
</>
);
@@ -1,8 +1,8 @@
import TISInfo from "#components/imports/TISInfo";
import ImportSQLiteForm from "#components/imports/ImportSQLiteForm";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import Divider from "#components/util/Divider";
import Muted from "#components/util/Muted";
import { TachiConfig } from "#lib/config";
import { convertBeatorajaDb } from "#util/db-converters/beatoraja";
import React from "react";
import { Link } from "react-router-dom";
@@ -11,18 +11,30 @@ export default function LR2orajaDBPage() {
return (
<>
<TISInfo name="LR2Oraja Database" />
<ImportSQLiteForm
convert={(dbs) => {
const { k7, k14, warnings } = convertBeatorajaDb(dbs.score!, dbs.chart!);
const results = [k7, k14].filter((r) => r !== null);
return { results, warnings };
}}
fileInputs={[
{ key: "score", label: "Beatoraja score.db" },
{ key: "chart", label: "Beatoraja songdata.db" },
]}
name="LR2oraja (Beatoraja) Database Import"
/>
<Divider />
<Muted>
This method is intended for syncing up with existing scores. For new scores, you
should set up the <Link to="/import/beatoraja-ir">LR2oraja IR</Link>, for automatic
should set up the <Link to="/import/beatoraja-ir">LR2oraja IR</Link> for automatic
score uploading.
</Muted>
<br />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
Note: If you submit a score on a chart that the server doesn't recognise, you'll
need to wait until at least 2 other players submit scores for that chart before
it'll show up. This is to combat accidental IR spam.
</Muted>
</>
);
@@ -1,27 +1,64 @@
import TISInfo from "#components/imports/TISInfo";
import ImportSQLiteForm from "#components/imports/ImportSQLiteForm";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import Divider from "#components/util/Divider";
import Muted from "#components/util/Muted";
import { TachiConfig } from "#lib/config";
import React from "react";
import Alert from "react-bootstrap/Alert";
import { convertUSCDb } from "#util/db-converters/usc";
import React, { useState } from "react";
import { Alert, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
export default function USCDBPage() {
useSetSubheader(["Import Scores", "USC Database File"]);
const [playtype, setPlaytype] = useState<"" | "Controller" | "Keyboard">("");
return (
<>
<TISInfo name="USC Database" />
<Divider />
<Alert variant="info">
This method is intended for syncing up with existing scores. For new scores, you
should set up the <Link to="/import/usc-ir">USC IR</Link>, for automatic score
should set up the <Link to="/import/usc-ir">USC IR</Link> for automatic score
uploading.
</Alert>
<ImportSQLiteForm
convert={(dbs) => {
if (playtype === "") {
throw new Error("Please select an input device.");
}
const { result, warnings } = convertUSCDb(dbs.db!, playtype);
return { results: result.scores.length > 0 ? [result] : [], warnings };
}}
extraControls={
<Form.Group>
<Form.Label>
<strong>Input Device</strong>
</Form.Label>
<Form.Select
onChange={(e) =>
setPlaytype(e.target.value as "" | "Controller" | "Keyboard")
}
value={playtype}
>
<option value="">Please select…</option>
<option value="Controller">Controller</option>
<option value="Keyboard">Keyboard</option>
</Form.Select>
<Form.Text className="text-muted">
Keyboard and Controller players are on separate leaderboards. Selecting
the wrong one will cause problems.
</Form.Text>
</Form.Group>
}
extraValid={playtype !== ""}
fileInputs={[{ key: "db", label: "USC maps.db" }]}
name="USC Database Import"
/>
<Divider />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.NAME} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
If you submit a score on a chart that the server doesn't recognise, you'll need to
wait until at least 2 other players submit scores for that chart before it'll show
up. This is to combat accidental IR spam.
</Muted>
</>
);
@@ -0,0 +1,271 @@
import Divider from "#components/util/Divider";
import useImport from "#components/util/import/useImport";
import Loading from "#components/util/Loading";
import { openDatabase } from "#util/db-converters/sql-loader";
import React, { useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { type Database } from "sql.js";
import ImportStateRenderer from "./ImportStateRenderer";
export interface ConvertWarning {
message: string;
level: "error" | "warn";
}
export interface ConvertResult {
/** One or more Batch Manual documents to submit in sequence. */
results: unknown[];
warnings: ConvertWarning[];
}
export interface SQLiteFileInput {
/** Human-readable label shown above the file picker. */
label: string;
/** Key used to look up the opened Database in the convert callback. */
key: string;
}
interface Props {
/** Heading for this import form. */
name: string;
/** One file input (USC) or two (LR2 / Beatoraja). */
fileInputs: SQLiteFileInput[];
/** Optional extra controls rendered between file pickers and the import button
* (e.g. a playtype selector). Validity of those controls is signalled via
* `extraValid`; if omitted the import button doesn't wait for them. */
extraControls?: React.ReactNode;
/** When `extraControls` is provided, set this to false to disable the import
* button until the controls have a valid value. */
extraValid?: boolean;
/**
* Conversion function. Receives a map of `key → opened Database` and
* returns one or more Batch Manual documents plus any warnings.
* Called on the main thread – keep it reasonably fast (typical score DBs
* are a few thousand rows, so this should be fine).
*/
convert: (dbs: Record<string, Database>) => ConvertResult;
}
type ConvertState =
| { message: string; phase: "error" }
| { phase: "idle" }
| { phase: "loading" }
| { phase: "preview"; results: unknown[]; warnings: ConvertWarning[] };
export default function ImportSQLiteForm({
name,
fileInputs,
extraControls,
extraValid = true,
convert,
}: Props) {
// One File ref per declared file input
const [files, setFiles] = useState<Record<string, File | null>>(() =>
Object.fromEntries(fileInputs.map((f) => [f.key, null])),
);
const [convertState, setConvertState] = useState<ConvertState>({ phase: "idle" });
const { importState: importState1, runImport: runImport1 } = useImport("/import/file", {});
const { importState: importState2, runImport: runImport2 } = useImport("/import/file", {});
const allFilesProvided = fileInputs.every((f) => files[f.key] !== null);
const canConvert =
allFilesProvided &&
extraValid &&
convertState.phase !== "loading" &&
importState1.state === "not_started" &&
importState2.state === "not_started";
async function handleConvertAndImport() {
if (!allFilesProvided) {
return;
}
setConvertState({ phase: "loading" });
const dbs: Record<string, Database> = {};
try {
for (const { key } of fileInputs) {
dbs[key] = await openDatabase(files[key]!);
}
const { results, warnings } = convert(dbs);
if (results.length === 0) {
setConvertState({
phase: "error",
message: "No importable scores were found in the database.",
});
return;
}
setConvertState({ phase: "preview", results, warnings });
} catch (err) {
setConvertState({
phase: "error",
message: err instanceof Error ? err.message : String(err),
});
} finally {
for (const db of Object.values(dbs)) {
try {
db.close();
} catch {
// ignore
}
}
}
}
async function handleSubmit() {
if (convertState.phase !== "preview") {
return;
}
const { results } = convertState;
const makeOptions = (body: unknown): RequestInit => {
const formData = new FormData();
formData.append("importType", "file/batch-manual");
formData.append(
"scoreData",
new Blob([JSON.stringify(body)], { type: "application/json" }),
"scores.json",
);
return {
method: "POST",
headers: { "X-User-Intent": "true" },
body: formData,
};
};
if (results.length >= 1) {
await runImport1(makeOptions(results[0]));
}
if (results.length >= 2) {
await runImport2(makeOptions(results[1]));
}
}
const bothDone =
importState1.state === "done" &&
(importState2.state === "done" || importState2.state === "not_started");
return (
<div className="vstack gap-3">
<h2 className="text-center">{name}</h2>
<Alert variant="warning">
Processing your database file runs entirely in your browser. Depending on the number
of scores, this may use significant CPU for a few seconds. This is expected.
</Alert>
{fileInputs.map(({ key, label }) => (
<Form.Group key={key}>
<Form.Label>{label}</Form.Label>
<input
accept=".db"
className="form-control"
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
setFiles((prev) => ({ ...prev, [key]: file }));
// Reset convert state when a file changes
setConvertState({ phase: "idle" });
}}
type="file"
/>
</Form.Group>
))}
{extraControls}
{convertState.phase === "error" && (
<Alert variant="danger">
<strong>Error:</strong> {convertState.phase === "error" && convertState.message}
</Alert>
)}
{convertState.phase === "preview" && (
<>
<Alert variant="info">
<strong>Ready to import</strong>
<ul className="mb-0 mt-1">
{convertState.results.map((r, i) => {
const bm = r as {
meta: { game: string; playtype: string };
scores: unknown[];
};
return (
<li key={i}>
<strong>
{bm.meta.game} {bm.meta.playtype}
</strong>
: {bm.scores.length} score
{bm.scores.length !== 1 ? "s" : ""}
</li>
);
})}
</ul>
</Alert>
{convertState.warnings.length > 0 && (
<Alert variant="warning">
<strong>{convertState.warnings.length} warning(s):</strong>
<ul className="mb-0 mt-1">
{convertState.warnings.map((w, i) => (
<li
className={w.level === "error" ? "text-danger" : ""}
key={i}
>
{w.message}
</li>
))}
</ul>
</Alert>
)}
</>
)}
<div className="text-center">
{convertState.phase !== "preview" ? (
<Button
disabled={!canConvert}
onClick={handleConvertAndImport}
variant="primary"
>
{convertState.phase === "loading" ? (
<>
<Loading /> Reading database…
</>
) : (
"Read Database"
)}
</Button>
) : !bothDone ? (
<Button
disabled={
importState1.state === "waiting_init" ||
importState1.state === "waiting_processing" ||
importState2.state === "waiting_init" ||
importState2.state === "waiting_processing"
}
onClick={handleSubmit}
variant="success"
>
Submit Scores
</Button>
) : null}
</div>
{importState1.state !== "not_started" && (
<>
<Divider />
<ImportStateRenderer state={importState1} />
</>
)}
{importState2.state !== "not_started" && (
<>
<Divider />
<ImportStateRenderer state={importState2} />
</>
)}
</div>
);
}
@@ -0,0 +1,236 @@
/**
* Port of tachi-import-scripts/src-tauri/src/backend/beatoraja.rs
*
* Converts Beatoraja score.db + chart.db into Batch Manual documents
* (one per gamemode: 7K, 14K) suitable for /ir/direct-manual/import.
*/
import { type Database } from "sql.js";
import { queryAll, queryOne } from "./sql-loader";
const SERVICE = "tachi-import-scripts";
interface BeatorajaScoreRow {
sha256: string;
clear: number;
epg: number;
egr: number;
egd: number;
ebd: number;
epr: number;
ems: number;
lpg: number;
lgr: number;
lgd: number;
lbd: number;
lpr: number;
lms: number;
combo: number;
minbp: number;
random: number;
date: number;
}
interface BeatorajaChartRow {
title: string;
subtitle: string;
feature: number;
mode: number;
}
export interface BeatorajaConvertWarning {
message: string;
level: "error" | "warn";
}
interface BMSBatchManual {
meta: {
game: "bms";
playtype: "7K" | "14K";
service: string;
version: null;
};
scores: unknown[];
classes: Record<string, never>;
}
export interface BeatorajaConvertResult {
k7: BMSBatchManual | null;
k14: BMSBatchManual | null;
warnings: BeatorajaConvertWarning[];
}
// Chart feature bitflags (from beatoraja.rs)
const CHART_FEATURE_RANDOM = 0b100;
function parseLamp(clear: number): string | null {
switch (clear) {
case 0:
return "NO PLAY";
case 1:
return "FAILED";
case 2:
case 3:
return "ASSIST CLEAR";
case 4:
return "EASY CLEAR";
case 5:
return "CLEAR";
case 6:
return "HARD CLEAR";
case 7:
return "EX HARD CLEAR";
case 8:
case 9:
case 10:
return "FULL COMBO";
default:
return null;
}
}
function parseRandom(random: number): string | null {
switch (random) {
case 0:
return "NONRAN";
case 1:
return "MIRROR";
case 2:
return "RANDOM";
case 3:
return "R-RANDOM";
case 4:
return "S-RANDOM";
default:
// H-Ran, Spiral, etc. — unfair, skip
return null;
}
}
export function convertBeatorajaDb(scoreDb: Database, chartDb: Database): BeatorajaConvertResult {
// mode = 0 means BMS (not PMS)
const scoreRows = queryAll<BeatorajaScoreRow>(scoreDb, "SELECT * FROM score WHERE mode = 0");
const scores7k: unknown[] = [];
const scores14k: unknown[] = [];
const warnings: BeatorajaConvertWarning[] = [];
for (const score of scoreRows) {
const chart = queryOne<BeatorajaChartRow>(
chartDb,
"SELECT title, subtitle, feature, mode FROM song WHERE sha256 = ?",
[score.sha256],
);
if (!chart) {
warnings.push({
level: "warn",
message: `Could not find a matching chart for sha256 ${score.sha256}. Skipping.`,
});
continue;
}
const name = `${chart.title} ${chart.subtitle}`.trim();
// Skip charts with #RANDOM declarations — results are non-deterministic
if (chart.feature & CHART_FEATURE_RANDOM) {
continue;
}
if (chart.mode !== 7 && chart.mode !== 14) {
// Unknown/unsupported gamemode — silently skip
continue;
}
const playtype = chart.mode === 7 ? "7K" : "14K";
const lamp = parseLamp(score.clear);
if (lamp === null) {
warnings.push({
level: "warn",
message: `Skipping score on "${name}" — unknown lamp value ${score.clear}.`,
});
continue;
}
let random: string | null = null;
if (playtype === "7K") {
random = parseRandom(score.random);
if (random === null) {
warnings.push({
level: "warn",
message: `Skipping score on "${name}" — invalid or unfair random option (${score.random}).`,
});
continue;
}
}
// Beatoraja stores i32::MAX or negative as "no BP data"
const bp = score.minbp === 2_147_483_647 || score.minbp < 0 ? null : score.minbp;
const bmsScore = {
identifier: score.sha256,
matchType: "bmsChartHash",
score: (score.lpg + score.epg) * 2 + score.egr + score.lgr,
lamp,
comment: null,
timeAchieved: score.date * 1000,
optional: {
bp,
fast: score.egr + score.egd,
slow: score.lgr + score.lgd,
maxCombo: score.combo,
epg: score.epg,
egr: score.egr,
egd: score.egd,
ebd: score.ebd,
epr: score.epr,
lpg: score.lpg,
lgr: score.lgr,
lgd: score.lgd,
lbd: score.lbd,
lpr: score.lpr,
},
scoreMeta: {
random,
inputDevice: null,
client: "lr2oraja",
gauge: null,
},
judgements: {
pgreat: score.epg + score.lpg,
great: score.egr + score.lgr,
good: score.egd + score.lgd,
bad: score.ebd + score.lbd,
poor: score.epr + score.lpr + score.ems + score.lms,
},
};
if (playtype === "7K") {
scores7k.push(bmsScore);
} else {
scores14k.push(bmsScore);
}
}
return {
k7:
scores7k.length > 0
? {
meta: { game: "bms", playtype: "7K", service: SERVICE, version: null },
scores: scores7k,
classes: {},
}
: null,
k14:
scores14k.length > 0
? {
meta: { game: "bms", playtype: "14K", service: SERVICE, version: null },
scores: scores14k,
classes: {},
}
: null,
warnings,
};
}
@@ -0,0 +1,217 @@
/**
* Port of tachi-import-scripts/src-tauri/src/backend/lr2.rs
*
* Converts LR2 score.db + chart.db into Batch Manual documents
* (one per gamemode: 7K, 14K) suitable for /ir/direct-manual/import.
*/
import { type Database } from "sql.js";
import { queryAll, queryOne } from "./sql-loader";
const SERVICE = "tachi-import-scripts";
interface LR2ScoreRow {
hash: string;
clear: number;
perfect: number;
great: number;
good: number;
bad: number;
poor: number;
maxcombo: number;
minbp: number;
op_best: number;
}
interface LR2ChartRow {
title: string;
subtitle: string | null;
mode: number | null;
}
export interface LR2ConvertWarning {
message: string;
level: "error" | "warn";
}
interface BMSBatchManual {
meta: {
game: "bms";
playtype: "7K" | "14K";
service: string;
version: null;
};
scores: unknown[];
classes: Record<string, never>;
}
export interface LR2ConvertResult {
k7: BMSBatchManual | null;
k14: BMSBatchManual | null;
warnings: LR2ConvertWarning[];
}
function parseLamp(clear: number, name: string): string | null {
switch (clear) {
case 0:
return "NO PLAY";
case 1:
return "FAILED";
case 2:
return "EASY CLEAR";
case 3:
return "CLEAR";
case 4:
return "HARD CLEAR";
case 5:
return "FULL COMBO";
default:
return null;
}
}
/**
* LR2 encodes the random option in the top digit of op_best.
* 0 = NONRAN, 1 = MIRROR, 2 = RANDOM, 3 = S-RANDOM
*/
function parseRandom(opBest: number): string | null {
if (opBest > 100) {
return null;
}
const tenths = Math.floor(opBest / 10);
switch (tenths) {
case 0:
return "NONRAN";
case 1:
return "MIRROR";
case 2:
return "RANDOM";
case 3:
return "S-RANDOM";
default:
return null;
}
}
export function convertLR2Db(scoreDb: Database, chartDb: Database): LR2ConvertResult {
const scoreRows = queryAll<LR2ScoreRow>(scoreDb, "SELECT * FROM score WHERE complete = 1");
const scores7k: unknown[] = [];
const scores14k: unknown[] = [];
const warnings: LR2ConvertWarning[] = [];
for (const score of scoreRows) {
// MD5 hashes are 32 hex characters — skip anything that doesn't look like one
if (score.hash.length !== 32) {
continue;
}
const chart = queryOne<LR2ChartRow>(
chartDb,
"SELECT title, subtitle, mode FROM song WHERE hash = ?",
[score.hash],
);
if (!chart) {
warnings.push({
level: "warn",
message: `Could not find a matching chart for score hash ${score.hash}. Skipping.`,
});
continue;
}
const name = `${chart.title} ${chart.subtitle ?? ""}`.trim();
if (chart.mode !== 7 && chart.mode !== 14) {
// Unknown/unsupported gamemode — silently skip
continue;
}
const playtype = chart.mode === 7 ? "7K" : "14K";
if (score.minbp < 0) {
// Likely autoscratch — skip
warnings.push({
level: "warn",
message: `Skipping score on "${name}" — negative BP (${score.minbp}), probably autoscratch.`,
});
continue;
}
const lamp = parseLamp(score.clear, name);
if (lamp === null) {
warnings.push({
level: "warn",
message: `Skipping score on "${name}" — unknown lamp value ${score.clear}.`,
});
continue;
}
let random: string | null = null;
if (playtype === "7K") {
random = parseRandom(score.op_best);
if (random === null) {
warnings.push({
level: "warn",
message: `Skipping score on "${name}" — unknown random option ${score.op_best}.`,
});
continue;
}
}
const bmsScore = {
identifier: score.hash,
matchType: "bmsChartHash",
score: score.perfect * 2 + score.great,
lamp,
comment: null,
timeAchieved: null,
optional: {
bp: score.minbp,
maxCombo: score.maxcombo,
fast: null,
slow: null,
},
scoreMeta: {
random,
inputDevice: null,
client: "LR2",
gauge: null,
},
judgements: {
pgreat: score.perfect,
great: score.great,
good: score.good,
bad: score.bad,
poor: score.poor,
},
};
if (playtype === "7K") {
scores7k.push(bmsScore);
} else {
scores14k.push(bmsScore);
}
}
return {
k7:
scores7k.length > 0
? {
meta: { game: "bms", playtype: "7K", service: SERVICE, version: null },
scores: scores7k,
classes: {},
}
: null,
k14:
scores14k.length > 0
? {
meta: { game: "bms", playtype: "14K", service: SERVICE, version: null },
scores: scores14k,
classes: {},
}
: null,
warnings,
};
}
@@ -0,0 +1,49 @@
import initSqlJs, { type BindParams, type Database, type SqlJsStatic } from "sql.js";
// Vite resolves this to a hashed static asset URL at build time
import sqlWasmUrl from "sql.js/dist/sql-wasm.wasm?url";
let sqlJsPromise: Promise<SqlJsStatic> | null = null;
export type { Database as SqlJsDatabase };
/** Lazily initialise sql.js once, returning the cached promise on subsequent calls. */
export function getSqlJs(): Promise<SqlJsStatic> {
if (!sqlJsPromise) {
sqlJsPromise = initSqlJs({
locateFile: () => sqlWasmUrl as string,
});
}
return sqlJsPromise;
}
/** Open a SQLite database from a browser File object. */
export async function openDatabase(file: File): Promise<Database> {
const SQL = await getSqlJs();
const buffer = await file.arrayBuffer();
return new SQL.Database(new Uint8Array(buffer));
}
/** Pull a single row from a query, returns null if nothing found. */
export function queryOne<T>(db: Database, sql: string, params: BindParams = []): T | null {
const stmt = db.prepare(sql);
stmt.bind(params);
if (!stmt.step()) {
stmt.free();
return null;
}
const row = stmt.getAsObject() as T;
stmt.free();
return row;
}
/** Pull all rows from a query. */
export function queryAll<T>(db: Database, sql: string, params: BindParams = []): T[] {
const results: T[] = [];
const stmt = db.prepare(sql);
stmt.bind(params);
while (stmt.step()) {
results.push(stmt.getAsObject() as T);
}
stmt.free();
return results;
}
@@ -0,0 +1,220 @@
/**
* Port of tachi-import-scripts/src-tauri/src/backend/usc.rs
*
* Converts an unnamed_sdvx_clone (USC) maps.db into a Batch Manual document
* suitable for submission to /ir/direct-manual/import.
*/
import { type Database } from "sql.js";
import { queryAll, queryOne } from "./sql-loader";
const SERVICE = "tachi-import-scripts";
const MIN_DB_VERSION = 19;
const MAX_DB_VERSION = 20;
interface USCScoreRow {
score: number;
crit: number;
near: number;
miss: number;
gauge: number;
timestamp: number;
chart_hash: string;
window_perfect: number;
window_good: number;
window_hold: number;
window_miss: number;
window_slam: number;
gauge_type: number;
auto_flags: number;
mirror: boolean | number;
random: boolean | number;
early: number | null;
late: number | null;
combo: number | null;
title: string;
diff_shortname: string;
}
const DEFAULT_WINDOWS = { perfect: 46, good: 150, hold: 150, miss: 300, slam: 84 };
const LEGACY_WINDOWS = { perfect: 46, good: 92, hold: 138, miss: 250, slam: 84 };
const BUGGED_WINDOWS = { perfect: 46, good: 92, hold: 138, miss: 300, slam: 84 };
type HitWindows = typeof DEFAULT_WINDOWS;
function windowsEqual(a: HitWindows, b: HitWindows): boolean {
return (
a.perfect === b.perfect &&
a.good === b.good &&
a.hold === b.hold &&
a.miss === b.miss &&
a.slam === b.slam
);
}
export interface USCConvertWarning {
message: string;
level: "error" | "warn";
}
function checkHitWindows(windows: HitWindows): { ok: boolean; warning?: USCConvertWarning } {
if (windowsEqual(windows, DEFAULT_WINDOWS) || windowsEqual(windows, LEGACY_WINDOWS)) {
return { ok: true };
}
if (windowsEqual(windows, BUGGED_WINDOWS)) {
return {
ok: true,
warning: {
level: "warn",
message:
"Score detected with bugged hit windows. A game update caused the new hit windows to partially apply. You should go into settings and reset your hit windows.",
},
};
}
return { ok: false };
}
function getLamp(score: USCScoreRow): string {
if (score.score === 10_000_000) {
return "PERFECT ULTIMATE CHAIN";
}
if (score.miss === 0) {
return "ULTIMATE CHAIN";
}
if (score.gauge_type === 1) {
return score.gauge > 0 ? "EXCESSIVE CLEAR" : "FAILED";
}
return score.gauge > 0.7 ? "CLEAR" : "FAILED";
}
function getNoteMod(mirror: boolean, random: boolean): string {
if (mirror && random) {
return "MIR-RAN";
}
if (mirror) {
return "MIRROR";
}
if (random) {
return "RANDOM";
}
return "NORMAL";
}
export interface USCBatchManual {
meta: {
game: "usc";
playtype: "Controller" | "Keyboard";
service: string;
version: null;
};
scores: unknown[];
classes: Record<string, never>;
}
export function convertUSCDb(
db: Database,
playtype: "Controller" | "Keyboard",
): { result: USCBatchManual; warnings: USCConvertWarning[] } {
const versionRow = queryOne<{ version: number }>(db, "SELECT version FROM Database");
if (!versionRow) {
throw new Error("Could not read version from Database table.");
}
const version = versionRow.version;
if (version < MIN_DB_VERSION) {
throw new Error(
`Your maps.db version is ${version}, which is below the minimum of ${MIN_DB_VERSION}. Update your game.`,
);
}
if (version > MAX_DB_VERSION) {
throw new Error(
`Your maps.db version is ${version}, which is above the maximum supported version (${MAX_DB_VERSION}). Please report this so the tool can be updated.`,
);
}
const rows = queryAll<USCScoreRow>(
db,
"SELECT * FROM Scores LEFT JOIN Charts ON Scores.chart_hash = Charts.hash",
);
const scores = [];
const warnings: USCConvertWarning[] = [];
for (const row of rows) {
if (row.auto_flags !== 0) {
continue;
}
const windows: HitWindows = {
perfect: row.window_perfect,
good: row.window_good,
hold: row.window_hold,
miss: row.window_miss,
slam: row.window_slam,
};
const { ok, warning } = checkHitWindows(windows);
if (warning) {
warnings.push(warning);
}
if (!ok) {
warnings.push({
level: "error",
message: `Skipping score on "${row.title} [${row.diff_shortname}]" — non-standard hit windows detected.`,
});
continue;
}
const mirror = Boolean(row.mirror);
const random = Boolean(row.random);
let gaugeMod: string;
if (row.gauge_type === 0) {
gaugeMod = "NORMAL";
} else if (row.gauge_type === 1) {
gaugeMod = "HARD";
} else {
warnings.push({
level: "warn",
message: `Skipping score on "${row.title} [${row.diff_shortname}]" — unknown gauge type ${row.gauge_type}.`,
});
continue;
}
scores.push({
identifier: row.chart_hash,
matchType: "uscChartHash",
score: row.score,
lamp: getLamp(row),
comment: null,
timeAchieved: row.timestamp * 1000,
optional: {
fast: row.early,
slow: row.late,
maxCombo: row.combo,
gauge: row.gauge * 100,
},
scoreMeta: {
noteMod: getNoteMod(mirror, random),
gaugeMod,
},
judgements: {
critical: row.crit,
near: row.near,
miss: row.miss,
},
});
}
return {
result: {
meta: { game: "usc", playtype, service: SERVICE, version: null },
scores,
classes: {},
},
warnings,
};
}