// 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) }; }