// Convert Hololive Dreams' decrypted SUS dialect to this application's compact score timeline. import { readFile, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { readHolodoriDbTable as readMaster } from './holodoridb-source.mjs'; import path from 'node:path'; import { susScoreWeight } from './sus-score-weight.mjs'; import { formulaScoreMetadata } from './song-score-formulas.mjs'; import { parseSusTimeline } from './sus-timeline.mjs'; const difficulties = ['easy', 'normal', 'hard', 'expert']; const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; })); const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map((row) => { const data = row.data ?? row; return [data.id, data.text]; })); const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map((row) => { const data = row.data ?? row; return [`${data.musicId}.${data.difficultyType.split('_').at(-1).toLowerCase()}`, data.difficultyLevel]; })); const songIds = process.argv.slice(2); if (songIds.length === 0) throw new Error('곡 ID 또는 곡 ID.난이도를 지정하세요.'); const selected = songIds.flatMap((value) => { const match = value.match(/^(m\d{4})(?:\.(easy|normal|hard|expert))?$/); if (!match) throw new Error(`잘못된 채보 선택: ${value}`); return (match[2] ? [match[2]] : difficulties).map((difficulty) => ({ songId: match[1], difficulty })); }); const generatedCatalogEntries = []; const outputs = []; for (const { songId, difficulty } of selected) { const file = path.resolve(`data/raw/sus/chart_${songId}_${difficulty}.sus`); const bytes = await readFile(file); const timeline = parseSusTimeline(bytes.toString('utf8')); const weight = susScoreWeight(bytes.toString('utf8')); if (timeline.songId !== songId || timeline.notes.length !== weight.fullComboNoteCount) { const mids = timeline.notes.filter((note) => note.type === 'mid').length; throw new Error(`${songId}.${difficulty}: 파서 노트 수 ${timeline.notes.length}/${weight.fullComboNoteCount} 불일치 (중간 ${mids}, 헤더 ${weight.counts.LONG_RELAY + weight.counts.LONG_CONTINUE})`); } const master = music.get(songId); const difficultyLevel = levels.get(`${songId}.${difficulty}`); const susSha256 = createHash('sha256').update(bytes).digest('hex'); const formula = formulaScoreMetadata(songId, difficulty, difficultyLevel, timeline.notes.length, master.liveScoreCoefficientPermil, susSha256); const output = { schemaVersion: 1, songId, difficulty, durationSeconds: master.playingSeconds, scoreRatio: formula?.scoreRatio ?? null, scoreCalibrationSource: formula?.scoreCalibrationSource ?? null, ...(formula ?? {}), notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds, singerType: master.musicSingerType?.split('_').at(-1).toLowerCase() ?? null, singerCharacterIds: master.characterIds ?? [], source: { page: 'Hololive Dreams CDN via holodori-asset-tools', transcriptionStatus: 'exact_from_decrypted_sus', susSha256 } }; outputs.push({ filename: path.resolve(`static/data/charts/${songId}.${difficulty}.json`), output }); generatedCatalogEntries.push({ songId, title: translation.get(master.titleLangId) ?? songId, difficulty, difficultyLevel, durationSeconds: master.playingSeconds, scoreRatio: output.scoreRatio, scoreCalibrationSource: output.scoreCalibrationSource, scoreFormula: output.scoreFormula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime: Number(master.startTime), path: `/data/charts/${songId}.${difficulty}.json` }); console.log(`${songId}.${difficulty}: ${timeline.notes.length} notes ${timeline.specialStartSeconds.join(', ')}`); } const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json'); const catalog = JSON.parse(await readFile(catalogPath, 'utf8')); const generatedKeys = new Set(generatedCatalogEntries.map((entry) => `${entry.songId}.${entry.difficulty}`)); catalog.charts = [...catalog.charts.filter((entry) => !generatedKeys.has(`${entry.songId}.${entry.difficulty}`)), ...generatedCatalogEntries] .sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty)); // Finish validation of every selected chart before writing any service file. for (const { filename, output } of outputs) await writeFile(filename, JSON.stringify(output)); await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);