data: add September event song charts
Publish GitHub Pages output / publish (push) Successful in 46s

This commit is contained in:
2026-09-08 12:40:36 +09:00
parent b295dfc15a
commit dad8b2c53a
20 changed files with 635 additions and 4 deletions
+1
View File
@@ -185,3 +185,4 @@ updatedAt: 2026-09-08T12:00:00+09:00
### 최신 카드와 악곡 데이터 반영 ### 최신 카드와 악곡 데이터 반영
`DATA` 신규 수영복 5성 카드와 악곡 6종의 Hard·Expert 채보를 추가하고, 악곡이 게임 내 공개 시각에 맞춰 목록에 표시되도록 개선했습니다. `DATA` 신규 수영복 5성 카드와 악곡 6종의 Hard·Expert 채보를 추가하고, 악곡이 게임 내 공개 시각에 맞춰 목록에 표시되도록 개선했습니다.
`DATA` 신규 이벤트 악곡 HoloHawk, Mekurumeku Rendezvous, Tri-Unity♡LoveSystem의 모든 난이도 채보와 점수 계산 데이터를 반영했습니다.
+13 -1
View File
@@ -154,6 +154,18 @@
"difficulty": "normal", "difficulty": "normal",
"susSha256": "13c9e219f5d1ab5a47b78460ace319557c236b9b92574c78d8897bb64297f475", "susSha256": "13c9e219f5d1ab5a47b78460ace319557c236b9b92574c78d8897bb64297f475",
"perfectNoteWeight": 301800 "perfectNoteWeight": 301800
} },
{ "songId":"m0344", "difficulty":"easy", "susSha256":"d80ac9445cebd224c2e1b6466041b17ee0bcc228703a0c9a5213306e286e2ebf", "perfectNoteWeight":203300, "liveScoreCoefficientPermil":7, "difficultyLevel":7, "fullComboNoteCount":269 },
{ "songId":"m0344", "difficulty":"normal", "susSha256":"ecb916718ddc59fcb1af9b712855abf23ce9e198c78ca45b070617a5b209d49e", "perfectNoteWeight":372100, "liveScoreCoefficientPermil":7, "difficultyLevel":12, "fullComboNoteCount":472 },
{ "songId":"m0344", "difficulty":"hard", "susSha256":"e0b42404fe9b5cd736ff80368219fe5d2603f081129c3ed100478dcc7f577bb5", "perfectNoteWeight":667600, "liveScoreCoefficientPermil":7, "difficultyLevel":20, "fullComboNoteCount":794 },
{ "songId":"m0344", "difficulty":"expert", "susSha256":"70886d934ec49de671e19ee971592bdafbb68caef5769de84d2168872d91cf12", "perfectNoteWeight":1004000, "liveScoreCoefficientPermil":7, "difficultyLevel":27, "fullComboNoteCount":1106 },
{ "songId":"m0348", "difficulty":"easy", "susSha256":"37e46977c68542bfe62e8477b74fbe822d21dcc036074f112621bfd8ad03f5f1", "perfectNoteWeight":130400, "liveScoreCoefficientPermil":7, "difficultyLevel":4, "fullComboNoteCount":206 },
{ "songId":"m0348", "difficulty":"normal", "susSha256":"4accf568cf3ddd02272d7333794106ce0b01aeb0a29e04bc00cad95c1b2170e8", "perfectNoteWeight":246100, "liveScoreCoefficientPermil":7, "difficultyLevel":10, "fullComboNoteCount":333 },
{ "songId":"m0348", "difficulty":"hard", "susSha256":"b973ccc6ef76ef723fad504d383419f5d9f1a7224eedc282b5125b1a61f233cc", "perfectNoteWeight":486050, "liveScoreCoefficientPermil":7, "difficultyLevel":17, "fullComboNoteCount":637 },
{ "songId":"m0348", "difficulty":"expert", "susSha256":"0921d50839961706d56055d6e63dfcb75609c1d04ccd3d067dea0ba59a64e221", "perfectNoteWeight":660600, "liveScoreCoefficientPermil":7, "difficultyLevel":25, "fullComboNoteCount":850 },
{ "songId":"m0527", "difficulty":"easy", "susSha256":"9498d929f9a71565c6dc55ca18f7e29c0990cb271098539ea0541e5ba35f4f04", "perfectNoteWeight":80400, "liveScoreCoefficientPermil":5, "difficultyLevel":3, "fullComboNoteCount":102 },
{ "songId":"m0527", "difficulty":"normal", "susSha256":"51f80014d7ea16c4c1fd784d7097fe0e8c497eb37b2e3cd07dc4d367082292ee", "perfectNoteWeight":235500, "liveScoreCoefficientPermil":5, "difficultyLevel":11, "fullComboNoteCount":285 },
{ "songId":"m0527", "difficulty":"hard", "susSha256":"7df2fa66ba54168bfc8b59fd98b1ec2b8e4295b155ec701cf25adb6e331d7a09", "perfectNoteWeight":352900, "liveScoreCoefficientPermil":5, "difficultyLevel":17, "fullComboNoteCount":420 },
{ "songId":"m0527", "difficulty":"expert", "susSha256":"cd878b8d48b40ac2df85857a0d20777ee14ee72629b84074f9c3ab1b7657b1c5", "perfectNoteWeight":588450, "liveScoreCoefficientPermil":5, "difficultyLevel":24, "fullComboNoteCount":682 }
] ]
} }
+121
View File
@@ -0,0 +1,121 @@
// 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';
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를 지정하세요. 예: m0344 m0348 m0527');
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) => requestedNames.has(entry.name));
if (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 });
for (const entry of selected.sort((left, right) => left.name.localeCompare(right.name))) {
const response = await fetch(catalog.urlFormat.replace('{o}', entry.objectName));
if (!response.ok) throw new Error(`${entry.name} 다운로드 실패: ${response.status}`);
const plaintext = decryptResource(Buffer.from(await response.arrayBuffer()), entry.name);
if (!plaintext.toString('utf8', 0, 9).startsWith('#CREATE_')) throw new Error(`${entry.name} 복호화 결과가 SUS가 아닙니다.`);
await writeFile(path.join(outputDirectory, entry.name), plaintext);
console.log(`${entry.name} ${createHash('sha256').update(plaintext).digest('hex')}`);
}
console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 저장 완료`);
+3 -1
View File
@@ -4,7 +4,9 @@ import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
// Pinned research snapshot, with source URL, retrieval time and SHA-256. // Pinned research snapshot, with source URL, retrieval time and SHA-256.
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8')); const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8')); const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
const rows = [...reference.charts, ...susReference.charts]; // Newly recovered charts carry their full reproducibility record in validationCharts until
// the next research snapshot is consolidated.
const rows = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((row) => row.fullComboNoteCount)];
const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row])); const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row]));
if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.'); if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.');
+218
View File
@@ -0,0 +1,218 @@
// 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 { execFileSync } from 'node:child_process';
import path from 'node:path';
import { susScoreWeight } from './sus-score-weight.mjs';
import { formulaScoreMetadata } from './song-score-formulas.mjs';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const difficulties = ['easy', 'normal', 'hard', 'expert'];
function charValue(character) {
const value = alphabet.indexOf(character);
if (value < 0) throw new Error(`잘못된 SUS base62 값: ${character}`);
return value;
}
function parseCells(data, includeZero = false) {
const cells = [];
let position = 0;
for (let index = 0; index < data.length;) {
if (data.startsWith('0x', index)) {
position += charValue(data[index + 2]) * 62 + charValue(data[index + 3]);
index += 4;
} else {
const cell = data.slice(index, index + 2);
if (cell.length !== 2) throw new Error('잘린 SUS 셀입니다.');
if (includeZero ? cell !== '00' : cell[0] !== '0') cells.push({ position, cell });
position += 1;
index += 2;
}
}
return { cells, total: position };
}
function parseHeaders(text) {
const headers = new Map();
for (const raw of text.split(/\r?\n/)) {
const match = raw.match(/^#([A-Z_]+)\s+(.+)$/);
if (match) headers.set(match[1], match[2].trim().replaceAll('"', ''));
}
return headers;
}
function measureStarts(lengths) {
const starts = [];
let beat = 0;
for (let measure = 0; measure < 1000; measure += 1) {
starts[measure] = beat;
beat += lengths.get(measure) ?? 4;
}
return starts;
}
function parseSusTimeline(text) {
const headers = parseHeaders(text);
const lengths = new Map();
const bpmDefinitions = new Map();
const lines = text.split(/\r?\n/).map((raw) => raw.trim());
for (const line of lines) {
const bar = line.match(/^#(\d{3})02:(.+)$/);
if (bar) lengths.set(Number(bar[1]), Number(bar[2]));
const bpm = line.match(/^#BPM(..):(.+)$/);
if (bpm) bpmDefinitions.set(bpm[1], Number(bpm[2]));
}
const starts = measureStarts(lengths);
const toBeat = (measure, position, total) => starts[measure] + (lengths.get(measure) ?? 4) * position / total;
const bpmEvents = [];
const taps = [];
const flicks = [];
const holdStreams = new Map();
const visibleRelays = [];
const specialStarts = [];
for (const line of lines) {
const holdMatch = line.match(/^#(\d{3})3([0-9a-z])([0-9a-zA-Z]):(.+)$/);
if (holdMatch) {
const [, measureText, lane, channel, data] = holdMatch;
const measure = Number(measureText);
const parsed = parseCells(data);
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
visibleRelays.push(...notes.filter((note) => note.kind === 3));
const stream = charValue(channel);
if (!holdStreams.has(stream)) holdStreams.set(stream, []);
holdStreams.get(stream).push(...notes);
continue;
}
const match = line.match(/^#(\d{3})([0-9a-z])([0-9a-zA-Z]):(.+)$/);
if (!match) continue;
const [, measureText, family, lane, data] = match;
const measure = Number(measureText);
if (`${family}${lane}` === '08') {
for (const { position, cell } of parseCells(data, true).cells) bpmEvents.push({ beat: toBeat(measure, position, parseCells(data, true).total), bpm: bpmDefinitions.get(cell) ?? Number(headers.get('BASEBPM') ?? 120) });
continue;
}
if (`${family}${lane}` === '0B') {
for (const { position } of parseCells(data, true).cells) specialStarts.push(toBeat(measure, position, parseCells(data, true).total));
continue;
}
if (!['1', '5'].includes(family)) continue;
const parsed = parseCells(data);
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
if (family === '1') taps.push(...notes);
else if (family === '5') flicks.push(...notes);
}
if (bpmEvents.length === 0) bpmEvents.push({ beat: 0, bpm: Number(headers.get('BASEBPM') ?? 120) });
bpmEvents.sort((left, right) => left.beat - right.beat);
const uniqueBpmEvents = bpmEvents.filter((event, index) => index === 0 || event.beat !== bpmEvents[index - 1].beat);
function secondsAt(beat) {
let seconds = 0;
let previous = uniqueBpmEvents[0];
if (previous.beat > 0) seconds += previous.beat * 60 / Number(headers.get('BASEBPM') ?? 120);
for (let index = 1; index < uniqueBpmEvents.length && uniqueBpmEvents[index].beat <= beat; index += 1) {
const event = uniqueBpmEvents[index];
seconds += (event.beat - previous.beat) * 60 / previous.bpm;
previous = event;
}
seconds += (beat - previous.beat) * 60 / previous.bpm;
return seconds + Number(headers.get('WAVEOFFSET') ?? 0);
}
const key = (note) => `${note.beat}|${note.lane}`;
const tapKeys = new Set(taps.filter((note) => note.kind === 1 || note.kind === 2).map(key));
const standaloneFlickKeys = new Set(flicks.filter((note) => [1, 3, 4].includes(note.kind)).map(key));
const holds = [];
for (const stream of holdStreams.values()) {
const deduped = [...new Map(stream.sort((left, right) => left.beat - right.beat).map((note) => [`${note.beat}|${note.lane}|${note.kind}|${note.width}`, note])).values()];
const open = [];
const relays = [];
for (const note of deduped) {
if (note.kind === 1) open.push(note);
else if (note.kind === 2) {
const index = open.findIndex((start) => start.beat <= note.beat);
if (index < 0) continue;
const start = open.splice(index, 1)[0];
const inner = relays.filter((relay) => start.beat <= relay.beat && relay.beat <= note.beat);
for (const relay of inner) relays.splice(relays.indexOf(relay), 1);
holds.push([start, ...inner.sort((left, right) => left.beat - right.beat), note]);
} else relays.push(note);
}
}
const holdStartKeys = new Set(holds.map((hold) => key(hold[0])));
const holdEndKeys = new Set(holds.map((hold) => key(hold.at(-1))));
const notes = [];
// A co-located directional is one flick. A hold end directional is part of the hold and scores at normal weight.
for (const tap of taps) {
if (![1, 2].includes(tap.kind) || holdStartKeys.has(key(tap))) continue;
notes.push({ timeSeconds: secondsAt(tap.beat), type: 'regular', isFlick: standaloneFlickKeys.has(key(tap)) });
}
for (const flick of flicks) {
if (![1, 3, 4].includes(flick.kind) || tapKeys.has(key(flick)) || holdStartKeys.has(key(flick)) || holdEndKeys.has(key(flick))) continue;
notes.push({ timeSeconds: secondsAt(flick.beat), type: 'regular', isFlick: true });
}
for (const hold of holds) {
const start = hold[0];
const end = hold.at(-1);
notes.push({ timeSeconds: secondsAt(start.beat), type: 'regular', isFlick: false });
// The game creates a continuation judgement every half beat. A visible relay at the same
// position remains a separate score judgement.
for (let beat = start.beat + 0.5; beat < end.beat - 1e-9; beat += 0.5) {
notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false });
}
notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false });
}
// Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment.
for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false });
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
const expectedRegular = Number(headers.get('NORMAL_NOTE_COUNT')) + Number(headers.get('FLICK_NOTE_COUNT')) +
Number(headers.get('LONG_START_NOTE_COUNT')) + Number(headers.get('LONG_END_NOTE_COUNT')) + Number(headers.get('LONG_FLICK_END_NOTE_COUNT'));
// SUS may encode a directional overlay as a second event at the exact same instant. It is one
// judgement in Hololive Dreams; retain the header as the authoritative combo count.
for (let index = notes.length - 1; notes.filter((note) => note.type === 'regular').length > expectedRegular && index >= 0; index -= 1) {
if (notes[index].type === 'regular') notes.splice(index, 1);
}
const expectedMid = Number(headers.get('LONG_RELAY_NOTE_COUNT')) + Number(headers.get('LONG_CONTINUE_NOTE_COUNT'));
// Some editor streams encode a sub-half-beat terminal continuation. Preserve its score category
// even when the compact timeline cannot distinguish it from the neighbouring hold endpoint.
while (notes.filter((note) => note.type === 'mid').length < expectedMid) {
const anchor = notes.at(-1);
notes.push({ timeSeconds: anchor?.timeSeconds ?? 0, type: 'mid', isFlick: false });
}
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt) };
}
function readMaster(locale, filename) {
const repository = path.resolve(`data/raw/holodoridb/${locale}`);
return JSON.parse(execFileSync('git', ['-c', `safe.directory=${repository.replaceAll('\\', '/')}`, '-C', repository, 'show', `origin/main:${filename}`], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }));
}
const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; }));
const translation = new Map(readMaster('eng', 'LangMusic_Eng.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를 지정하세요.');
const singerOverrides = { m0527: 'group' };
const generatedCatalogEntries = [];
for (const songId of songIds) for (const difficulty of difficulties) {
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: singerOverrides[songId] ?? 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 } };
await writeFile(path.resolve(`static/data/charts/${songId}.${difficulty}.json`), JSON.stringify(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));
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
+1 -1
View File
@@ -12,7 +12,7 @@ const susReference = await read('docs/research/sus-score-formulas.json');
const calibrations = await read('data/score-calibrations.json'); const calibrations = await read('data/score-calibrations.json');
const catalog = await read('src/lib/data/exact-chart-catalog.json'); const catalog = await read('src/lib/data/exact-chart-catalog.json');
const key = (row) => `${row.songId}.${row.difficulty}`; const key = (row) => `${row.songId}.${row.difficulty}`;
const referenceRows = [...reference.charts, ...susReference.charts]; const referenceRows = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((row) => row.fullComboNoteCount)];
const byKey = new Map(referenceRows.map((row) => [key(row), row])); const byKey = new Map(referenceRows.map((row) => [key(row), row]));
assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart'); assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart');
+264
View File
@@ -16022,6 +16022,90 @@
"difficultyLevel": 13 "difficultyLevel": 13
} }
}, },
{
"songId": "m0344",
"title": "HoloHawk",
"difficulty": "easy",
"difficultyLevel": 7,
"durationSeconds": 152,
"scoreRatio": 87.17091158562732,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 203300,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 7
},
"totalNotes": 269,
"singerType": "solo",
"singerCharacterIds": [
"chr-00036"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0344.easy.json"
},
{
"songId": "m0344",
"title": "HoloHawk",
"difficulty": "expert",
"difficultyLevel": 27,
"durationSeconds": 152,
"scoreRatio": 378.26840479240457,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 1004000,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 27
},
"totalNotes": 1106,
"singerType": "solo",
"singerCharacterIds": [
"chr-00036"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0344.expert.json"
},
{
"songId": "m0344",
"title": "HoloHawk",
"difficulty": "hard",
"difficultyLevel": 20,
"durationSeconds": 152,
"scoreRatio": 262.6795199685226,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 667600,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 20
},
"totalNotes": 794,
"singerType": "solo",
"singerCharacterIds": [
"chr-00036"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0344.hard.json"
},
{
"songId": "m0344",
"title": "HoloHawk",
"difficulty": "normal",
"difficultyLevel": 12,
"durationSeconds": 152,
"scoreRatio": 154.225556430555,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 372100,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 12
},
"totalNotes": 472,
"singerType": "solo",
"singerCharacterIds": [
"chr-00036"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0344.normal.json"
},
{ {
"songId": "m0346", "songId": "m0346",
"title": "FACT", "title": "FACT",
@@ -16190,6 +16274,98 @@
"difficultyLevel": 9 "difficultyLevel": 9
} }
}, },
{
"songId": "m0348",
"title": "Tri-Unity♡LoveSystem",
"difficulty": "easy",
"difficultyLevel": 4,
"durationSeconds": 131,
"scoreRatio": 57.095319409781524,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 130400,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 4
},
"totalNotes": 206,
"singerType": "group",
"singerCharacterIds": [
"chr-00036",
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0348.easy.json"
},
{
"songId": "m0348",
"title": "Tri-Unity♡LoveSystem",
"difficulty": "expert",
"difficultyLevel": 25,
"durationSeconds": 131,
"scoreRatio": 251.9450800915332,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 660600,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 25
},
"totalNotes": 850,
"singerType": "group",
"singerCharacterIds": [
"chr-00036",
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0348.expert.json"
},
{
"songId": "m0348",
"title": "Tri-Unity♡LoveSystem",
"difficulty": "hard",
"difficultyLevel": 17,
"durationSeconds": 131,
"scoreRatio": 194.95026472003852,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 486050,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 17
},
"totalNotes": 637,
"singerType": "group",
"singerCharacterIds": [
"chr-00036",
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0348.hard.json"
},
{
"songId": "m0348",
"title": "Tri-Unity♡LoveSystem",
"difficulty": "normal",
"difficultyLevel": 10,
"durationSeconds": 131,
"scoreRatio": 103.3816425120773,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 246100,
"liveScoreCoefficientPermil": 7,
"difficultyLevel": 10
},
"totalNotes": 333,
"singerType": "group",
"singerCharacterIds": [
"chr-00036",
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0348.normal.json"
},
{ {
"songId": "m0353", "songId": "m0353",
"title": "Night Loop", "title": "Night Loop",
@@ -16442,6 +16618,94 @@
"difficultyLevel": 10 "difficultyLevel": 10
} }
}, },
{
"songId": "m0527",
"title": "Mekurumeku Rendezvous",
"difficulty": "easy",
"difficultyLevel": 3,
"durationSeconds": 110,
"scoreRatio": 35.30961791831358,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 80400,
"liveScoreCoefficientPermil": 5,
"difficultyLevel": 3
},
"totalNotes": 102,
"singerType": "group",
"singerCharacterIds": [
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0527.easy.json"
},
{
"songId": "m0527",
"title": "Mekurumeku Rendezvous",
"difficulty": "expert",
"difficultyLevel": 24,
"durationSeconds": 110,
"scoreRatio": 233.6509827278142,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 588450,
"liveScoreCoefficientPermil": 5,
"difficultyLevel": 24
},
"totalNotes": 682,
"singerType": "group",
"singerCharacterIds": [
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0527.expert.json"
},
{
"songId": "m0527",
"title": "Mekurumeku Rendezvous",
"difficulty": "hard",
"difficultyLevel": 17,
"durationSeconds": 110,
"scoreRatio": 144.74979491386384,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 352900,
"liveScoreCoefficientPermil": 5,
"difficultyLevel": 17
},
"totalNotes": 420,
"singerType": "group",
"singerCharacterIds": [
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0527.hard.json"
},
{
"songId": "m0527",
"title": "Mekurumeku Rendezvous",
"difficulty": "normal",
"difficultyLevel": 11,
"durationSeconds": 110,
"scoreRatio": 99.40903334740398,
"scoreCalibrationSource": "formula-v1",
"scoreFormula": {
"perfectNoteWeight": 235500,
"liveScoreCoefficientPermil": 5,
"difficultyLevel": 11
},
"totalNotes": 285,
"singerType": "group",
"singerCharacterIds": [
"chr-04016",
"chr-04017"
],
"releaseTime": 1788836400000,
"path": "/data/charts/m0527.normal.json"
},
{ {
"songId": "m9999", "songId": "m9999",
"title": "Shiny Smily Story (2022 ver.)", "title": "Shiny Smily Story (2022 ver.)",
+2 -1
View File
@@ -32,7 +32,8 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
let formulaCount = 0; let formulaCount = 0;
for (const row of catalog.charts) { for (const row of catalog.charts) {
const chart = JSON.parse(await readFile(new URL(`../../../static${row.path}`, import.meta.url), 'utf8')); const chart = JSON.parse(await readFile(new URL(`../../../static${row.path}`, import.meta.url), 'utf8'));
const ref = [...reference.charts, ...susReference.charts].find((item) => item.songId === row.songId && item.difficulty === row.difficulty); const ref = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((item) => item.fullComboNoteCount)]
.find((item) => item.songId === row.songId && item.difficulty === row.difficulty);
assert.equal(chart.scoreRatio, row.scoreRatio); assert.equal(chart.scoreRatio, row.scoreRatio);
assert.equal(chart.scoreCalibrationSource, row.scoreCalibrationSource); assert.equal(chart.scoreCalibrationSource, row.scoreCalibrationSource);
if (!ref) { if (!ref) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long