fix: repair SUS timing and note classification for 18 charts
Publish GitHub Pages output / publish (push) Successful in 47s
Publish GitHub Pages output / publish (push) Successful in 47s
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Read-only audit: never writes service charts or pads/truncates the reference timeline.
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import vm from 'node:vm';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
const source = prepareHolodoriDbSource('kor', { review: true });
|
||||
const music = new Map(JSON.parse(readHolodoriDbText(source, 'Music.json')).map(({ data }) => [data.id, data]));
|
||||
const titles = new Map(JSON.parse(readHolodoriDbText(source, 'LangMusic_Kor.json')).map(({ data }) => [data.id, data.text]));
|
||||
const catalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
|
||||
// Keep the original faulty parser fixed for reproducible historical comparison.
|
||||
const converter = execFileSync('git', ['show', 'bb3573c:scripts/sus-to-exact-chart.mjs'], { encoding: 'utf8' });
|
||||
const parser = converter.slice(converter.indexOf('const alphabet'), converter.indexOf('const music ='));
|
||||
function replaceOnce(text, before, after) {
|
||||
if (!text.includes(before) || text.indexOf(before) !== text.lastIndexOf(before)) throw new Error('Converter changed: audit instrumentation needs review.');
|
||||
return text.replace(before, after);
|
||||
}
|
||||
let legacy = replaceOnce(parser, 'const notes = [];', 'const notes = []; let removed = 0, added = 0;');
|
||||
legacy = replaceOnce(legacy, 'notes.splice(index, 1);', '{ notes.splice(index, 1); removed++; }');
|
||||
legacy = replaceOnce(legacy, 'const anchor = notes.at(-1);', 'added++; const anchor = notes.at(-1);');
|
||||
legacy = replaceOnce(legacy, 'return { songId: headers.get', 'return { removed, added, songId: headers.get');
|
||||
// Diagnostic candidate only: remove the known endpoint double count, and expose unresolved
|
||||
// continuation discrepancies instead of manufacturing notes. This is NOT a proven game parser.
|
||||
let candidate = replaceOnce(parser, '|| holdStartKeys.has(key(tap)))', '|| holdStartKeys.has(key(tap)) || holdEndKeys.has(key(tap)))');
|
||||
candidate = replaceOnce(candidate, 'notes.splice(index, 1);', '{}');
|
||||
candidate = replaceOnce(candidate, "while (notes.filter((note) => note.type === 'mid').length < expectedMid)", 'if (false)');
|
||||
// SUS measure lengths remain in effect until the next change.
|
||||
candidate = replaceOnce(candidate, 'const starts = measureStarts(lengths);', 'let currentLength = 4; for (let m = 0; m < 1000; m++) { currentLength = lengths.get(m) ?? currentLength; lengths.set(m, currentLength); } const starts = measureStarts(lengths);');
|
||||
const legacyContext = vm.createContext({});
|
||||
const candidateContext = vm.createContext({});
|
||||
vm.runInContext(legacy, legacyContext);
|
||||
vm.runInContext(candidate, candidateContext);
|
||||
const counts = (notes) => ({ regular: notes.filter(n => n.type !== 'mid' && !n.isFlick).length, flick: notes.filter(n => n.isFlick).length, mid: notes.filter(n => n.type === 'mid').length });
|
||||
const sameCounts = (a, b) => Object.keys(a).every(k => a[k] === b[k]);
|
||||
// Match multisets within 1 microsecond, including simultaneous notes; array ordering is irrelevant.
|
||||
function differences(a, b) {
|
||||
const sort = notes => [...notes].sort((x,y) => x.timeSeconds-y.timeSeconds);
|
||||
const extra = [], missing = [];
|
||||
for (const kind of ['regular', 'flick', 'mid']) {
|
||||
const filter = n => (n.type === 'mid' ? 'mid' : n.isFlick ? 'flick' : 'regular') === kind;
|
||||
const left = sort(a.filter(filter)), right = sort(b.filter(filter));
|
||||
let i = 0, j = 0;
|
||||
while (i < left.length || j < right.length) {
|
||||
if (i < left.length && j < right.length && Math.abs(left[i].timeSeconds-right[j].timeSeconds) <= 1e-6) { i++; j++; }
|
||||
else if (j >= right.length || (i < left.length && left[i].timeSeconds < right[j].timeSeconds)) extra.push({ kind, time: left[i++].timeSeconds });
|
||||
else missing.push({ kind, time: right[j++].timeSeconds });
|
||||
}
|
||||
}
|
||||
return { extra, missing };
|
||||
}
|
||||
const rows = [];
|
||||
for (const entry of catalog.charts) {
|
||||
const bytes = await readFile(`data/raw/sus/chart_${entry.songId}_${entry.difficulty}.sus`);
|
||||
const text = bytes.toString('utf8');
|
||||
const chart = JSON.parse(await readFile(`static${entry.path}`, 'utf8'));
|
||||
const weight = susScoreWeight(text);
|
||||
const expected = { regular: weight.counts.NORMAL+weight.counts.LONG_START+weight.counts.LONG_END+weight.counts.LONG_FLICK_END, flick: weight.counts.FLICK, mid: weight.counts.LONG_RELAY+weight.counts.LONG_CONTINUE };
|
||||
legacyContext.input = candidateContext.input = text;
|
||||
const old = vm.runInContext('parseSusTimeline(input)', legacyContext);
|
||||
const reference = vm.runInContext('parseSusTimeline(input)', candidateContext);
|
||||
const legacyDiff = differences(chart.notes, old.notes);
|
||||
const diff = differences(chart.notes, reference.notes);
|
||||
const specialMismatch = chart.specialStartSeconds.length !== reference.specialStartSeconds.length || chart.specialStartSeconds.some((t,i) => Math.abs(t-reference.specialStartSeconds[i]) > 1e-6);
|
||||
rows.push({ songId: entry.songId, title: titles.get(music.get(entry.songId)?.titleLangId) ?? entry.title, difficulty: entry.difficulty,
|
||||
sourceHashMatches: chart.source.susSha256 === createHash('sha256').update(bytes).digest('hex'),
|
||||
expected, actual: counts(chart.notes), categoryMismatch: !sameCounts(expected, counts(chart.notes)),
|
||||
formulaWeight: { actual: chart.scoreFormula?.perfectNoteWeight, expected: weight.perfectNoteWeight },
|
||||
specialMismatch, actualSpecial: chart.specialStartSeconds, referenceSpecial: reference.specialStartSeconds,
|
||||
legacy: { matches: !legacyDiff.extra.length && !legacyDiff.missing.length, removed: old.removed, added: old.added },
|
||||
candidate: { counts: counts(reference.notes), headerCountsMatch: sameCounts(expected, counts(reference.notes)), ...diff }
|
||||
});
|
||||
}
|
||||
const summary = {
|
||||
charts: rows.length, songs: new Set(rows.map(r=>r.songId)).size,
|
||||
sourceHashMismatch: rows.filter(r=>!r.sourceHashMatches).length,
|
||||
categoryMismatch: rows.filter(r=>r.categoryMismatch).length,
|
||||
categoryMismatchByDifficulty: Object.fromEntries(['easy','normal','hard','expert'].map(d=>[d,rows.filter(r=>r.difficulty===d&&r.categoryMismatch).length])),
|
||||
formulaMismatch: rows.filter(r=>r.formulaWeight.actual!==r.formulaWeight.expected).length,
|
||||
specialMismatch: rows.filter(r=>r.specialMismatch).length,
|
||||
matchesLegacy: rows.filter(r=>r.legacy.matches).length,
|
||||
confirmedLegacySurgery: rows.filter(r=>r.legacy.matches && (r.legacy.removed || r.legacy.added)).length,
|
||||
candidateHeaderMatch: rows.filter(r=>r.candidate.headerCountsMatch).length,
|
||||
candidateTimelineMatch: rows.filter(r=>!r.candidate.extra.length&&!r.candidate.missing.length).length,
|
||||
candidateHeaderMatchButTimelineDiff: rows.filter(r=>r.candidate.headerCountsMatch&&(r.candidate.extra.length||r.candidate.missing.length)).length,
|
||||
candidateUnresolved: rows.filter(r=>!r.candidate.headerCountsMatch).length
|
||||
};
|
||||
await mkdir('data/sus-audit', { recursive: true });
|
||||
await writeFile('data/sus-audit/latest.json', JSON.stringify({generatedAt:new Date().toISOString(),source,summary,notes:'Candidate is diagnostic, not a verified replacement. Header agreement alone does not prove timing. Differences use a 1 microsecond tolerance.',rows},null,2));
|
||||
const lines = ['# 전체 SUS 채보 대조', '',
|
||||
`전체 ${summary.songs}곡·${summary.charts}개 난이도를 대조했습니다. 서비스 파일은 변경하지 않았습니다.`, '',
|
||||
'## 원본에서 직접 확인한 차이', '',
|
||||
`- 노트 종류별 개수 불일치: ${summary.categoryMismatch}개. 전체 노트 수가 같아도 일반·플릭·중간 판정 구성이 다릅니다.`,
|
||||
`- 원본 SHA-256 변경: ${summary.sourceHashMismatch}개. 기존 파일의 기록된 원본과 이번 수집본이 다릅니다.`,
|
||||
`- 점수 분모 변경: ${summary.formulaMismatch}개. Pretty Guardian Matsulimit Zero Hard 544300 → 539500, Expert 874750 → 884700.`,
|
||||
`- 스페셜 시각 차이: ${summary.specialMismatch}개. Pretty Guardian Matsulimit Zero 전 난이도.`, '',
|
||||
'## 타이밍 진단의 범위와 한계', '',
|
||||
'진단용 재구성은 롱노트 끝 중복 및 강제 삭제·추가를 제거하고, 마디 길이가 다음 변경까지 지속되도록 처리한 후보입니다. 게임의 완전한 정답 파서로 검증된 것은 아닙니다. 시각 오차 허용치는 1마이크로초입니다.',
|
||||
`- 후보와 종류·시각이 모두 일치: ${summary.candidateTimelineMatch}개. 이 비교 범위에서 차이가 없다는 뜻이며 게임 전체 동작의 보증은 아닙니다.`,
|
||||
`- 후보 종류별 개수는 원본 헤더와 맞지만 현재 타이밍과 차이: ${summary.candidateHeaderMatchButTimelineDiff}개. 상세 JSON에 초과·누락 시각을 기록했습니다.`,
|
||||
`- 후보의 종류별 개수가 헤더와 불일치: ${summary.candidateUnresolved}개. 특히 롱노트 중간 판정 재구성은 추가 조사가 필요하며 자동 교체하면 안 됩니다.`,
|
||||
`- 강제 삭제·추가를 수행한 기존 변환기 결과와 일치: ${summary.confirmedLegacySurgery}개. 변환 이력 자체를 증명하는 지표는 아닙니다.`,
|
||||
'- 각 지표는 중복되므로 합산해서 오류 채보 수로 해석하면 안 됩니다.',
|
||||
'- 타이밍과 스킬 구간의 관계에 따라 총점 오차 방향이 달라집니다. 특정 편성의 20만 점 차이를 이 보고서만으로 확정할 수 없습니다.', '',
|
||||
'## 집계', '', '```json', JSON.stringify(summary,null,2), '```', '', '## 전곡 결과', '', '|곡|난이도|종류 수 불일치|원본 해시 변경|기존 변환 일치 / 삭제 / 추가|후보 헤더 일치|후보 대비 초과 / 누락|스페셜 차이|', '|---|---|---|---|---|---|---|---|'];
|
||||
for (const r of rows) lines.push(`|${r.title}|${r.difficulty}|${r.categoryMismatch}|${!r.sourceHashMatches}|${r.legacy.matches} / ${r.legacy.removed} / ${r.legacy.added}|${r.candidate.headerCountsMatch}|${r.candidate.extra.length} / ${r.candidate.missing.length}|${r.specialMismatch}|`);
|
||||
await writeFile('data/sus-audit/latest.md', lines.join('\n')+'\n');
|
||||
console.log(JSON.stringify(summary,null,2));
|
||||
@@ -0,0 +1,88 @@
|
||||
// Fetch public wf-calc chart snapshots for comparison, without changing service data.
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import vm from 'node:vm';
|
||||
import { prepareHolodoriDbSource } from './holodoridb-source.mjs';
|
||||
|
||||
const source = prepareHolodoriDbSource('kor', { review: true });
|
||||
const origin = 'https://dreams.wf-calc.net';
|
||||
const directory = 'data/wf-audit';
|
||||
await mkdir(`${directory}/charts`, { recursive: true });
|
||||
async function get(url) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
||||
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
const home = await get(origin);
|
||||
const mainPath = home.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
|
||||
if (!mainPath) throw new Error('Main module not found');
|
||||
const main = await get(new URL(mainPath, origin));
|
||||
await writeFile(`${directory}/main.js`, main);
|
||||
const simulatorPath = main.match(/\.\/UnitSimulatorPage-([^"']+\.js)/)?.[1];
|
||||
if (!simulatorPath) throw new Error('Simulator module not found');
|
||||
const simulatorUrl = `${origin}/assets/UnitSimulatorPage-${simulatorPath}`;
|
||||
const simulator = await get(simulatorUrl);
|
||||
await writeFile(`${directory}/simulator.js`, simulator);
|
||||
const imports = [...simulator.matchAll(/charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g)].map(([,name,asset]) => {
|
||||
const match = name.match(/^(m\d{4})\.(easy|normal|hard|expert)-(\d+)\.json$/i);
|
||||
return match ? { songId: match[1], difficulty: match[2].toLowerCase(), level: Number(match[3]), name, asset } : null;
|
||||
}).filter(Boolean);
|
||||
if (!imports.length) throw new Error('No chart modules found');
|
||||
if (new Set(imports.map(i=>`${i.songId}.${i.difficulty}`)).size !== imports.length) throw new Error('Duplicate chart modules');
|
||||
// Reuse only the existing local data decoder/adapter; remote JS is never executed.
|
||||
const collector = await readFile('scripts/collect-exact-charts.mjs', 'utf8');
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(collector.slice(collector.indexOf('function requiredMatch'), collector.indexOf('function legacyCalibrationNotes')), context);
|
||||
const local = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
|
||||
const sus = JSON.parse(await readFile('data/sus-audit/latest.json', 'utf8'));
|
||||
if (sus.source.commit !== source.commit) throw new Error('SUS audit source changed: rerun it first');
|
||||
const rows = [];
|
||||
let cursor = 0;
|
||||
console.log(`wf-calc ${imports.length}개 채보 비교 시작`);
|
||||
const counts = notes => ({ regular:notes.filter(n=>n.type!=='mid'&&!n.isFlick).length, flick:notes.filter(n=>n.isFlick).length, mid:notes.filter(n=>n.type==='mid').length });
|
||||
function compare(a,b,kind=true) {
|
||||
let extra=0,missing=0;
|
||||
for(const type of kind?['regular','flick','mid']:['all']) {
|
||||
const filter=n=>type==='all'||(n.type==='mid'?'mid':n.isFlick?'flick':'regular')===type;
|
||||
const x=a.filter(filter).map(n=>n.timeSeconds).sort((a,b)=>a-b), y=b.filter(filter).map(n=>n.timeSeconds).sort((a,b)=>a-b);
|
||||
let i=0,j=0;
|
||||
while(i<x.length||j<y.length) {
|
||||
if(i<x.length&&j<y.length&&Math.abs(x[i]-y[j])<=1e-6){i++;j++;}
|
||||
else if(j>=y.length||(i<x.length&&x[i]<y[j])){extra++;i++;}else{missing++;j++;}
|
||||
}
|
||||
}
|
||||
return {extra,missing};
|
||||
}
|
||||
async function worker() {
|
||||
while(cursor<imports.length) {
|
||||
const item=imports[cursor++];
|
||||
const url=`${origin}/assets/${item.asset}`;
|
||||
const module=await get(url);
|
||||
context.input=module;
|
||||
const data=JSON.parse(vm.runInContext('extractJsonParseArgument(input)',context));
|
||||
await writeFile(`${directory}/charts/${item.songId}.${item.difficulty}.json`,JSON.stringify(data));
|
||||
context.input=data;
|
||||
const notes=vm.runInContext('flattenEvents(input)',context);
|
||||
const entry=local.charts.find(c=>c.songId===item.songId&&c.difficulty===item.difficulty);
|
||||
if(!entry){rows.push({...item,localMissing:true});continue;}
|
||||
const chart=JSON.parse(await readFile(`static${entry.path}`,'utf8'));
|
||||
const reference=sus.rows.find(r=>r.songId===item.songId&&r.difficulty===item.difficulty);
|
||||
const special=(data.sp_activation_events??[]).toSorted((a,b)=>a.slot-b.slot).map(e=>e.time_seconds);
|
||||
const category=counts(notes);
|
||||
const differentSpecial=other=>special.length!==other.length||special.some((t,i)=>Math.abs(t-other[i])>1e-6);
|
||||
rows.push({...item,title:reference.title,url,moduleSha256:createHash('sha256').update(module).digest('hex'),
|
||||
wfSource:data.source,localSource:chart.source,wfCounts:category,susExpected:reference.expected,
|
||||
wfCategoryMatchesSus:Object.keys(category).every(k=>category[k]===reference.expected[k]),
|
||||
localVsWf:compare(chart.notes,notes),localVsWfTimesOnly:compare(chart.notes,notes,false),
|
||||
wfSpecial:special,localSpecialMismatch:differentSpecial(chart.specialStartSeconds),susSpecialMismatch:differentSpecial(reference.referenceSpecial),
|
||||
metadataKeys:Object.keys(data).filter(k=>k!=='note_events')});
|
||||
if(rows.length%100===0||rows.length===imports.length)console.log(`${rows.length}/${imports.length}`);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:6},worker));
|
||||
rows.sort((a,b)=>a.songId.localeCompare(b.songId)||a.difficulty.localeCompare(b.difficulty));
|
||||
const missing=local.charts.filter(c=>!rows.some(r=>r.songId===c.songId&&r.difficulty===c.difficulty)).map(c=>({songId:c.songId,title:c.title,difficulty:c.difficulty}));
|
||||
const shared=rows.filter(r=>!r.localMissing);
|
||||
const summary={wfCharts:rows.length,shared:shared.length,missingOnWf:missing.length,localTimelineMatches:shared.filter(r=>!r.localVsWf.extra&&!r.localVsWf.missing).length,localTimesOnlyMatches:shared.filter(r=>!r.localVsWfTimesOnly.extra&&!r.localVsWfTimesOnly.missing).length,wfCategoryMismatchSus:shared.filter(r=>!r.wfCategoryMatchesSus).length,localSpecialMismatch:shared.filter(r=>r.localSpecialMismatch).length,wfSpecialMismatchSus:shared.filter(r=>r.susSpecialMismatch).length};
|
||||
await writeFile(`${directory}/latest.json`,JSON.stringify({generatedAt:new Date().toISOString(),source,simulatorUrl,summary,missingOnWf:missing,adapter:'Existing local flattenEvents adapter; adapter-derived classifications must not be attributed to the wf-calc scoring engine.',rows},null,2));
|
||||
console.log(JSON.stringify(summary,null,2));
|
||||
@@ -3,6 +3,8 @@
|
||||
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=';
|
||||
@@ -94,7 +96,13 @@ function decryptResource(bytes, name) {
|
||||
}
|
||||
|
||||
const requested = process.argv.slice(2);
|
||||
if (requested.length === 0) throw new Error('SUS 파일명 또는 곡 ID를 지정하세요. 예: m0344 m0348 m0527');
|
||||
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]
|
||||
));
|
||||
@@ -102,20 +110,48 @@ const requestedNames = new Set(requested.flatMap((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 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 });
|
||||
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')}`);
|
||||
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}개 처리`);
|
||||
}
|
||||
}
|
||||
console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 저장 완료`);
|
||||
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}개`);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const report = JSON.parse(await readFile('data/wf-audit/latest.json','utf8'));
|
||||
const audit = await readFile('scripts/audit-wf-charts.mjs','utf8');
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(audit.slice(audit.indexOf('const counts ='),audit.indexOf('async function worker()')),context);
|
||||
const simulator = await readFile('data/wf-audit/simulator.js','utf8');
|
||||
const catalogText=simulator.match(/\b\w+=JSON\.parse\(`(\[\{"key":"m[\s\S]*?)`\)/)?.[1];
|
||||
if(!catalogText)throw new Error('wf chart catalog not found');
|
||||
const catalog = JSON.parse(catalogText.replaceAll('\\\\','\\').replaceAll('\\`','`').replaceAll('\\${','${'));
|
||||
const collection=JSON.parse(await readFile('data/raw/sus/collection-manifest.json','utf8'));
|
||||
for(const row of report.rows) {
|
||||
const data=JSON.parse(await readFile(`data/wf-audit/charts/${row.songId}.${row.difficulty}.json`,'utf8'));
|
||||
const local=JSON.parse(await readFile(`static/data/charts/${row.songId}.${row.difficulty}.json`,'utf8'));
|
||||
const events=data.note_events;
|
||||
// Match pn() in the saved wf simulator: preserve its explicit note_type/is_flick.
|
||||
const native=[...(events.blue??[]).map(n=>({...n,type:'regular',isFlick:n.is_flick??false})),
|
||||
...(events.pink??[]).map(n=>({...n,type:'regular',isFlick:n.is_flick??true})),
|
||||
...(events.green_blocks??[]).flatMap(block=>block.map((n,i)=>({...n,type:n.note_type??(i===0||i===block.length-1?'regular':'mid'),isFlick:n.is_flick??false})))
|
||||
].map(n=>({timeSeconds:n.time_seconds,type:n.type,isFlick:n.isFlick}));
|
||||
context.left=local.notes;context.right=native;
|
||||
row.localVsWfNative=vm.runInContext('compare(left,right)',context);
|
||||
row.wfNativeCounts=vm.runInContext('counts(right)',context);
|
||||
row.wfNativeCategoryMatchesSus=Object.keys(row.susExpected).every(k=>row.susExpected[k]===row.wfNativeCounts[k]);
|
||||
row.wfSourceMatchesSus=row.wfSource.sha256===collection.files.find(f=>f.name===`chart_${row.songId}_${row.difficulty}.sus`).sha256;
|
||||
const metadata=catalog.find(c=>c.key===`${row.songId}::${row.difficulty.toUpperCase()}`);
|
||||
row.ratio={local:local.scoreRatio,wfCatalog:metadata?.scoreRatio,wfCatalogSource:metadata?.scoreCalibrationSource};
|
||||
}
|
||||
report.summary.wfNativeCategoryMismatchSus=report.rows.filter(r=>!r.wfNativeCategoryMatchesSus).length;
|
||||
report.summary.localVsWfNativeMatches=report.rows.filter(r=>!r.localVsWfNative.extra&&!r.localVsWfNative.missing).length;
|
||||
report.summary.wfSourceHashMismatchSus=report.rows.filter(r=>!r.wfSourceMatchesSus).length;
|
||||
await writeFile('data/wf-audit/latest.json',JSON.stringify(report,null,2));
|
||||
const lines=['# wf-calc 전체 채보 비교','',`비교 시각: ${report.generatedAt}`,`스냅샷: ${report.simulatorUrl}`,'',
|
||||
'## 해석','',
|
||||
'- wf 원본: 사이트의 pn() 함수처럼 note_type/is_flick을 그대로 읽은 데이터입니다. 사이트의 최종 점수 엔진 전체를 재현했다는 뜻은 아닙니다.',
|
||||
'- 우리 어댑터: 기존 수집기의 flattenEvents를 거친 결과입니다. 이 단계의 분류 차이를 wf 자체 오류라고 부르면 안 됩니다.',
|
||||
'- 시간 비교 허용치는 1마이크로초이며 동시 노트는 개수까지 비교합니다.',
|
||||
'- wf 카탈로그 scoreRatio는 estimated 등 출처가 표시된 카탈로그 값입니다. 사이트에서 최종 계산 시 사용하는 값과 같은지는 별도 검증이 필요합니다.',
|
||||
'- 원본 SUS 헤더는 노트 종류별 개수와 분모 검증에 사용했습니다. 기존 SUS 타이밍 재구성 후보의 미해결 사항은 그대로 남아 있습니다.','',
|
||||
'## 집계','','```json',JSON.stringify(report.summary,null,2),'```','',
|
||||
'## Mekurumeku Rendezvous Expert','','```json',JSON.stringify(report.rows.find(r=>r.songId==='m0527'&&r.difficulty==='expert'),null,2),'```','',
|
||||
'## 전곡 결과','','|곡|난이도|우리 어댑터 적용 후 차이 초과/누락|wf 원본 분류 차이 초과/누락|스페셜 차이|wf 원본 해시 변경|', '|---|---|---|---|---|---|'];
|
||||
for(const r of report.rows)lines.push(`|${r.title}|${r.difficulty}|${r.localVsWf.extra}/${r.localVsWf.missing}|${r.localVsWfNative.extra}/${r.localVsWfNative.missing}|${r.localSpecialMismatch}|${!r.wfSourceMatchesSus}|`);
|
||||
await writeFile('data/wf-audit/latest.md',lines.join('\n')+'\n');
|
||||
console.log(JSON.stringify(report.summary,null,2));
|
||||
console.log(JSON.stringify(report.rows.find(r=>r.songId==='m0527'&&r.difficulty==='expert'),null,2));
|
||||
@@ -0,0 +1,175 @@
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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]));
|
||||
}
|
||||
// A measure-length change persists until the next directive.
|
||||
let currentLength = 4;
|
||||
for (let measure = 0; measure < 1000; measure += 1) {
|
||||
currentLength = lengths.get(measure) ?? currentLength;
|
||||
lengths.set(measure, currentLength);
|
||||
}
|
||||
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)) || holdEndKeys.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.
|
||||
// Continuations lie on the song's half-beat grid, not half a beat after the hold head.
|
||||
for (let beat = (Math.floor(start.beat * 2 + 1e-9) + 1) / 2; 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'));
|
||||
const expectedMid = Number(headers.get('LONG_RELAY_NOTE_COUNT')) + Number(headers.get('LONG_CONTINUE_NOTE_COUNT'));
|
||||
const regularCount = notes.filter((note) => note.type === 'regular').length;
|
||||
const midCount = notes.length - regularCount;
|
||||
const flickCount = notes.filter((note) => note.isFlick).length;
|
||||
if (regularCount !== expectedRegular || midCount !== expectedMid || flickCount !== Number(headers.get('FLICK_NOTE_COUNT'))) {
|
||||
throw new Error(`${headers.get('MUSIC_ID')}: SUS category mismatch: regular ${regularCount}/${expectedRegular}, mid ${midCount}/${expectedMid}, flick ${flickCount}/${headers.get('FLICK_NOTE_COUNT')}`);
|
||||
}
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseSusTimeline } from './sus-timeline.mjs';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
function grid(points) {
|
||||
const cells = Array(16).fill('00');
|
||||
for (const [index, value] of points) cells[index] = value;
|
||||
return cells.join('');
|
||||
}
|
||||
const fixture = `#MUSIC_ID m0001
|
||||
#BASEBPM 60
|
||||
#FULL_COMBO_NOTE_COUNT 7
|
||||
#TOTAL_NOTE_COUNT 7
|
||||
#NORMAL_NOTE_COUNT 1
|
||||
#FLICK_NOTE_COUNT 1
|
||||
#LONG_START_NOTE_COUNT 1
|
||||
#LONG_END_NOTE_COUNT 0
|
||||
#LONG_FLICK_END_NOTE_COUNT 1
|
||||
#LONG_RELAY_NOTE_COUNT 1
|
||||
#LONG_CONTINUE_NOTE_COUNT 2
|
||||
#GHOST_NOTE_COUNT 0
|
||||
#DAMAGE_NOTE_COUNT 0
|
||||
#00002:4
|
||||
#000320:${grid([[1,'13'],[3,'33'],[4,'53'],[5,'23']])}
|
||||
#00012:${grid([[5,'13'],[15,'13']])}
|
||||
#00052:${grid([[5,'13'],[8,'13']])}
|
||||
`;
|
||||
|
||||
test('끝 플릭을 중복 집계하지 않고 중간 판정을 곡의 반박 격자에 배치한다', () => {
|
||||
const { notes } = parseSusTimeline(fixture);
|
||||
assert.deepEqual(notes, [
|
||||
{ timeSeconds:0.25,type:'regular',isFlick:false },
|
||||
{ timeSeconds:0.5,type:'mid',isFlick:false },
|
||||
{ timeSeconds:0.75,type:'mid',isFlick:false },
|
||||
{ timeSeconds:1,type:'mid',isFlick:false },
|
||||
{ timeSeconds:1.25,type:'regular',isFlick:false },
|
||||
{ timeSeconds:2,type:'regular',isFlick:true },
|
||||
{ timeSeconds:3.75,type:'regular',isFlick:false }
|
||||
]);
|
||||
// Song Mechanics: hidden relay contributes only to the denominator.
|
||||
assert.equal(susScoreWeight(fixture).perfectNoteWeight, 4450);
|
||||
assert.equal(notes.reduce((sum,n)=>sum+(n.type==='mid'?100:n.isFlick?1050:1000),0),4350);
|
||||
});
|
||||
|
||||
test('헤더 불일치를 후반 삭제나 임의 중간 노트 추가로 숨기지 않는다', () => {
|
||||
assert.throws(()=>parseSusTimeline(fixture.replace('#NORMAL_NOTE_COUNT 1','#NORMAL_NOTE_COUNT 0')), /category mismatch/);
|
||||
assert.throws(()=>parseSusTimeline(fixture.replace('#LONG_CONTINUE_NOTE_COUNT 2','#LONG_CONTINUE_NOTE_COUNT 3')), /category mismatch/);
|
||||
assert.throws(()=>parseSusTimeline(fixture.replace('#FLICK_NOTE_COUNT 1','#FLICK_NOTE_COUNT 0')), /category mismatch/);
|
||||
});
|
||||
|
||||
test('마디 길이 변경은 다음 변경까지 유지한다', () => {
|
||||
const text = `#MUSIC_ID m0002
|
||||
#BASEBPM 60
|
||||
#NORMAL_NOTE_COUNT 2
|
||||
#FLICK_NOTE_COUNT 0
|
||||
#LONG_START_NOTE_COUNT 0
|
||||
#LONG_END_NOTE_COUNT 0
|
||||
#LONG_FLICK_END_NOTE_COUNT 0
|
||||
#LONG_RELAY_NOTE_COUNT 0
|
||||
#LONG_CONTINUE_NOTE_COUNT 0
|
||||
#00002:3
|
||||
#00112:11
|
||||
#00212:11
|
||||
`;
|
||||
assert.deepEqual(parseSusTimeline(text).notes.map(n=>n.timeSeconds),[3,6]);
|
||||
});
|
||||
+13
-178
@@ -6,189 +6,22 @@ import path from 'node:path';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
import { parseSusTimeline } from './sus-timeline.mjs';
|
||||
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) };
|
||||
}
|
||||
|
||||
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를 지정하세요.');
|
||||
const singerOverrides = { m0527: 'group' };
|
||||
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 = [];
|
||||
for (const songId of songIds) for (const difficulty of difficulties) {
|
||||
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'));
|
||||
@@ -201,8 +34,8 @@ for (const songId of songIds) for (const difficulty of difficulties) {
|
||||
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));
|
||||
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(', ')}`);
|
||||
}
|
||||
@@ -211,4 +44,6 @@ 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`);
|
||||
|
||||
Reference in New Issue
Block a user