// Download and decrypt selected Hololive Dreams SUS resources from Octo CDN. // Kept dependency-free so chart collection does not depend on a local Python runtime. import { createDecipheriv, createHash } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { prepareHolodoriDbSource } from './holodoridb-source.mjs'; import { susScoreWeight } from './sus-score-weight.mjs'; const OCTO_API_KEY = 'B46OtKlGGHoz6sxbOWDe3VUvBsagXxr5av38IQIKUKo='; const OCTO_CLIENT_KEY = 'CwFhQ+S5m4nERWVaq5oFZIP0cZLc0j7O/zllG0UYVNo='; const OCTO_LIST_URL = 'https://us.game-hololive-dreams.com/asset/v2/pub/a/5/v/200001/list/0'; const resourceMagic = Buffer.from('QUAVMAGIC'); function readVarint(bytes, offset) { let value = 0; let shift = 0; while (offset < bytes.length) { const current = bytes[offset++]; value += (current & 0x7f) * (2 ** shift); if ((current & 0x80) === 0) return [value, offset]; shift += 7; } throw new Error('잘린 Octo protobuf입니다.'); } function protobufFields(bytes) { const fields = []; for (let offset = 0; offset < bytes.length;) { const [tag, afterTag] = readVarint(bytes, offset); offset = afterTag; const field = tag >> 3; const wireType = tag & 7; let value; if (wireType === 0) [value, offset] = readVarint(bytes, offset); else if (wireType === 2) { let length; [length, offset] = readVarint(bytes, offset); value = bytes.subarray(offset, offset + length); offset += length; } else if (wireType === 1) { value = bytes.subarray(offset, offset + 8); offset += 8; } else if (wireType === 5) { value = bytes.subarray(offset, offset + 4); offset += 4; } else throw new Error(`지원하지 않는 protobuf wire type: ${wireType}`); fields.push([field, value]); } return fields; } function decodeEntry(bytes) { const entry = {}; for (const [field, value] of protobufFields(bytes)) { if (field === 2) entry.name = Buffer.from(value).toString('utf8'); else if (field === 7) entry.objectName = Buffer.from(value).toString('utf8'); } return entry; } function decryptCatalog(encrypted) { const key = createHash('sha256').update(OCTO_API_KEY).digest(); const decipher = createDecipheriv('aes-256-cbc', key, encrypted.subarray(0, 16)); const plaintext = Buffer.concat([decipher.update(encrypted.subarray(16)), decipher.final()]); const entries = []; let urlFormat = ''; let revisionId = null; for (const [field, value] of protobufFields(plaintext)) { if (field === 1) revisionId = value; else if (field === 3) entries.push(decodeEntry(value)); else if (field === 4) urlFormat = Buffer.from(value).toString('utf8'); } if (!urlFormat || revisionId === null) throw new Error('Octo 카탈로그 형식이 바뀌었습니다.'); return { revisionId, urlFormat, entries }; } function resourceMask(name) { // Exact equivalent of holodori-asset-tools/crypto/mask.py. const mask = Buffer.alloc(name.length * 2); for (let index = 0; index < name.length; index += 1) { const character = name.charCodeAt(index) & 0xff; mask[index * 2] = character; mask[mask.length - 1 - index * 2] = (~character) & 0xff; } let hash = 0x7c; for (const byte of mask) hash = ((((hash & 1) << 7) | (hash >> 1)) ^ byte) & 0xff; return Buffer.from(mask.map((byte) => byte ^ hash)); } function decryptResource(bytes, name) { if (!bytes.subarray(0, resourceMagic.length).equals(resourceMagic)) return bytes; const mask = resourceMask(name); const output = Buffer.from(bytes.subarray(resourceMagic.length)); for (let index = 0; index < Math.min(256, output.length); index += 1) output[index] ^= mask[index % mask.length]; return output; } const requested = process.argv.slice(2); if (requested.length === 0) throw new Error('SUS 파일명, 곡 ID 또는 --all을 지정하세요.'); const collectAll = requested.length === 1 && requested[0] === '--all'; if (!collectAll && requested.some((value) => !/^m\d{4}$|^chart_m\d{4}_(easy|normal|hard|expert)\.sus$/.test(value))) { throw new Error('잘못된 SUS 수집 대상입니다.'); } // Pin every reviewed locale before downloading. Never use stale refs after a failed fetch. const sources = Object.fromEntries(['eng', 'kor', 'jpn'].map((locale) => [locale, prepareHolodoriDbSource(locale)])); const requestedNames = new Set(requested.flatMap((value) => /^m\d{4}$/.test(value) ? ['easy', 'normal', 'hard', 'expert'].map((difficulty) => `chart_${value}_${difficulty}.sus`) : [value] )); const catalogResponse = await fetch(OCTO_LIST_URL, { headers: { 'X-OCTO-KEY': OCTO_API_KEY, 'X-APP-OCTO-KEY': OCTO_CLIENT_KEY } }); if (!catalogResponse.ok) throw new Error(`Octo 카탈로그 요청 실패: ${catalogResponse.status}`); const catalog = decryptCatalog(Buffer.from(await catalogResponse.arrayBuffer())); const selected = catalog.entries.filter((entry) => collectAll ? /^chart_m\d{4}_(easy|normal|hard|expert)\.sus$/.test(entry.name) : requestedNames.has(entry.name)); if (!collectAll && selected.length !== requestedNames.size) { const found = new Set(selected.map((entry) => entry.name)); throw new Error(`카탈로그에 없는 SUS: ${[...requestedNames].filter((name) => !found.has(name)).join(', ')}`); } const outputDirectory = path.resolve('data/raw/sus'); await mkdir(outputDirectory, { recursive: true }); if (!selected.length || new Set(selected.map((entry) => entry.name)).size !== selected.length) throw new Error('비어 있거나 중복된 SUS 카탈로그입니다.'); const results = []; let cursor = 0; console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 수집 시작`); async function worker() { while (cursor < selected.length) { const entry = selected[cursor++]; const url = catalog.urlFormat.replace('{o}', entry.objectName); let error; for (let attempt = 0; attempt < 3; attempt += 1) { try { const response = await fetch(url, { signal: AbortSignal.timeout(120000) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const plaintext = decryptResource(Buffer.from(await response.arrayBuffer()), entry.name); const text = plaintext.toString('utf8'); if (!text.startsWith('#CREATE_')) throw new Error('복호화 결과가 SUS가 아닙니다.'); const songId = text.match(/^#MUSIC_ID\s+(\S+)/m)?.[1]; if (songId !== entry.name.split('_')[1]) throw new Error('SUS 곡 ID가 파일명과 다릅니다.'); let weight = null; let validationError = null; try { weight = susScoreWeight(text); } catch (validation) { validationError = validation.message; } await writeFile(path.join(outputDirectory, entry.name), plaintext); results.push({ name: entry.name, url, bytes: plaintext.length, sha256: createHash('sha256').update(plaintext).digest('hex'), weight, validationError }); error = null; break; } catch (failure) { error = failure; } } if (error) results.push({ name: entry.name, url, error: error.message }); if (results.length % 50 === 0 || results.length === selected.length) console.log(`${results.length}/${selected.length}개 처리`); } } await Promise.all(Array.from({ length: 6 }, worker)); results.sort((a, b) => a.name.localeCompare(b.name)); await writeFile(path.join(outputDirectory, collectAll ? 'collection-manifest.json' : 'selected-collection-manifest.json'), JSON.stringify({ collectedAt: new Date().toISOString(), revisionId: catalog.revisionId, sources, files: results }, null, 2)); const failed = results.filter((result) => result.error); if (failed.length) throw new Error(`SUS ${failed.length}개 수집 실패: ${failed.map((result) => result.name).join(', ')}`); console.log(`Octo revision ${catalog.revisionId}: ${results.length}개 SUS 저장 완료, 점수 구조 검증 경고 ${results.filter((result) => result.validationError).length}개`);