From eb294ff5e31647abe78466f3952e5860772bcdd0 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 9 Sep 2026 14:31:25 +0900 Subject: [PATCH] fix: repair SUS timing and note classification for 18 charts --- UPDATES.md | 4 + docs/research/sus-timeline-repair.md | 32 +++++ package.json | 2 +- scripts/audit-sus-timelines.mjs | 109 +++++++++++++++ scripts/audit-wf-charts.mjs | 88 ++++++++++++ scripts/download-sus-charts.mjs | 58 ++++++-- scripts/summarize-wf-audit.mjs | 47 +++++++ scripts/sus-timeline.mjs | 175 +++++++++++++++++++++++ scripts/sus-timeline.test.mjs | 67 +++++++++ scripts/sus-to-exact-chart.mjs | 191 ++------------------------ src/lib/data/exact-chart-catalog.json | 8 +- static/data/charts/m0344.expert.json | 2 +- static/data/charts/m0344.hard.json | 2 +- static/data/charts/m0344.normal.json | 2 +- static/data/charts/m0348.easy.json | 2 +- static/data/charts/m0348.expert.json | 2 +- static/data/charts/m0348.hard.json | 2 +- static/data/charts/m0348.normal.json | 2 +- static/data/charts/m0363.easy.json | 2 +- static/data/charts/m0363.expert.json | 2 +- static/data/charts/m0363.normal.json | 2 +- static/data/charts/m0459.easy.json | 2 +- static/data/charts/m0459.expert.json | 2 +- static/data/charts/m0459.hard.json | 2 +- static/data/charts/m0459.normal.json | 2 +- static/data/charts/m0527.easy.json | 2 +- static/data/charts/m0527.expert.json | 2 +- static/data/charts/m0527.hard.json | 2 +- static/data/charts/m0527.normal.json | 2 +- 29 files changed, 605 insertions(+), 212 deletions(-) create mode 100644 docs/research/sus-timeline-repair.md create mode 100644 scripts/audit-sus-timelines.mjs create mode 100644 scripts/audit-wf-charts.mjs create mode 100644 scripts/summarize-wf-audit.mjs create mode 100644 scripts/sus-timeline.mjs create mode 100644 scripts/sus-timeline.test.mjs diff --git a/UPDATES.md b/UPDATES.md index a9a8722..634cf74 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -4,6 +4,10 @@ updatedAt: 2026-09-09T12:00:00+09:00 ## 2026년 9월 9일 +### 일부 악곡의 노트 타이밍·분류 수정 + +`FIXED` HoloHawk, Tri-Unity♡LoveSystem, Simulacre, Illusion Night, Mekurumeku Rendezvous의 영향받은 18개 난이도 채보를 수정했습니다. 롱노트 끝 중복으로 후반 노트가 누락되던 문제와 중간 판정 시각을 바로잡고, 롱노트 끝 플릭은 일반 노트와 같은 점수로 계산하도록 분류했습니다. + ### FUWAMOCO 솔로 악곡 보너스 수정 `FIXED` 후와와와 모코코 둘만 가창하는 FUWAMOCO 악곡을 솔로 악곡으로 판정하여, 두 멤버의 솔로 악곡 보너스가 함께 적용되도록 수정했습니다. diff --git a/docs/research/sus-timeline-repair.md b/docs/research/sus-timeline-repair.md new file mode 100644 index 0000000..8c5dcde --- /dev/null +++ b/docs/research/sus-timeline-repair.md @@ -0,0 +1,32 @@ +# 18개 난이도 채보 수정 검증 + +기준: [Song Mechanics](https://docs.google.com/document/d/1P1newxmgxS8b-0raHk5vqnbb8X1nXh1xs3d4gYuhUMQ), Note Score 절. + +일반·롱노트 시작/끝/끝 플릭 1000, 중간·릴레이 100, 독립 플릭 1050을 적용했다. 비가시 릴레이·베지어는 득점 타임라인에 넣지 않는다. PC 독립 플릭 1000 규칙은 이번 채보 수정과 별개의 플랫폼 계산 옵션이므로 변경하지 않았다. + +롱노트 끝의 중복 tap을 제외하고, 중간 판정을 곡 전체의 0.5박 격자로 생성한다. 마디 길이는 다음 변경까지 유지한다. 헤더와 종류별 개수가 다르면 수집을 중단하며, 노트를 삭제하거나 마지막에 추가하지 않는다. + +18개 모두 원본 헤더의 개수 및 분류와 일치하고, wf-calc 스냅샷과 원본 SHA-256 및 전체 노트 시각이 1마이크로초 이내에서 일치했다. wf-calc의 점수 분류를 그대로 복사하지 않았다. 기존 1노트 점수 환산값·분모·스페셜 시각은 모두 유지했다. FUWAMOCO 가창 분류는 공식 마스터의 solo로 바로잡았다. + +이 결과만으로 특정 편성의 330만/350만 차이 전체를 설명한다고 단정하지 않는다. + +|곡 ID|난이도|전체|일반(롱 끝 포함)|독립 플릭|중간|마지막 시각| +|---|---|---:|---:|---:|---:|---:| +|m0344|normal|472|361|0|111|150.000000| +|m0344|hard|794|579|70|145|150.000000| +|m0344|expert|1106|864|114|128|150.000000| +|m0348|easy|206|122|0|84|125.142857| +|m0348|normal|333|236|0|97|125.142857| +|m0348|hard|637|447|19|171|125.142857| +|m0348|expert|850|581|34|235|125.142857| +|m0363|easy|274|168|0|106|137.806452| +|m0363|normal|574|343|0|231|139.161290| +|m0363|expert|1222|1007|57|158|139.161290| +|m0459|easy|255|124|0|131|125.227273| +|m0459|normal|326|182|0|144|125.227273| +|m0459|hard|616|411|27|178|125.227273| +|m0459|expert|870|617|62|191|125.227273| +|m0527|easy|102|78|0|24|106.430155| +|m0527|normal|285|230|0|55|108.292683| +|m0527|hard|420|324|20|76|108.292683| +|m0527|expert|682|547|27|108|108.292683| diff --git a/package.json b/package.json index a58d091..8c55488 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "db:studio": "drizzle-kit studio", "data:apply-song-score-formulas": "node scripts/apply-song-score-formulas.mjs", "data:validate-song-score-formula": "node scripts/validate-song-score-formula.mjs", - "test:sus-score": "node --test scripts/sus-score-weight.test.mjs" + "test:sus-score": "node --test scripts/sus-score-weight.test.mjs scripts/sus-timeline.test.mjs" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.7", diff --git a/scripts/audit-sus-timelines.mjs b/scripts/audit-sus-timelines.mjs new file mode 100644 index 0000000..324dfa5 --- /dev/null +++ b/scripts/audit-sus-timelines.mjs @@ -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)); diff --git a/scripts/audit-wf-charts.mjs b/scripts/audit-wf-charts.mjs new file mode 100644 index 0000000..b568f07 --- /dev/null +++ b/scripts/audit-wf-charts.mjs @@ -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(/