diff --git a/src/__tests__/round-trip-unrecognized-extras.test.ts b/src/__tests__/round-trip-unrecognized-extras.test.ts new file mode 100644 index 0000000..0cff877 --- /dev/null +++ b/src/__tests__/round-trip-unrecognized-extras.test.ts @@ -0,0 +1,188 @@ +/** + * Round-trip tests for the `.chart`/`.mid` preservation buckets that landed + * after the writer stack was authored: + * + * - `metadata.extraChartSongFields` — unknown `[Song]` keys (.chart) + * - `unrecognizedSyncTrackEvents` — non-B/TS `[SyncTrack]` lines (.chart) + * - `unrecognizedEventsTrackMidiEvents` — non-text MIDI events on the EVENTS + * track (e.g. RB practice-assist + * notes 24/25/26) + * + * All tests go through `parseChartAndIni` on the writer output and assert on + * the resulting `ParsedChart` — no assertions about the raw serialized bytes. + */ + +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { writeChartFile } from '../chart/chart-writer' +import { writeMidiFile } from '../chart/midi-writer' +import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTripChart(chart: ParsedChart): ParsedChart { + const bytes = new TextEncoder().encode(writeChartFile(chart)) + const result = parseChartAndIni([{ fileName: 'notes.chart', data: bytes }]) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +function roundTripMidi(chart: ParsedChart): ParsedChart { + const bytes = writeMidiFile(chart) + const result = parseChartAndIni([{ fileName: 'notes.mid', data: bytes }]) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +// --------------------------------------------------------------------------- +// metadata.extraChartSongFields +// --------------------------------------------------------------------------- + +describe('writeChartFile round-trip: metadata.extraChartSongFields', () => { + it('preserves Moonscraper / GHTCP legacy fields verbatim', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.extraChartSongFields = { + Player2: 'bass', + PreviewEnd: '0', + MediaType: 'cd', + MusicStream: 'song.ogg', + GuitarStream: 'guitar.ogg', + HoPo: '0', + } + const re = roundTripChart(chart) + expect(re.metadata.extraChartSongFields).toEqual({ + Player2: 'bass', + PreviewEnd: '0', + MediaType: 'cd', + MusicStream: 'song.ogg', + GuitarStream: 'guitar.ogg', + HoPo: '0', + }) + }) + + it('preserves quoted string values including inner spaces', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.extraChartSongFields = { + ArtistText: '"by"', + MusicStream: '"Some Song.ogg"', + } + const re = roundTripChart(chart) + expect(re.metadata.extraChartSongFields).toEqual({ + // The parser strips one layer of enclosing quotes on read. What matters + // is that the key survives and the value round-trips: re-writing the + // stripped form re-adds no quotes, so the next parse sees it unquoted. + ArtistText: 'by', + MusicStream: 'Some Song.ogg', + }) + }) + + it('preserves future / unknown keys the parser has never heard of', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.metadata.extraChartSongFields = { FutureField: 'future-value', Boss: '1' } + expect(roundTripChart(chart).metadata.extraChartSongFields).toEqual({ + FutureField: 'future-value', + Boss: '1', + }) + }) + + it('is still undefined when no extras are set', () => { + const chart = createEmptyChart({ format: 'chart' }) + expect(roundTripChart(chart).metadata.extraChartSongFields).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// unrecognizedSyncTrackEvents +// --------------------------------------------------------------------------- + +describe('writeChartFile round-trip: unrecognizedSyncTrackEvents', () => { + it('preserves tempo anchors (A) verbatim', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedSyncTrackEvents.push( + { tick: 0, text: 'A 0' }, + { tick: 3840, text: 'A 8805460' }, + ) + expect(roundTripChart(chart).unrecognizedSyncTrackEvents).toEqual([ + { tick: 0, text: 'A 0' }, + { tick: 3840, text: 'A 8805460' }, + ]) + }) + + it('preserves unknown / future SyncTrack event types verbatim', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedSyncTrackEvents.push({ tick: 1920, text: 'FUTURE 12 34 56' }) + expect(roundTripChart(chart).unrecognizedSyncTrackEvents).toEqual([ + { tick: 1920, text: 'FUTURE 12 34 56' }, + ]) + }) + + it('does not disturb tempos or time signatures at the same tick', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.tempos.push({ tick: 960, beatsPerMinute: 150, msTime: 0 }) + chart.timeSignatures.push({ tick: 960, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + chart.unrecognizedSyncTrackEvents.push({ tick: 960, text: 'A 12345' }) + const re = roundTripChart(chart) + expect(re.tempos.find(t => t.tick === 960)?.beatsPerMinute).toBe(150) + expect(re.timeSignatures.find(ts => ts.tick === 960)).toMatchObject({ numerator: 3, denominator: 4 }) + expect(re.unrecognizedSyncTrackEvents).toEqual([{ tick: 960, text: 'A 12345' }]) + }) + + it('writing to .mid drops anchors (no MIDI equivalent)', () => { + // Anchors are .chart-only. Round-tripping through .mid necessarily loses + // them — the field is always [] on a .mid-parsed result. This test pins + // that contract so a future writer change doesn't accidentally smuggle + // anchors through as something else. + const chart = createEmptyChart({ format: 'mid' }) + chart.unrecognizedSyncTrackEvents.push({ tick: 1920, text: 'A 12345' }) + expect(roundTripMidi(chart).unrecognizedSyncTrackEvents).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// unrecognizedEventsTrackMidiEvents +// --------------------------------------------------------------------------- + +describe('writeMidiFile round-trip: unrecognizedEventsTrackMidiEvents', () => { + it('preserves Rock Band practice-mode assist-sample notes (24/25/26)', () => { + const chart = createEmptyChart({ format: 'mid' }) + // deltaTime = absolute tick (per scan-chart's convertToAbsoluteTime). + chart.unrecognizedEventsTrackMidiEvents.push( + { deltaTime: 0, type: 'noteOn', channel: 0, noteNumber: 24, velocity: 100 }, + { deltaTime: 480, type: 'noteOff', channel: 0, noteNumber: 24, velocity: 0 }, + { deltaTime: 960, type: 'noteOn', channel: 0, noteNumber: 26, velocity: 100 }, + { deltaTime: 1440, type: 'noteOff', channel: 0, noteNumber: 26, velocity: 0 }, + ) + const re = roundTripMidi(chart) + // Compare just the shape that matters — tick + event kind + note number. + const mapped = re.unrecognizedEventsTrackMidiEvents.map(e => ({ + tick: e.deltaTime, + type: e.type, + noteNumber: 'noteNumber' in e ? e.noteNumber : undefined, + })) + expect(mapped).toEqual([ + { tick: 0, type: 'noteOn', noteNumber: 24 }, + { tick: 480, type: 'noteOff', noteNumber: 24 }, + { tick: 960, type: 'noteOn', noteNumber: 26 }, + { tick: 1440, type: 'noteOff', noteNumber: 26 }, + ]) + }) + + it('is empty when no source MIDI events were on the EVENTS track', () => { + const chart = createEmptyChart({ format: 'mid' }) + expect(roundTripMidi(chart).unrecognizedEventsTrackMidiEvents).toEqual([]) + }) + + it('writing to .chart drops MIDI EVENTS-track events (no .chart equivalent)', () => { + // .chart has no concept of non-text events on its [Events] section; + // these are a strict MIDI-source concept. Round-tripping through .chart + // loses them — pin the contract. + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedEventsTrackMidiEvents.push( + { deltaTime: 0, type: 'noteOn', channel: 0, noteNumber: 24, velocity: 100 }, + ) + expect(roundTripChart(chart).unrecognizedEventsTrackMidiEvents).toEqual([]) + }) +}) diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts index f4fa682..85a89a5 100644 --- a/src/chart/chart-writer.ts +++ b/src/chart/chart-writer.ts @@ -82,6 +82,18 @@ function serializeSongSection(chart: ParsedChart): string[] { } if (m.diff_guitar != null) lines.push(` Difficulty = ${m.diff_guitar}`) + // Round-trip any `[Song]` keys the parser didn't claim (deprecated + // Moonscraper / GHTCP fields — `Player2`, `HoPo`, `PreviewEnd`, `MediaType`, + // audio-stream filenames, etc.). Values are preserved verbatim: if the + // source didn't quote the value, we don't quote it here either. Consumers + // should treat these as opaque and never synthesize them — editors should + // discover audio via folder scan rather than trust `*Stream` values here. + if (m.extraChartSongFields) { + for (const [key, value] of Object.entries(m.extraChartSongFields)) { + lines.push(` ${key} = ${value}`) + } + } + lines.push('}') return lines } @@ -96,6 +108,7 @@ function serializeSyncTrack(chart: ParsedChart): string[] { type SyncEvent = | { tick: number; order: 0; kind: 'ts'; numerator: number; denominator: number } | { tick: number; order: 1; kind: 'bpm'; beatsPerMinute: number } + | { tick: number; order: 2; kind: 'raw'; text: string } const events: SyncEvent[] = [ ...chart.timeSignatures.map( @@ -110,9 +123,12 @@ function serializeSyncTrack(chart: ParsedChart): string[] { ...chart.tempos.map( (t): SyncEvent => ({ tick: t.tick, order: 1, kind: 'bpm', beatsPerMinute: t.beatsPerMinute }), ), + ...chart.unrecognizedSyncTrackEvents.map( + (e): SyncEvent => ({ tick: e.tick, order: 2, kind: 'raw', text: e.text }), + ), ] - // Sort by tick, then TS before B at the same tick. Duplicates preserved. + // Sort by tick, then TS < B < raw at the same tick. Duplicates preserved. events.sort((a, b) => { if (a.tick !== b.tick) return a.tick - b.tick return a.order - b.order @@ -122,6 +138,8 @@ function serializeSyncTrack(chart: ParsedChart): string[] { if (ev.kind === 'bpm') { const millibeats = Math.round(ev.beatsPerMinute * 1000) lines.push(` ${ev.tick} = B ${millibeats}`) + } else if (ev.kind === 'raw') { + lines.push(` ${ev.tick} = ${ev.text}`) } else if (ev.denominator === 4) { lines.push(` ${ev.tick} = TS ${ev.numerator}`) } else { diff --git a/src/chart/midi-writer.ts b/src/chart/midi-writer.ts index f13a239..8e4a68b 100644 --- a/src/chart/midi-writer.ts +++ b/src/chart/midi-writer.ts @@ -237,6 +237,16 @@ function buildEventsTrack(chart: ParsedChart): MidiEvent[] { } } + // Round-trip any non-text events that were on the EVENTS track in the + // source `.mid` — most notably RB practice-mode assist sample notes + // (note numbers 24/25/26), plus stray sysex / channel / meta events an + // authoring tool happened to leave here. Events arrive with `deltaTime` + // already expanded to absolute-tick (per scan-chart's post-process); + // `finalizeMidiTrack` converts back to per-event delta below. + for (const ev of chart.unrecognizedEventsTrackMidiEvents) { + events.push({ tick: ev.deltaTime, event: { ...ev, deltaTime: 0 } }) + } + return finalizeMidiTrack(events) }