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