fix: calculate chart scores with song formulas and hidden note weights
Publish GitHub Pages output / publish (push) Successful in 54s

This commit is contained in:
2026-09-06 21:51:42 +09:00
parent b97a4008bb
commit e688036d11
409 changed files with 7732 additions and 1559 deletions
+3
View File
@@ -21,6 +21,9 @@ for (const calibration of calibrations.charts) {
// Easy/Normal 실측도 보관하되 현재 생성 대상인 Hard/Expert만 즉시 동기화한다.
if (!catalogRow) continue;
// 공식 기반 채보는 실측 보정으로 덮어쓰지 않는다. 실측은 검증 자료로 유지한다.
if (catalogRow.scoreCalibrationSource === 'formula-v1') continue;
catalogRow.scoreRatio = calibration.scoreRatio;
catalogRow.scoreCalibrationSource = 'measured';
+24
View File
@@ -0,0 +1,24 @@
import { readFile, writeFile } from 'node:fs/promises';
import { formulaScoreMetadata } from './song-score-formulas.mjs';
const root = new URL('../', import.meta.url);
const catalogPath = new URL('src/lib/data/exact-chart-catalog.json', root);
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
const updates = [];
const missing = [];
// Validate every chart before writing any changes. Do not change notes or SP timing.
for (const row of catalog.charts) {
const chartPath = new URL(`static${row.path}`, root);
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
const metadata = formulaScoreMetadata(row.songId, row.difficulty, row.difficultyLevel, chart.notes.length, undefined, chart.source?.susSha256);
if (!metadata) { missing.push(`${row.songId}.${row.difficulty}`); continue; }
if (chart.songId !== row.songId || chart.difficulty !== row.difficulty || row.totalNotes !== chart.notes.length) {
throw new Error(`채보 카탈로그 불일치: ${row.songId}.${row.difficulty}`);
}
Object.assign(row, metadata);
Object.assign(chart, metadata);
updates.push({ chartPath, chart });
}
for (const { chartPath, chart } of updates) await writeFile(chartPath, JSON.stringify(chart));
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
console.log(`${updates.length}개 채보에 곡별 공식을 적용했습니다. 기존 환산 유지: ${missing.join(', ') || '없음'}`);
+23
View File
@@ -0,0 +1,23 @@
// node scripts/calculate-sus-score-formula.mjs data/raw/sus/chart_m0341_expert.sus
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { susScoreWeight } from './sus-score-weight.mjs';
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
const file = process.argv[2];
if (!file) throw new Error('원본 SUS 경로를 지정하세요.');
const bytes = await readFile(file);
const result = susScoreWeight(bytes.toString('utf8'));
const difficulty = basename(file).match(/_(easy|normal|hard|expert)\.sus$/)?.[1];
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
const catalog = JSON.parse(await readFile(new URL('../src/lib/data/exact-chart-catalog.json', import.meta.url), 'utf8'));
const chart = catalog.charts.find((row) => row.songId === result.songId && row.difficulty === difficulty);
const song = reference.charts.find((row) => row.songId === result.songId);
if (!chart || !song) throw new Error('카탈로그 난이도 또는 곡별 계수를 찾지 못했습니다.');
const local = JSON.parse(await readFile(new URL(`../static${chart.path}`, import.meta.url), 'utf8'));
const susSha256 = createHash('sha256').update(bytes).digest('hex');
if (local.source?.susSha256 !== susSha256 || local.notes.length !== result.fullComboNoteCount) throw new Error('현재 서비스와 다른 원본 채보입니다.');
// SUS SCORE_LEVEL is editor metadata; use the published master difficulty level.
const formula = { perfectNoteWeight: result.perfectNoteWeight, liveScoreCoefficientPermil: song.liveScoreCoefficientPermil, difficultyLevel: chart.difficultyLevel };
console.log(JSON.stringify({ ...result, difficulty, susSha256, ...formula, scoreRatio: scoreRatioFromFormula(formula) }, null, 2));
+7 -1
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { formulaScoreMetadata } from './song-score-formulas.mjs';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
const outputDirectory = path.resolve('static/data/charts');
@@ -237,14 +238,18 @@ async function worker() {
const releaseTime = Number(music?.startTime);
const notes = flattenEvents(chartData);
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
const difficultyLevel = difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null);
const formulaMetadata = formulaScoreMetadata(item.songId, item.difficulty, difficultyLevel, notes.length, music?.liveScoreCoefficientPermil, chartData.source?.sha256);
const output = {
schemaVersion: 1,
songId: item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
// 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
// 검증된 공식 우선. 참고 분모가 없는 채보만 기존 실측/추정으로 대체한다.
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
...formulaMetadata,
notes,
specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot)
@@ -268,6 +273,7 @@ async function worker() {
durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio,
scoreCalibrationSource: output.scoreCalibrationSource,
...(output.scoreFormula ? { scoreFormula: output.scoreFormula } : {}),
totalNotes: notes.length,
singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds,
+25
View File
@@ -0,0 +1,25 @@
import { readFile } from 'node:fs/promises';
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
// 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 susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
const rows = [...reference.charts, ...susReference.charts];
const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row]));
if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.');
export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, liveScoreCoefficientPermil, susSha256) {
const row = formulas.get(`${songId}.${difficulty.toLowerCase()}`);
if (!row) return null;
if (row.susSha256 && row.susSha256 !== susSha256) throw new Error(`${songId}.${difficulty}: 원본 SUS 해시가 다릅니다.`);
if (row.difficultyLevel !== difficultyLevel || row.fullComboNoteCount !== totalNotes ||
(liveScoreCoefficientPermil !== undefined && row.liveScoreCoefficientPermil !== liveScoreCoefficientPermil)) {
throw new Error(`${songId}.${difficulty}: 채보 또는 마스터 데이터가 점수 공식 참고값과 다릅니다.`);
}
const scoreFormula = {
perfectNoteWeight: row.perfectNoteWeight,
liveScoreCoefficientPermil: row.liveScoreCoefficientPermil,
difficultyLevel: row.difficultyLevel
};
return { scoreRatio: scoreRatioFromFormula(scoreFormula), scoreCalibrationSource: 'formula-v1', scoreFormula };
}
+66
View File
@@ -0,0 +1,66 @@
// Counts the score denominator, not a playable-note timeline.
// SUS headers supply playable categories, including generated continuation ticks.
// Body family 3 kinds 4/5 supply non-playable bezier / inactive relay points.
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const categories = ['NORMAL', 'FLICK', 'LONG_START', 'LONG_END', 'LONG_FLICK_END', 'LONG_RELAY', 'LONG_CONTINUE'];
export function susScoreWeight(text) {
const headers = new Map();
let invisibleRelayCount = 0;
let bezierPointCount = 0;
let starts = 0, ends = 0, relays = 0;
for (const line of text.split(/\r?\n/)) {
const header = line.match(/^#([A-Z_]+)\s+(\S+)\s*$/);
if (header) {
if (headers.has(header[1])) throw new Error(`Duplicate SUS header: ${header[1]}`);
headers.set(header[1], header[2]);
}
const match = line.match(/^#\d{3}3[0-9a-zA-Z]{2}:(.*)$/);
if (!match) continue;
const body = match[1].trim();
// Repeated channel lines are additive; do not overwrite them in a map.
for (let index = 0; index < body.length;) {
if (body.startsWith('0x', index)) {
const run = body.slice(index + 2, index + 4);
if (run.length !== 2 || [...run].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS empty run');
index += 4; // Empty cells have no denominator contribution.
continue;
}
const token = body.slice(index, index + 2);
if (token.length !== 2 || [...token].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS note token');
const kind = token[0];
if (kind === '1') starts += 1;
else if (kind === '2') ends += 1;
else if (kind === '3') relays += 1;
else if (kind === '4') bezierPointCount += 1;
else if (kind === '5') invisibleRelayCount += 1;
else if (kind !== '0') throw new Error(`Unsupported SUS hold kind: ${kind}`);
index += 2;
}
}
function integer(name) {
const raw = headers.get(name);
if (!raw || !/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) throw new Error(`Invalid SUS header: ${name}`);
return Number(raw);
}
const counts = Object.fromEntries(categories.map((kind) => [kind, integer(`${kind}_NOTE_COUNT`)]));
// Ghosts are visual guides, not invisible hold relays; they add no score weight.
integer('GHOST_NOTE_COUNT');
for (const unsupported of ['DAMAGE_NOTE_COUNT']) {
if (integer(unsupported) !== 0) throw new Error(`Unsupported scoring category: ${unsupported}`);
}
const fullComboNoteCount = integer('FULL_COMBO_NOTE_COUNT');
if (Object.values(counts).reduce((sum, count) => sum + count, 0) !== fullComboNoteCount ||
integer('TOTAL_NOTE_COUNT') !== fullComboNoteCount + integer('GHOST_NOTE_COUNT') ||
starts !== counts.LONG_START || ends !== counts.LONG_END + counts.LONG_FLICK_END || relays !== counts.LONG_RELAY) {
throw new Error('SUS header/body category mismatch');
}
const songId = headers.get('MUSIC_ID');
if (!songId || !/^m\d{4}$/.test(songId)) throw new Error('Invalid SUS music ID');
const baseWeight = 1000 * (counts.NORMAL + counts.LONG_START + counts.LONG_END + counts.LONG_FLICK_END)
+ 1050 * counts.FLICK + 100 * (counts.LONG_RELAY + counts.LONG_CONTINUE);
return {
songId, fullComboNoteCount, counts, invisibleRelayCount, bezierPointCount, baseWeight,
perfectNoteWeight: baseWeight + 100 * (invisibleRelayCount + bezierPointCount)
};
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { susScoreWeight } from './sus-score-weight.mjs';
const text = `#MUSIC_ID m0001
#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
#DAMAGE_NOTE_COUNT 0
#GHOST_NOTE_COUNT 0
#000320:140x0K3454
#000320:0xft4424
#00052:1c
`;
test('비가시·베지어는 분모에만 포함하고 끝 플릭은 1000으로 집계한다', () => {
const result = susScoreWeight(text);
assert.equal(result.fullComboNoteCount, 7);
assert.equal(result.baseWeight, 4350);
assert.equal(result.invisibleRelayCount, 1);
assert.equal(result.bezierPointCount, 1);
assert.equal(result.perfectNoteWeight, 4550);
});
test('잘린 토큰·미지원 종류·헤더 불일치는 잘못된 분모 대신 실패한다', () => {
for (const changed of [text.replace('0xft', '0x!t'), text + '#001320:4', text + '#001320:64', text.replace('LONG_START_NOTE_COUNT 1', 'LONG_START_NOTE_COUNT 2')]) {
assert.throws(() => susScoreWeight(changed));
}
});
test('고스트 가이드는 총 오브젝트 수에는 포함하지만 점수 분모에는 포함하지 않는다', () => {
const withGhost = text.replace('GHOST_NOTE_COUNT 0', 'GHOST_NOTE_COUNT 2').replace('TOTAL_NOTE_COUNT 7', 'TOTAL_NOTE_COUNT 9');
assert.equal(susScoreWeight(withGhost).perfectNoteWeight, 4550);
});
+99
View File
@@ -0,0 +1,99 @@
// Read-only comparison: this does not replace production scoreRatio values.
// Run: node scripts/validate-song-score-formula.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { scoreRatioFromFormula, finalNoteScore } from '../src/lib/engine/score.ts';
const root = new URL('../', import.meta.url);
const read = async (relative) => JSON.parse(await readFile(new URL(relative, root), 'utf8'));
const reference = await read('docs/research/song-score-formula-reference.json');
const susReference = await read('docs/research/sus-score-formulas.json');
const calibrations = await read('data/score-calibrations.json');
const catalog = await read('src/lib/data/exact-chart-catalog.json');
const key = (row) => `${row.songId}.${row.difficulty}`;
const referenceRows = [...reference.charts, ...susReference.charts];
const byKey = new Map(referenceRows.map((row) => [key(row), row]));
assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart');
function ratio(row) {
assert.ok(Number.isInteger(row.perfectNoteWeight) && row.perfectNoteWeight > 0);
const coefficient = 2.3 * (1 + row.liveScoreCoefficientPermil / 1000 * (row.difficultyLevel - 5));
assert.ok(coefficient > 0);
return row.perfectNoteWeight / 1000 / coefficient;
}
// Exact rational ceiling for the documented formula; no fitted ratios or epsilon.
function predict(measurement, row) {
const bonusScaled = Math.round(measurement.directSongScoreBonusPercent * 100);
assert.ok(Math.abs(bonusScaled / 100 - measurement.directSongScoreBonusPercent) < 1e-10);
assert.ok(Number.isInteger(measurement.overallPower));
const numerator = BigInt(measurement.overallPower) * 23n
* BigInt(1000 + row.liveScoreCoefficientPermil * (row.difficultyLevel - 5))
* BigInt(10000 + bonusScaled);
const denominator = 10n * BigInt(row.perfectNoteWeight) * 10000n;
return Number((numerator + denominator - 1n) / denominator);
}
const measuredRows = [];
for (const calibration of calibrations.charts) {
const ref = byKey.get(key(calibration));
assert.ok(ref, `Missing measured reference: ${key(calibration)}`);
const predictions = calibration.measurements.map((measurement) => {
const predicted = predict(measurement, ref);
// ceil(A / R) = S implies R in [A/S, A/(S-1)).
const amount = measurement.overallPower * (1 + measurement.directSongScoreBonusPercent / 100);
const lower = amount / measurement.normalPerfectScore;
const upperExclusive = amount / (measurement.normalPerfectScore - 1);
assert.ok(ratio(ref) >= lower && ratio(ref) < upperExclusive, `Ratio outside measured interval: ${key(calibration)}`);
assert.equal(predicted, measurement.normalPerfectScore, `Measured score mismatch: ${key(calibration)}`);
assert.equal(finalNoteScore(measurement.overallPower / scoreRatioFromFormula(ref), measurement.directSongScoreBonusPercent), predicted,
`Production engine mismatch: ${key(calibration)}`);
return { ...measurement, predicted, ratioInterval: { lower, upperExclusive } };
});
measuredRows.push({ key: key(calibration), researchRatio: ratio(ref), storedRatio: calibration.scoreRatio, predictions });
}
const missing = [], comparisons = [], noteDiagnostics = [];
for (const chart of catalog.charts) {
const ref = byKey.get(key(chart));
if (!ref) { missing.push(key(chart)); continue; }
assert.equal(chart.difficultyLevel, ref.difficultyLevel, `Difficulty mismatch: ${key(chart)}`);
const local = await read(`static${chart.path}`);
assert.equal(chart.scoreCalibrationSource, 'formula-v1', `Formula not applied: ${key(chart)}`);
assert.equal(chart.scoreRatio, scoreRatioFromFormula(ref));
assert.equal(local.scoreRatio, chart.scoreRatio);
assert.deepEqual(local.scoreFormula, chart.scoreFormula);
assert.equal(local.notes.length, ref.fullComboNoteCount, `Combo count mismatch: ${key(chart)}`);
const localWeight = local.notes.reduce((sum, note) => sum + (note.type === 'mid' ? 100 : note.isFlick ? 1050 : 1000), 0);
noteDiagnostics.push({
key: key(chart), localWeight, referencePlayableWeight: ref.baseWeight,
referenceDenominator: ref.perfectNoteWeight,
nonPlayableWeight: ref.perfectNoteWeight - ref.baseWeight,
playableClassificationDifference: localWeight - ref.baseWeight
});
comparisons.push({ key: key(chart), title: chart.title, source: chart.scoreCalibrationSource,
// Positive means the current unrounded base score is higher than research.
currentScoreRelativeToResearchPercent: 100 * (ratio(ref) / chart.scoreRatio - 1) });
}
const comparisonBySource = Object.fromEntries(Object.entries(Object.groupBy(comparisons, (row) => row.source)).map(([source, rows]) => {
const ordered = rows.toSorted((a, b) => a.currentScoreRelativeToResearchPercent - b.currentScoreRelativeToResearchPercent);
return [source, { count: rows.length,
meanAbsoluteDifferencePercent: rows.reduce((sum, row) => sum + Math.abs(row.currentScoreRelativeToResearchPercent), 0) / rows.length,
over5Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 5).length,
over10Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 10).length,
minimum: ordered[0], maximum: ordered.at(-1) }];
}));
const report = {
reference: fileURLToPath(new URL('docs/research/song-score-formula-reference.json', root)),
source: reference.source,
measuredSongs: new Set(calibrations.charts.map((row) => row.songId)).size,
measuredCharts: measuredRows.length,
exactMeasurements: measuredRows.reduce((sum, row) => sum + row.predictions.length, 0),
catalogCharts: catalog.charts.length, coveredCharts: comparisons.length, missing,
comparisonBySource,
measuredRows,
measuredNoteDiagnostics: noteDiagnostics.filter((row) => measuredRows.some((measured) => measured.key === row.key))
};
console.log(JSON.stringify(report, null, 2));
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { susScoreWeight } from './sus-score-weight.mjs';
const record = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
const directory = process.argv[2] ?? 'data/raw/sus';
for (const row of [...record.validationCharts, ...record.charts]) {
const bytes = await readFile(path.join(directory, `chart_${row.songId}_${row.difficulty}.sus`));
assert.equal(createHash('sha256').update(bytes).digest('hex'), row.susSha256);
const actual = susScoreWeight(bytes.toString('utf8'));
assert.equal(actual.songId, row.songId);
assert.equal(actual.perfectNoteWeight, row.perfectNoteWeight, `${row.songId}.${row.difficulty}`);
if (row.counts) {
assert.deepEqual(actual.counts, row.counts);
assert.equal(actual.invisibleRelayCount, row.invisibleRelayCount);
assert.equal(actual.bezierPointCount, row.bezierPointCount);
}
}
console.log(`${record.validationCharts.length}개 기존 분모와 ${record.charts.length}개 신규 SUS 분모를 재현했습니다.`);