110 lines
9.9 KiB
JavaScript
110 lines
9.9 KiB
JavaScript
// 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));
|