From 06c15d7e3ea269cbeffbf28ce0e19e0f8639f813 Mon Sep 17 00:00:00 2001 From: Eli White Date: Tue, 21 Apr 2026 16:18:08 -0700 Subject: [PATCH] Round-trip extraChartSongFields / unrecognizedSyncTrackEvents / unrecognizedEventsTrackMidiEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser preserves three post-writer-stack round-trip buckets that the writers weren't yet emitting: - `metadata.extraChartSongFields` (unknown `[Song]` keys, .chart source) - `unrecognizedSyncTrackEvents` (non-B/TS `[SyncTrack]` lines, .chart) - `unrecognizedEventsTrackMidiEvents` (non-text MIDI events on the EVENTS track — e.g. RB practice-mode assist-sample notes 24/25/26) Emit them: - `writeChartFile` appends `extraChartSongFields` at the tail of `[Song]` (values written verbatim — no quoting added or stripped). Editors and consumers should treat these as opaque and never synthesize them; this is strictly a round-trip aid for tools like Moonscraper that author deprecated fields (`Player2`, `HoPo`, `PreviewEnd`, audio-stream filenames, etc.). Any audio file discovery should go through folder scan, not via `[Song].*Stream` values. - `writeChartFile` emits `unrecognizedSyncTrackEvents` alongside tempos + time signatures in `[SyncTrack]`, sorted by tick (TS < B < raw at the same tick for determinism). Text is written verbatim as `${tick} = ${text}`, so tempo anchors (`A `) and any future SyncTrack event types survive a parse → write loop without parser updates. - `writeMidiFile` appends `unrecognizedEventsTrackMidiEvents` to the EVENTS track after sections / end events / unrecognized text events / coda. Events arrive with absolute-tick `deltaTime` (scan-chart's post-process) and get re-deltified by `finalizeMidiTrack`. `.chart`-only fields (`extraChartSongFields`, `unrecognizedSyncTrackEvents`) are dropped when writing to `.mid`, and `unrecognizedEventsTrackMidiEvents` is dropped when writing to `.chart` — tests pin both contracts so a future writer change doesn't accidentally smuggle them through as something else. 11 new tests in `round-trip-unrecognized-extras.test.ts` cover legacy-field preservation, tempo-anchor round-trip, forward-compat for unknown SyncTrack types, co-occurrence with tempos/TS at the same tick, practice-assist note round-trip on `.mid`, and the cross-format drop contracts. All 1020 tests pass (1009 existing + 11 new). --- .../round-trip-unrecognized-extras.test.ts | 188 ++++++++++++++++++ src/chart/chart-writer.ts | 20 +- src/chart/midi-writer.ts | 10 + 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/round-trip-unrecognized-extras.test.ts 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) }