diff --git a/typescript/bot/src/slash-commands/commands/sync.ts b/typescript/bot/src/slash-commands/commands/sync.ts
index fcbbf9e5d..731ab327f 100644
--- a/typescript/bot/src/slash-commands/commands/sync.ts
+++ b/typescript/bot/src/slash-commands/commands/sync.ts
@@ -19,9 +19,11 @@ const choices: Array<[string, string]> = (
["CG DEV MUSECA", "api/cg-dev-museca"],
["CG DEV Pop'n", "api/cg-dev-popn"],
["CG DEV Jubeat", "api/cg-dev-jubeat"],
+ ["CG DEV IIDX", "api/cg-dev-iidx"],
["CG SDVX", "api/cg-prod-sdvx"],
["CG MUSECA", "api/cg-prod-museca"],
["CG Pop'n", "api/cg-prod-popn"],
+ ["CG IIDX", "api/cg-prod-iidx"],
["MYT CHUNITHM", "api/myt-chunithm"],
["MYT MAIMAI DX", "api/myt-maimaidx"],
["MYT ONGEKI", "api/myt-ongeki"],
diff --git a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx
index 8549f7450..738a3b228 100644
--- a/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx
+++ b/typescript/client/src/app/pages/dashboard/import/ImportPage.tsx
@@ -164,6 +164,9 @@ function ImportInfoDisplayer({ game }: { game: GameGroup }) {
/>,
,
,
+ ,
+ ,
+ ,
);
+ case "api/cg-dev-iidx":
+ return (
+
+ );
+ case "api/cg-nag-iidx":
+ return (
+
+ );
+ case "api/cg-gan-iidx":
+ return (
+
+ );
case "api/myt-chunithm":
return (
+
+
+
@@ -226,6 +229,9 @@ export default function ImportRoutes() {
+
+
+
@@ -239,6 +245,9 @@ export default function ImportRoutes() {
+
+
+
diff --git a/typescript/client/src/components/imports/CGIntegrationPage.tsx b/typescript/client/src/components/imports/CGIntegrationPage.tsx
index 4d52af609..cc618f889 100644
--- a/typescript/client/src/components/imports/CGIntegrationPage.tsx
+++ b/typescript/client/src/components/imports/CGIntegrationPage.tsx
@@ -18,7 +18,7 @@ import ImportStateRenderer from "./ImportStateRenderer";
interface Props {
cgType: "dev" | "gan" | "nag";
- game: "jubeat" | "museca" | "popn" | "sdvx";
+ game: "iidx" | "jubeat" | "museca" | "popn" | "sdvx";
}
export default function CGIntegrationPage({ cgType, game }: Props) {
diff --git a/typescript/common/src/constants/import-types.ts b/typescript/common/src/constants/import-types.ts
index dcd812481..591475d5c 100644
--- a/typescript/common/src/constants/import-types.ts
+++ b/typescript/common/src/constants/import-types.ts
@@ -39,14 +39,17 @@ const API_IMPORT_TYPES: Record = {
"api/myt-maimaidx": true,
"api/myt-ongeki": true,
"api/myt-wacca": true,
+ "api/cg-dev-iidx": true,
"api/cg-dev-jubeat": true,
"api/cg-dev-museca": true,
"api/cg-dev-popn": true,
"api/cg-dev-sdvx": true,
+ "api/cg-nag-iidx": true,
"api/cg-nag-jubeat": true,
"api/cg-nag-museca": true,
"api/cg-nag-popn": true,
"api/cg-nag-sdvx": true,
+ "api/cg-gan-iidx": true,
"api/cg-gan-jubeat": true,
"api/cg-gan-museca": true,
"api/cg-gan-popn": true,
diff --git a/typescript/common/src/types/import-types.ts b/typescript/common/src/types/import-types.ts
index 6e0b8b655..11577dfbf 100644
--- a/typescript/common/src/types/import-types.ts
+++ b/typescript/common/src/types/import-types.ts
@@ -20,9 +20,9 @@ export type APIImportTypes =
| "api/myt-wacca"
// cg has dev and prod supported
- // with four games.
+ // with five games.
// this typescript feature for stringliteral expansion is kinda neat.
- | `api/cg-${"dev" | "gan" | "nag"}-${"jubeat" | "museca" | "popn" | "sdvx"}`;
+ | `api/cg-${"dev" | "gan" | "nag"}-${"iidx" | "jubeat" | "museca" | "popn" | "sdvx"}`;
export type IRImportTypes =
| "ir/barbatos"
diff --git a/typescript/db/src/generated/public/ImportType.ts b/typescript/db/src/generated/public/ImportType.ts
index a8bdeda43..f1c5dc502 100644
--- a/typescript/db/src/generated/public/ImportType.ts
+++ b/typescript/db/src/generated/public/ImportType.ts
@@ -3,46 +3,49 @@
/** Represents the enum public.import_type */
type ImportType =
- | 'file/batch-manual'
- | 'file/eamusement-iidx-csv'
- | 'file/eamusement-sdvx-csv'
- | 'file/mypagescraper-player-csv'
- | 'file/mypagescraper-records-csv'
- | 'file/pli-iidx-csv'
- | 'file/solid-state-squad'
| 'api/arc-iidx'
| 'api/arc-sdvx'
- | 'api/mer-iidx'
+ | 'api/cg-dev-iidx'
+ | 'api/cg-dev-jubeat'
+ | 'api/cg-dev-museca'
+ | 'api/cg-dev-popn'
+ | 'api/cg-dev-sdvx'
+ | 'api/cg-gan-iidx'
+ | 'api/cg-gan-jubeat'
+ | 'api/cg-gan-museca'
+ | 'api/cg-gan-popn'
+ | 'api/cg-gan-sdvx'
+ | 'api/cg-nag-iidx'
+ | 'api/cg-nag-jubeat'
+ | 'api/cg-nag-museca'
+ | 'api/cg-nag-popn'
+ | 'api/cg-nag-sdvx'
| 'api/eag-iidx'
| 'api/eag-sdvx'
| 'api/flo-iidx'
| 'api/flo-sdvx'
+ | 'api/mer-iidx'
| 'api/min-sdvx'
| 'api/myt-chunithm'
| 'api/myt-maimaidx'
| 'api/myt-ongeki'
| 'api/myt-wacca'
- | 'api/cg-dev-jubeat'
- | 'api/cg-dev-museca'
- | 'api/cg-dev-popn'
- | 'api/cg-dev-sdvx'
- | 'api/cg-gan-jubeat'
- | 'api/cg-gan-museca'
- | 'api/cg-gan-popn'
- | 'api/cg-gan-sdvx'
- | 'api/cg-nag-jubeat'
- | 'api/cg-nag-museca'
- | 'api/cg-nag-popn'
- | 'api/cg-nag-sdvx'
+ | 'file/batch-manual'
+ | 'file/eamusement-iidx-csv'
+ | 'file/eamusement-sdvx-csv'
+ | 'file/import-class'
+ | 'file/mypagescraper-player-csv'
+ | 'file/mypagescraper-records-csv'
+ | 'file/pli-iidx-csv'
+ | 'file/solid-state-squad'
| 'ir/barbatos'
| 'ir/beatoraja'
| 'ir/direct-manual'
- | 'ir/fervidex-static'
| 'ir/fervidex'
- | 'ir/kshook-sv6c-static'
+ | 'ir/fervidex-static'
| 'ir/kshook-sv6c'
+ | 'ir/kshook-sv6c-static'
| 'ir/lr2hook'
- | 'ir/usc'
- | 'file/import-class';
+ | 'ir/usc';
export default ImportType;
diff --git a/typescript/server/.env.example b/typescript/server/.env.example
index ea1d02dca..b47edbf7c 100644
--- a/typescript/server/.env.example
+++ b/typescript/server/.env.example
@@ -49,7 +49,7 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=us-east-1
TACHI_NAME=Tachi Local Dev
TACHI_TYPE=omni
TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki,ddr
-TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/import-class,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
+TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/import-class,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/cg-dev-iidx,api/cg-nag-iidx,api/cg-gan-iidx,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
# TACHI_GITHUB_APP_ID=
# TACHI_GITHUB_APP_BASE64_PRIVATE_KEY=
diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts
new file mode 100644
index 000000000..0ca342267
--- /dev/null
+++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.test.ts
@@ -0,0 +1,208 @@
+import { log } from "#lib/log/log";
+import DB from "#services/pg/db";
+import { Testing511Song, Testing511SPA } from "#test-utils/test-data";
+import deepmerge from "deepmerge";
+import { describe, expect, it } from "vitest";
+
+import type { CGContext, CGIIDXScore } from "../types";
+
+import { ConverterAPICGIIDX } from "./converter";
+
+const LEG_SONG_ID = "s_cg_iidx_leg_test";
+const LEG_CHART_ID = "c_cg_iidx_leg_test";
+
+const iidxScore: CGIIDXScore = {
+ version: 33,
+ internalId: 1000,
+ difficulty: "SPA",
+ exScore: 1464,
+ clearType: 4,
+ perfectCount: 686,
+ greatCount: 92,
+ missCount: 8,
+ dead: 0,
+ ghost: "131316191818171a171717181817191617191718151913131917131717171817191818191818181918181a18181719171419171810141814191211161a131519",
+ ghostGauge:
+ "4c044c044c044c044c044c049a0402056a05060688060a075c071208940816096409b209000a340a9c0ad00a380bba0b560c8a0cbe0c0c0d740dc20d100e440eac0e140f620fb00ffe0f4c109a100211501191116112c9127f138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388138813881388135813721388138813881388138813881388138813de128813881388138813881388138813cc114e121113881388138813881388138813881388138813881388138813881388138813881300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ option1: 72584948351232,
+ option2: 0,
+ dateTime: "2025-11-29 14:07:09",
+};
+
+const context: CGContext = {
+ service: "dev",
+ userID: 1,
+};
+
+async function seed511Another() {
+ await DB.insertInto("song")
+ .values({
+ id: Testing511Song.id,
+ legacy_id: 1,
+ game_group: "iidx",
+ title: Testing511Song.title,
+ artist: Testing511Song.artist,
+ search_terms: Testing511Song.searchTerms,
+ alt_titles: Testing511Song.altTitles,
+ data: Testing511Song.data,
+ fts_document: "",
+ })
+ .execute();
+
+ await DB.insertInto("chart")
+ .values({
+ id: Testing511SPA.chartID,
+ legacy_id: Testing511SPA.chartID,
+ game: "iidx-sp",
+ song_id: Testing511Song.id,
+ difficulty: "ANOTHER",
+ level: Testing511SPA.level,
+ level_num: Testing511SPA.levelNum,
+ is_primary: Testing511SPA.isPrimary,
+ versions: Testing511SPA.versions,
+ data: Testing511SPA.data,
+ })
+ .execute();
+}
+
+async function seedLeggendariaFixture() {
+ await DB.insertInto("song")
+ .values({
+ id: LEG_SONG_ID,
+ legacy_id: 24_011,
+ game_group: "iidx",
+ title: "冬椿 ft. Kanae Asaba",
+ artist: "artist",
+ search_terms: [],
+ alt_titles: [],
+ data: { displayVersion: "1", genre: "TEST" },
+ fts_document: "",
+ })
+ .execute();
+
+ await DB.insertInto("chart")
+ .values({
+ id: LEG_CHART_ID,
+ legacy_id: LEG_CHART_ID,
+ game: "iidx-sp",
+ song_id: LEG_SONG_ID,
+ difficulty: "LEGGENDARIA",
+ level: "12",
+ level_num: 12,
+ is_primary: true,
+ versions: ["33"],
+ data: {
+ inGameID: 24_011,
+ notecount: 100,
+ "2dxtraSet": null,
+ },
+ })
+ .execute();
+}
+
+describe("ConverterAPICGIIDX", () => {
+ it("returns song, chart, and dryScore for valid input", async () => {
+ await seed511Another();
+
+ const res = await ConverterAPICGIIDX(iidxScore, context, "api/cg-dev-iidx", log);
+
+ expect(res.song).toMatchObject(Testing511Song);
+ expect(res.chart).toMatchObject({
+ chartID: Testing511SPA.chartID,
+ difficulty: "ANOTHER",
+ });
+ expect(res.dryScore).toStrictEqual({
+ comment: null,
+ game: "iidx-sp",
+ importType: "api/cg-dev-iidx",
+ timeAchieved: 1764425229000,
+ service: "CG Dev",
+ scoreData: {
+ score: 1464,
+ lamp: "CLEAR",
+ judgements: {
+ pgreat: 686,
+ great: 92,
+ },
+ optional: {
+ bp: 8,
+ gaugeHistory: [
+ 22, 22, 22, 22, 22, 22, 23.56, 25.64, 27.72, 30.84, 33.44, 36.04, 37.68,
+ 41.32, 43.92, 46.52, 48.08, 49.64, 51.2, 52.24, 54.32, 55.36, 57.44, 60.04,
+ 63.16, 64.2, 65.24, 66.8, 68.88, 70.44, 72, 73.04, 75.12, 77.2, 78.76,
+ 80.32, 81.88, 83.44, 85, 87.08, 88.64, 89.94, 94.1, 96.18, 99.82, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 99.04,
+ 99.56, 100, 100, 100, 100, 100, 100, 100, 100, 96.6, 100, 100, 100, 100,
+ 100, 100, 100, 91.12, 93.72, 97.62, 100, 100, 100, 100, 100, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100,
+ ],
+ },
+ },
+ scoreMeta: {
+ random: "S-RANDOM",
+ },
+ });
+ });
+
+ it("converts legacy Leggendaria music_id via lookup table", async () => {
+ await seed511Another();
+ await seedLeggendariaFixture();
+
+ const res = await ConverterAPICGIIDX(
+ deepmerge(iidxScore, { internalId: 24101 } as Partial),
+ context,
+ "api/cg-dev-iidx",
+ log,
+ );
+
+ expect(res.song).toMatchObject({ title: "冬椿 ft. Kanae Asaba" });
+ expect(res.chart).toMatchObject({
+ difficulty: "LEGGENDARIA",
+ data: {
+ inGameID: 24_011,
+ },
+ });
+ expect(res.dryScore.game).toBe("iidx-sp");
+ const sd = res.dryScore.scoreData as { lamp: string; score: number };
+ expect(sd.score).toBe(1464);
+ expect(sd.lamp).toBe("CLEAR");
+ });
+
+ it("throws when chart is missing", async () => {
+ await seed511Another();
+
+ await expect(
+ ConverterAPICGIIDX(
+ deepmerge(iidxScore, { internalId: 0 } as Partial),
+ context,
+ "api/cg-dev-iidx",
+ log,
+ ),
+ ).rejects.toMatchObject({
+ failureType: "SongOrChartNotFound",
+ message: /Could not find chart with songID 0 \(iidx-sp ANOTHER - Version 33\)/u,
+ });
+ });
+
+ it("rejects invalid music_id type", async () => {
+ await seed511Another();
+
+ await expect(
+ ConverterAPICGIIDX(
+ deepmerge(iidxScore, { internalId: "foo" }),
+ context,
+ "api/cg-dev-iidx",
+ log,
+ ),
+ ).rejects.toMatchObject({
+ message: expect.stringMatching(
+ /.*invalid input syntax for type integer.*foo/iu,
+ ) as string,
+ });
+ });
+});
diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts
new file mode 100644
index 000000000..80b4d0f9b
--- /dev/null
+++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/iidx/converter.ts
@@ -0,0 +1,436 @@
+import type { DryScore } from "#lib/score-import/framework/common/types";
+import type { ConverterFunction } from "#lib/score-import/import-types/common/types";
+import type { Difficulties, integer, ScoreMeta, Versions } from "tachi-common";
+import type { GetEnumValue } from "tachi-common/types/metrics";
+
+import {
+ InternalFailure,
+ InvalidScoreFailure,
+ SkipScoreFailure,
+ SongOrChartNotFoundFailure,
+} from "#lib/score-import/framework/common/converter-failures";
+import { ParseDateFromString } from "#lib/score-import/framework/common/score-utils";
+import { FindIIDXChartOnInGameIDVersion } from "#utils/queries/charts";
+import { FindSongOnID } from "#utils/queries/songs";
+
+import type { CGContext, CGIIDXScore } from "../types";
+
+import { FormatCGService } from "../util";
+
+export const ConverterAPICGIIDX: ConverterFunction = async (
+ data,
+ processContext,
+ importType,
+ log,
+) => {
+ const version = ConvertVersion(data.version);
+ const playtype = data.difficulty.startsWith("S") ? "SP" : "DP";
+ const game = playtype === "SP" ? "iidx-sp" : "iidx-dp";
+
+ let musicID = data.internalId;
+
+ if (data.difficulty === "SPB") {
+ throw new SkipScoreFailure(`We don't support BEGINNER charts. Sorry!`);
+ }
+
+ let difficulty = ConvertDifficulty(data.difficulty);
+
+ if (OldLeggendariaConversionTable[musicID] !== undefined) {
+ musicID = OldLeggendariaConversionTable[musicID]!;
+
+ // This is now definitely a leggendaria.
+ difficulty = "LEGGENDARIA";
+ }
+
+ const chart = await FindIIDXChartOnInGameIDVersion(game, musicID, difficulty, version);
+
+ if (!chart) {
+ throw new SongOrChartNotFoundFailure(
+ `Could not find chart with songID ${musicID} (${playtype} ${difficulty} - Version ${version})`,
+ importType,
+ data,
+ processContext,
+ );
+ }
+
+ const song = await FindSongOnID("iidx", chart.song.id);
+
+ if (!song) {
+ log.fatal(`Song-Chart desync with song ID ${chart.song.id} (iidx).`);
+ throw new InternalFailure(`Song-Chart desync with song ID ${chart.song.id} (iidx).`);
+ }
+
+ const dryScore: DryScore<"iidx-dp" | "iidx-sp"> = {
+ comment: null,
+ game: game,
+ importType,
+ timeAchieved: ParseDateFromString(data.dateTime),
+ service: FormatCGService(processContext.service),
+ scoreData: {
+ score: data.exScore,
+ lamp: ConvertLamp(data.clearType),
+ judgements: {
+ pgreat: data.perfectCount,
+ great: data.greatCount,
+ },
+ optional: {
+ bp: data.missCount,
+ gaugeHistory: ConvertGhostGauge(data.ghostGauge),
+ },
+ },
+ scoreMeta: ConvertOptions(data),
+ };
+
+ return { song, chart, dryScore };
+};
+
+// LEGGENDARIAs got turned into a real difficulty as of HEROIC VERSE.
+// This service still sends the old songIDs when old scores are requested,
+// so we need to convert it up.
+const OldLeggendariaConversionTable: Record = {
+ // RUGGED ASH† -> RUGGED ASH
+ 1100: 1017,
+
+ // Clione† -> Clione
+ 4100: 4005,
+
+ // ABSOLUTE† -> ABSOLUTE
+ 4101: 4001,
+
+ // RIDE ON THE LIGHT (HI GREAT MIX) † -> RIDE ON THE LIGHT (HI GREAT MIX)
+ 5100: 5014,
+
+ // RED ZONE† -> RED ZONE
+ 11100: 11032,
+
+ // spiral galaxy† -> spiral galaxy
+ 11101: 11012,
+
+ // Little Little Princess† -> Little Little Princess
+ 12100: 12002,
+
+ // CONTRACT† -> CONTRACT
+ 13100: 13010,
+
+ // VANESSA† -> VANESSA
+ 14100: 14009,
+
+ // KAMAITACHI† -> KAMAITACHI
+ 14101: 14046,
+
+ // ICARUS† -> ICARUS
+ 15101: 15023,
+
+ // THE DEEP STRIKER† -> THE DEEP STRIKER
+ 15102: 15007,
+
+ // Blue Rain† -> Blue Rain
+ 15104: 15004,
+
+ // Wanna Party?† -> Wanna Party?
+ 15105: 15045,
+
+ // 凛として咲く花の如く† -> 凛として咲く花の如く
+ 16101: 16050,
+
+ // THANK YOU FOR PLAYING† -> THANK YOU FOR PLAYING
+ 16102: 16045,
+
+ // naughty girl@Queen's Palace† -> naughty girl@Queen's Palace
+ 16103: 16031,
+
+ // Kung-fu Empire† -> Kung-fu Empire
+ 16104: 16015,
+
+ // SOLID STATE SQUAD† -> SOLID STATE SQUAD
+ 17101: 17060,
+
+ // Golden Palms† -> Golden Palms
+ 18100: 18025,
+
+ // おおきなこえで† -> おおきなこえで
+ 18103: 18011,
+
+ // QUANTUM TELEPORTATION† -> QUANTUM TELEPORTATION
+ 19100: 19063,
+
+ // 朧† -> 朧
+ 20103: 20100,
+
+ // 仮想空間の旅人たち† -> 仮想空間の旅人たち
+ 20104: 20039,
+
+ // LUV CAN SAVE U† -> LUV CAN SAVE U
+ 20105: 20068,
+
+ // Howling† -> Howling
+ 20106: 20024,
+
+ // 龍と少女とデコヒーレンス† -> 龍と少女とデコヒーレンス
+ 20107: 20019,
+
+ // Close the World feat.a☆ru†LEGGENDARIA -> Close the World feat. a☆ru
+ 21100: 21012,
+
+ // Sigmund†LEGGENDARIA -> Sigmund
+ 21101: 21059,
+
+ // Ancient Scapes†LEGGENDARIA -> Ancient Scapes
+ 21102: 21069,
+
+ // invoker†LEGGENDARIA -> invoker
+ 21103: 21073,
+
+ // Feel The Beat†LEGGENDARIA -> Feel The Beat
+ 21104: 21052,
+
+ // 疾風迅雷†LEGGENDARIA -> 疾風迅雷
+ 21105: 21048,
+
+ // Verflucht†LEGGENDARIA -> Verflucht
+ 21106: 21050,
+
+ // 廿† -> 廿
+ 21107: 21029,
+
+ // CHRONO DIVER -NORNIR-† -> CHRONO DIVER -NORNIR-
+ 22101: 22008,
+
+ // chrono diver -fragment-† -> chrono diver -fragment-
+ 22102: 22013,
+
+ // 恋は白帯、サンシロー† -> 恋は白帯、サンシロー
+ 22103: 22024,
+
+ // Beat Radiance† -> Beat Radiance
+ 22104: 22027,
+
+ // 超青少年ノ為ノ超多幸ナ超古典的超舞曲† -> 超青少年ノ為ノ超多幸ナ超古典的超舞曲
+ 22105: 22031,
+
+ // EBONY & IVORY† -> EBONY & IVORY
+ 22106: 22089,
+
+ // Cosmic Cat† -> Cosmic Cat
+ 22107: 22006,
+
+ // Damage Per Second† -> Damage Per Second
+ 23100: 23054,
+
+ // STARLIGHT DANCEHALL† -> STARLIGHT DANCEHALL
+ 23101: 23031,
+
+ // Amazing Mirage† -> Amazing Mirage
+ 24100: 24041,
+
+ // 冬椿 ft. Kanae Asaba† -> 冬椿 ft. Kanae Asaba
+ 24101: 24011,
+};
+
+function ConvertOptions(data: CGIIDXScore): ScoreMeta["iidx-dp"] | ScoreMeta["iidx-sp"] {
+ if (data.version < 23) {
+ return {}; // Unknown if bits are the same in older versions
+ }
+
+ const opt1 = BigInt(data.option1);
+ const opt2 = BigInt(data.option2);
+ const isModern = data.version >= 27;
+
+ type Without = { [P in Exclude]?: never };
+ type XOR = (T | Without) & (U | Without);
+
+ type randoms = NonNullable;
+ type gauges = NonNullable;
+
+ const RANDOM_MASKS: Partial> = isModern
+ ? {
+ RANDOM: 1n << 39n,
+ "R-RANDOM": 1n << 40n,
+ "S-RANDOM": 1n << 41n,
+ MIRROR: 1n << 42n,
+ }
+ : {
+ RANDOM: 1n << 18n,
+ "R-RANDOM": 1n << 19n,
+ "S-RANDOM": 1n << 20n,
+ MIRROR: 1n << 21n,
+ };
+
+ const GAUGE_MASKS: Partial> = isModern
+ ? {
+ "ASSISTED EASY": 1n << 35n,
+ EASY: 1n << 36n,
+ HARD: 1n << 37n,
+ "EX-HARD": 1n << 38n,
+ }
+ : {
+ "ASSISTED EASY": 1n << 14n,
+ EASY: 1n << 15n,
+ HARD: 1n << 16n,
+ "EX-HARD": 1n << 17n,
+ };
+
+ // TODO: find bits for AUTO SCRATCH and LEGACY NOTE
+
+ // Helper to find which string keys match the bitmask
+ const getActiveOptions = (
+ val: bigint,
+ masks: Record,
+ ): Array> => {
+ const options = [];
+
+ for (const [name, mask] of Object.entries(masks)) {
+ if ((val & mask) !== 0n) {
+ options.push(name);
+ }
+ }
+
+ return options;
+ };
+
+ const scoreMeta: Partial = {};
+
+ // for Random options, if no options are selected we know NONRAN is used.
+ // and we know only one option can be selected.
+
+ scoreMeta.random = "NONRAN";
+
+ const randomOptions = getActiveOptions(opt1, RANDOM_MASKS);
+
+ if (randomOptions.length > 0) {
+ scoreMeta.random = randomOptions[0] as randoms;
+ }
+
+ // For DP random is an array [p1 side, p2 side]
+ if (data.difficulty.startsWith("D")) {
+ scoreMeta.random = [scoreMeta.random, "NONRAN"];
+ const rightSide = getActiveOptions(opt2, RANDOM_MASKS);
+
+ if (rightSide.length > 0) {
+ scoreMeta.random[1] = rightSide[0] as randoms;
+ }
+ }
+
+ // for Gauge options, if no options are selected we do NOT know the gauge type
+ // like Random only one Gauge can be selected
+
+ const gaugeOptions = getActiveOptions(opt1, GAUGE_MASKS);
+
+ if (gaugeOptions.length > 0) {
+ scoreMeta.gauge = gaugeOptions[0] as gauges;
+ }
+
+ return scoreMeta;
+}
+
+function ConvertGhostGauge(rawHex: string): Array | null {
+ // Sanity check, all ghostGauge data that we have seen is 1,536 characters.
+ if (rawHex.length !== 1536) {
+ return null;
+ }
+
+ const graph = [];
+ const MAX_VAL = 5000;
+ let failed = false;
+
+ for (let i = 0; i < rawHex.length; i += 4) {
+ const chunk = rawHex.slice(i, i + 4);
+ const bigEndianHex = chunk.slice(2, 4) + chunk.slice(0, 2);
+ const rawValue = parseInt(bigEndianHex, 16);
+
+ if (rawValue > MAX_VAL) {
+ failed = true; // We've encountered a status flag seemingly indicating death.
+ graph.push(0.0);
+ break;
+ }
+ const percentage = (rawValue / MAX_VAL) * 100;
+ graph.push(Number(percentage.toFixed(2)));
+ }
+
+ // Remove padding 0s at the end of a normal play.
+ if (!failed) {
+ let lastDataIndex = -1;
+ for (let i = graph.length - 1; i >= 0; i--) {
+ if (graph[i] > 0) {
+ lastDataIndex = i;
+ break;
+ }
+ }
+ return lastDataIndex === -1 ? null : graph.slice(0, lastDataIndex + 1);
+ }
+
+ return graph;
+}
+
+function ConvertDifficulty(difficulty: string): Difficulties["iidx-dp" | "iidx-sp"] {
+ switch (difficulty.charAt(2)) {
+ case "N":
+ return "NORMAL";
+ case "H":
+ return "HYPER";
+ case "A":
+ return "ANOTHER";
+ case "L":
+ return "LEGGENDARIA";
+ }
+
+ throw new InvalidScoreFailure(`Unknown difficulty ${difficulty}.`);
+}
+
+function ConvertLamp(lamp: integer): GetEnumValue<"iidx-dp" | "iidx-sp", "lamp"> {
+ switch (lamp) {
+ case 0:
+ return "NO PLAY";
+ case 1:
+ return "FAILED";
+ case 2:
+ return "ASSIST CLEAR";
+ case 3:
+ return "EASY CLEAR";
+ case 4:
+ return "CLEAR";
+ case 5:
+ return "HARD CLEAR";
+ case 6:
+ return "EX HARD CLEAR";
+ case 7:
+ return "FULL COMBO";
+ }
+
+ throw new InvalidScoreFailure(`Unknown lamp value ${lamp}.`);
+}
+
+function ConvertVersion(version: integer): Versions["iidx-dp" | "iidx-sp"] {
+ switch (version) {
+ case 20:
+ return "20";
+ case 21:
+ return "21";
+ case 22:
+ return "22";
+ case 23:
+ return "23";
+ case 24:
+ return "24";
+ case 25:
+ return "25";
+ case 26:
+ return "26";
+ case 27:
+ return "27-omni";
+ case 28:
+ return "28-omni";
+ case 29:
+ return "29-omni";
+ case 30:
+ return "30-omni";
+ case 31:
+ return "31-omni";
+ case 32:
+ return "32-omni";
+ case 33:
+ return "33";
+ }
+
+ throw new InvalidScoreFailure(`Unsupported version ${version}.`);
+}
diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts
index fed67bbb0..a2f8b4101 100644
--- a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts
+++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts
@@ -11,6 +11,7 @@ import { type APIImportTypes, FormatPrError, type integer, type V3Game } from "t
import type { ParserFunctionReturns } from "../types";
import type {
CGContext,
+ CGIIDXScore,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
@@ -22,6 +23,29 @@ import type {
import { FetchCGScores } from "./traverse-api";
import { CGGameToTachiGame, FormatCGService } from "./util";
+const PR_CG_IIDX = {
+ internalId: p.isPositiveInteger,
+ difficulty: "string",
+ version: p.isPositiveInteger,
+ exScore: p.isPositiveInteger,
+ clearType: p.isPositiveInteger,
+ perfectCount: p.isPositiveInteger,
+ greatCount: p.isPositiveInteger,
+
+ // missCount is null when it's not recorded, so we have to allow that.
+ missCount: p.or(p.isPositiveInteger, p.is(null)),
+
+ // unused
+ dead: p.any,
+
+ option1: p.isPositiveInteger,
+ option2: p.isPositiveInteger,
+ ghost: p.any,
+ ghostGauge: p.any,
+
+ dateTime: "string",
+};
+
const PR_CG_JUBEAT = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
@@ -97,6 +121,7 @@ const PR_CG_POPN = {
// given a CG game, what should the returned data look like?
const CG_SCHEMAS: Record = {
+ iidx: PR_CG_IIDX,
jb: PR_CG_JUBEAT,
msc: PR_CG_MUSECA,
sdvx: PR_CG_SDVX,
@@ -169,16 +194,19 @@ export function CreateCGParser(
};
}
+export const ParseCGDevIIDX = CreateCGParser("iidx", "dev", "api/cg-dev-iidx");
export const ParseCGDevMuseca = CreateCGParser("msc", "dev", "api/cg-dev-museca");
export const ParseCGDevSDVX = CreateCGParser("sdvx", "dev", "api/cg-dev-sdvx");
export const ParseCGDevJubeat = CreateCGParser("jb", "dev", "api/cg-dev-jubeat");
export const ParseCGDevPopn = CreateCGParser("popn", "dev", "api/cg-dev-popn");
+export const ParseCGGanIIDX = CreateCGParser("iidx", "dev", "api/cg-gan-iidx");
export const ParseCGGanMuseca = CreateCGParser("msc", "gan", "api/cg-gan-museca");
export const ParseCGGanSDVX = CreateCGParser("sdvx", "gan", "api/cg-gan-sdvx");
export const ParseCGGanJubeat = CreateCGParser("jb", "gan", "api/cg-gan-jubeat");
export const ParseCGGanPopn = CreateCGParser("popn", "gan", "api/cg-gan-popn");
+export const ParseCGNagIIDX = CreateCGParser("iidx", "dev", "api/cg-nag-iidx");
export const ParseCGNagMuseca = CreateCGParser("msc", "nag", "api/cg-nag-museca");
export const ParseCGNagSDVX = CreateCGParser("sdvx", "nag", "api/cg-nag-sdvx");
export const ParseCGNagJubeat = CreateCGParser("jb", "nag", "api/cg-nag-jubeat");
diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts
index 0aedb51b4..0bbf5a0e1 100644
--- a/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts
+++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/types.ts
@@ -1,6 +1,7 @@
import type { integer } from "tachi-common";
export type CGSupportedGames =
+ | "iidx"
| "jb" // jubeat
| "msc" // museca
| "popn"
@@ -24,6 +25,23 @@ export interface CGErrorResponse {
message: string;
}
+export interface CGIIDXScore {
+ version: integer;
+ internalId: integer;
+ difficulty: string;
+ exScore: integer;
+ clearType: integer;
+ perfectCount: integer;
+ greatCount: integer;
+ missCount: integer | null;
+ dead: integer;
+ ghost: string;
+ ghostGauge: string;
+ option1: integer;
+ option2: integer;
+ dateTime: string;
+}
+
export interface CGMusecaScore {
internalId: integer;
difficulty: integer;
diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts
index 684883c53..36a7b0db3 100644
--- a/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts
+++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/util.ts
@@ -8,6 +8,7 @@ export function CGGameToTachiGame(cgGame: CGSupportedGames): GameGroup {
return "jubeat";
case "msc":
return "museca";
+ case "iidx":
case "popn":
case "sdvx":
return cgGame;
diff --git a/typescript/server/src/lib/score-import/import-types/common/types.ts b/typescript/server/src/lib/score-import/import-types/common/types.ts
index 5dba13873..4762bf03c 100644
--- a/typescript/server/src/lib/score-import/import-types/common/types.ts
+++ b/typescript/server/src/lib/score-import/import-types/common/types.ts
@@ -32,6 +32,7 @@ import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import type { IRUSCContext } from "../ir/usc/types";
import type {
CGContext,
+ CGIIDXScore,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
@@ -78,16 +79,19 @@ export interface ImportTypeDataMap {
"api/cg-dev-museca": CGMusecaScore;
"api/cg-dev-popn": CGPopnScore;
"api/cg-dev-jubeat": CGJubeatScore;
+ "api/cg-dev-iidx": CGIIDXScore;
"api/cg-nag-sdvx": CGSDVXScore;
"api/cg-nag-museca": CGMusecaScore;
"api/cg-nag-popn": CGPopnScore;
"api/cg-nag-jubeat": CGJubeatScore;
+ "api/cg-nag-iidx": CGIIDXScore;
"api/cg-gan-sdvx": CGSDVXScore;
"api/cg-gan-museca": CGMusecaScore;
"api/cg-gan-popn": CGPopnScore;
"api/cg-gan-jubeat": CGJubeatScore;
+ "api/cg-gan-iidx": CGIIDXScore;
}
export interface ImportTypeContextMap {
@@ -124,14 +128,17 @@ export interface ImportTypeContextMap {
"api/cg-dev-sdvx": CGContext;
"api/cg-dev-museca": CGContext;
"api/cg-dev-popn": CGContext;
+ "api/cg-dev-iidx": CGContext;
"api/cg-nag-jubeat": CGContext;
"api/cg-nag-sdvx": CGContext;
"api/cg-nag-museca": CGContext;
"api/cg-nag-popn": CGContext;
+ "api/cg-nag-iidx": CGContext;
"api/cg-gan-jubeat": CGContext;
"api/cg-gan-sdvx": CGContext;
"api/cg-gan-museca": CGContext;
"api/cg-gan-popn": CGContext;
+ "api/cg-gan-iidx": CGContext;
}
export interface OrphanScoreDocument {
diff --git a/typescript/server/src/lib/score-import/import-types/converters.ts b/typescript/server/src/lib/score-import/import-types/converters.ts
index 66845d291..02a6a7100 100644
--- a/typescript/server/src/lib/score-import/import-types/converters.ts
+++ b/typescript/server/src/lib/score-import/import-types/converters.ts
@@ -8,6 +8,7 @@ import ConvertAPIMytChunithm from "./api/myt-chunithm/converter";
import ConvertAPIMytMaimaiDx from "./api/myt-maimaidx/converter";
import ConvertAPIMytOngeki from "./api/myt-ongeki/converter";
import ConvertAPIMytWACCA from "./api/myt-wacca/converter";
+import { ConverterAPICGIIDX } from "./common/api-cg/iidx/converter";
import { ConverterAPICGJubeat } from "./common/api-cg/jb/converter";
import { ConverterAPICGMuseca } from "./common/api-cg/museca/converter";
import { ConverterAPICGPopn } from "./common/api-cg/popn/converter";
@@ -70,12 +71,15 @@ export const Converters: ConverterMap = {
"api/cg-dev-museca": ConverterAPICGMuseca,
"api/cg-dev-popn": ConverterAPICGPopn,
"api/cg-dev-jubeat": ConverterAPICGJubeat,
+ "api/cg-dev-iidx": ConverterAPICGIIDX,
"api/cg-nag-sdvx": ConverterAPICGSDVX,
"api/cg-nag-museca": ConverterAPICGMuseca,
"api/cg-nag-popn": ConverterAPICGPopn,
"api/cg-nag-jubeat": ConverterAPICGJubeat,
+ "api/cg-nag-iidx": ConverterAPICGIIDX,
"api/cg-gan-sdvx": ConverterAPICGSDVX,
"api/cg-gan-museca": ConverterAPICGMuseca,
"api/cg-gan-popn": ConverterAPICGPopn,
"api/cg-gan-jubeat": ConverterAPICGJubeat,
+ "api/cg-gan-iidx": ConverterAPICGIIDX,
};
diff --git a/typescript/server/src/lib/score-import/import-types/parsers.ts b/typescript/server/src/lib/score-import/import-types/parsers.ts
index b91f671b8..7ff7ac050 100644
--- a/typescript/server/src/lib/score-import/import-types/parsers.ts
+++ b/typescript/server/src/lib/score-import/import-types/parsers.ts
@@ -8,14 +8,17 @@ import ParseMytMaimaiDx from "./api/myt-maimaidx/parser";
import ParseMytOngeki from "./api/myt-ongeki/parser";
import ParseMytWACCA from "./api/myt-wacca/parser";
import {
+ ParseCGDevIIDX,
ParseCGDevJubeat,
ParseCGDevMuseca,
ParseCGDevPopn,
ParseCGDevSDVX,
+ ParseCGGanIIDX,
ParseCGGanJubeat,
ParseCGGanMuseca,
ParseCGGanPopn,
ParseCGGanSDVX,
+ ParseCGNagIIDX,
ParseCGNagJubeat,
ParseCGNagMuseca,
ParseCGNagPopn,
@@ -61,16 +64,19 @@ export const Parsers = {
"api/cg-dev-popn": ParseCGDevPopn,
"api/cg-dev-museca": ParseCGDevMuseca,
"api/cg-dev-jubeat": ParseCGDevJubeat,
+ "api/cg-dev-iidx": ParseCGDevIIDX,
"api/cg-nag-sdvx": ParseCGNagSDVX,
"api/cg-nag-popn": ParseCGNagPopn,
"api/cg-nag-museca": ParseCGNagMuseca,
"api/cg-nag-jubeat": ParseCGNagJubeat,
+ "api/cg-nag-iidx": ParseCGNagIIDX,
"api/cg-gan-sdvx": ParseCGGanSDVX,
"api/cg-gan-popn": ParseCGGanPopn,
"api/cg-gan-museca": ParseCGGanMuseca,
"api/cg-gan-jubeat": ParseCGGanJubeat,
+ "api/cg-gan-iidx": ParseCGGanIIDX,
"api/myt-chunithm": ParseMytChunithm,
"api/myt-maimaidx": ParseMytMaimaiDx,
diff --git a/typescript/server/src/test-utils/test-data.ts b/typescript/server/src/test-utils/test-data.ts
index 2bcd29a10..939955a4d 100644
--- a/typescript/server/src/test-utils/test-data.ts
+++ b/typescript/server/src/test-utils/test-data.ts
@@ -636,6 +636,12 @@ export const Testing511SPA: ChartDocument<"iidx-sp"> = {
"26-omni",
"27",
"26",
+ "25",
+ "24",
+ "23",
+ "22",
+ "21",
+ "20",
"inf",
"16-cs",
"12-cs",
@@ -643,6 +649,21 @@ export const Testing511SPA: ChartDocument<"iidx-sp"> = {
"8-cs",
"7-cs",
"bmus",
+ "28",
+ "28-omni",
+ "29",
+ "27-2dxtra",
+ "28-2dxtra",
+ "30",
+ "29-omni",
+ "30-omni",
+ "31",
+ "30-2dxtra",
+ "31-omni",
+ "32",
+ "31-2dxtra",
+ "33",
+ "32-omni",
],
};