diff --git a/src/__tests__/chart-writer.test.ts b/src/__tests__/chart-writer.test.ts new file mode 100644 index 0000000..17692c0 --- /dev/null +++ b/src/__tests__/chart-writer.test.ts @@ -0,0 +1,384 @@ +/** + * Round-trip tests for writeChartFile. + * + * All tests exercise the writer only through parseChartAndIni: build a + * ParsedChart, write it out, re-parse, and assert on the resulting + * ParsedChart. No assertions about the serialized .chart text (CRLF, + * quoting, field order, section ordering, specific N numbers, etc.) — + * the parser is the source of truth for observable behavior. + */ + +import { describe, expect, it } from 'vitest' + +import { writeChartFile } from '../chart/chart-writer' +import { createEmptyChart } from '../chart/create-chart' +import { noteFlags, noteTypes, NoteEvent } from '../chart/note-parsing-interfaces' +import { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTrip(chart: ParsedChart, iniText?: string): ParsedChart { + const files: { fileName: string; data: Uint8Array }[] = [ + { fileName: 'notes.chart', data: new TextEncoder().encode(writeChartFile(chart)) }, + ] + if (iniText !== undefined) { + files.push({ fileName: 'song.ini', data: new TextEncoder().encode(iniText) }) + } + const result = parseChartAndIni(files) + if (!result.parsedChart) { + throw new Error(`round-trip produced no parsedChart: ${JSON.stringify(result.chartFolderIssues)}`) + } + return result.parsedChart +} + +function addDrumTrack(chart: ParsedChart, difficulty: 'expert' | 'hard' | 'medium' | 'easy' = 'expert') { + const track: ParsedChart['trackData'][number] = { + instrument: 'drums', + difficulty, + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } + chart.trackData.push(track) + return track +} + +function addFretTrack(chart: ParsedChart, instrument: ParsedChart['trackData'][number]['instrument'] = 'guitar') { + const track: ParsedChart['trackData'][number] = { + instrument, + difficulty: 'expert', + starPowerSections: [], + rejectedStarPowerSections: [], + soloSections: [], + flexLanes: [], + drumFreestyleSections: [], + textEvents: [], + versusPhrases: [], + animations: [], + unrecognizedMidiEvents: [], + noteEventGroups: [], + } + chart.trackData.push(track) + return track +} + +function note(tick: number, type: number, flags = 0, length = 0): NoteEvent { + return { tick, type, flags, length, msTime: 0, msLength: 0 } +} + +/** Flatten a track's noteEventGroups into `{ tick, type, flags, length }` tuples for easy comparison. */ +function flatNotes(track: ParsedChart['trackData'][number]) { + return track.noteEventGroups.flatMap(g => + g.map(n => ({ tick: n.tick, type: n.type, flags: n.flags, length: n.length })), + ) +} + +function findTrack(chart: ParsedChart, instrument: ParsedChart['trackData'][number]['instrument'], difficulty = 'expert') { + const t = chart.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty) + if (!t) throw new Error(`no ${difficulty} ${instrument} track in round-tripped chart`) + return t +} + +describe('writeChartFile round-trip: [Song] metadata', () => { + it('preserves chart resolution', () => { + const re = roundTrip(createEmptyChart({ resolution: 192 })) + expect(re.resolution).toBe(192) + }) + + it('preserves string metadata fields', () => { + const chart = createEmptyChart() + chart.metadata.name = 'My Song' + chart.metadata.artist = 'Some Band' + chart.metadata.album = 'Greatest Hits' + chart.metadata.charter = 'Me' + chart.metadata.genre = 'Rock' + chart.metadata.year = '2024' + const re = roundTrip(chart) + expect(re.metadata).toMatchObject({ + name: 'My Song', + artist: 'Some Band', + album: 'Greatest Hits', + charter: 'Me', + genre: 'Rock', + year: '2024', + }) + }) + + it('preserves chart_offset', () => { + const chart = createEmptyChart() + chart.metadata.chart_offset = 250 + expect(roundTrip(chart).metadata.chart_offset).toBe(250) + }) + + it('preserves preview_start_time', () => { + const chart = createEmptyChart() + chart.metadata.preview_start_time = 30000 + expect(roundTrip(chart).metadata.preview_start_time).toBe(30000) + }) + + it('preserves diff_* difficulty fields', () => { + const chart = createEmptyChart() + chart.metadata.diff_guitar = 5 + expect(roundTrip(chart).metadata.diff_guitar).toBe(5) + }) + + it('does not leak ini `delay` into chart_offset', () => { + const chart = createEmptyChart() + chart.metadata.delay = 999 + expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() + }) + + it('does not emit a chart_offset for the value 0', () => { + const chart = createEmptyChart() + chart.metadata.chart_offset = 0 + expect(roundTrip(chart).metadata.chart_offset).toBeUndefined() + }) +}) + +describe('writeChartFile round-trip: [SyncTrack]', () => { + it('preserves the default tempo and time signature on an empty chart', () => { + const re = roundTrip(createEmptyChart()) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: t.beatsPerMinute }))).toEqual([{ tick: 0, bpm: 120 }]) + expect(re.timeSignatures.map(ts => ({ tick: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { tick: 0, n: 4, d: 4 }, + ]) + }) + + it('preserves non-4/4 time signatures', () => { + const chart = createEmptyChart({ timeSignature: { numerator: 6, denominator: 8 } }) + expect(roundTrip(chart).timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('preserves multiple tempo changes', () => { + const chart = createEmptyChart({ bpm: 140 }) + chart.tempos.push({ tick: 1920, beatsPerMinute: 200, msTime: 0 }) + const re = roundTrip(chart) + expect(re.tempos.map(t => ({ tick: t.tick, bpm: t.beatsPerMinute }))).toEqual([ + { tick: 0, bpm: 140 }, + { tick: 1920, bpm: 200 }, + ]) + }) + + it('preserves multiple time-signature changes', () => { + const chart = createEmptyChart() + chart.timeSignatures.push({ tick: 3840, numerator: 7, denominator: 8, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.timeSignatures.map(ts => ({ t: ts.tick, n: ts.numerator, d: ts.denominator }))).toEqual([ + { t: 0, n: 4, d: 4 }, + { t: 3840, n: 7, d: 8 }, + ]) + }) + + it('preserves fractional BPM', () => { + const chart = createEmptyChart({ bpm: 137.5 }) + expect(roundTrip(chart).tempos[0].beatsPerMinute).toBe(137.5) + }) + + it('preserves tempo + TS events that share a tick', () => { + const chart = createEmptyChart() + chart.tempos.push({ tick: 960, beatsPerMinute: 150, msTime: 0 }) + chart.timeSignatures.push({ tick: 960, numerator: 3, denominator: 4, msTime: 0, msLength: 0 }) + const re = roundTrip(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 }) + }) +}) + +describe('writeChartFile round-trip: [Events]', () => { + it('preserves sections at the right ticks with correct names', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: 'Intro', msTime: 0, msLength: 0 }) + chart.sections.push({ tick: 1920, name: 'Verse 1', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.sections.map(s => ({ tick: s.tick, name: s.name }))).toEqual([ + { tick: 0, name: 'Intro' }, + { tick: 1920, name: 'Verse 1' }, + ]) + }) + + it('preserves section names with special characters', () => { + const chart = createEmptyChart() + chart.sections.push({ tick: 0, name: '[BREAKDOWN]', msTime: 0, msLength: 0 }) + expect(roundTrip(chart).sections[0].name).toBe('[BREAKDOWN]') + }) + + it('preserves end events', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 9600, msTime: 0, msLength: 0 }) + expect(roundTrip(chart).endEvents.map(e => e.tick)).toEqual([9600]) + }) + + it('preserves unrecognized global events', () => { + const chart = createEmptyChart({ format: 'chart' }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 0, text: 'music_start', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.unrecognizedEventsTrackTextEvents.map(e => ({ tick: e.tick, text: e.text }))).toEqual([ + { tick: 0, text: 'music_start' }, + ]) + }) + + it('does not duplicate an end event that also appears in unrecognizedEventsTrackTextEvents', () => { + const chart = createEmptyChart() + chart.endEvents.push({ tick: 1000, msTime: 0, msLength: 0 }) + chart.unrecognizedEventsTrackTextEvents.push({ tick: 1000, text: 'end', msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + expect(re.endEvents.map(e => e.tick)).toEqual([1000]) + expect(re.unrecognizedEventsTrackTextEvents.filter(e => e.text === 'end')).toHaveLength(0) + }) +}) + +describe('writeChartFile round-trip: unrecognized chart sections', () => { + it('preserves unrecognized sections with arbitrary content', () => { + const chart = createEmptyChart() + chart.unrecognizedChartSections.push({ + name: 'MysteryBlock', + lines: ['0 = foo', '100 = bar'], + }) + expect(roundTrip(chart).unrecognizedChartSections).toEqual([ + { name: 'MysteryBlock', lines: ['0 = foo', '100 = bar'] }, + ]) + }) +}) + +// --------------------------------------------------------------------------- +// Track section round-trip tests +// --------------------------------------------------------------------------- + +describe('writeChartFile round-trip: drum tracks', () => { + it('preserves a per-difficulty drum track (expert)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart, 'expert') + track.noteEventGroups.push([note(480, noteTypes.redDrum)]) + const re = roundTrip(chart) + expect(findTrack(re, 'drums', 'expert').noteEventGroups).toHaveLength(1) + }) + + it('preserves base 4-lane drum notes with ticks and lengths', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick)]) + track.noteEventGroups.push([note(480, noteTypes.redDrum, 0, 240)]) + track.noteEventGroups.push([note(960, noteTypes.yellowDrum)]) + track.noteEventGroups.push([note(1440, noteTypes.blueDrum)]) + track.noteEventGroups.push([note(1920, noteTypes.greenDrum)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes).toEqual([ + expect.objectContaining({ tick: 0, type: noteTypes.kick, length: 0 }), + expect.objectContaining({ tick: 480, type: noteTypes.redDrum, length: 240 }), + expect.objectContaining({ tick: 960, type: noteTypes.yellowDrum, length: 0 }), + expect.objectContaining({ tick: 1440, type: noteTypes.blueDrum, length: 0 }), + expect.objectContaining({ tick: 1920, type: noteTypes.greenDrum, length: 0 }), + ]) + }) + + it('preserves double-kick (not as a regular kick)', () => { + const chart = createEmptyChart() + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick, noteFlags.doubleKick)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes).toHaveLength(1) + expect(notes[0].flags & noteFlags.doubleKick).toBeTruthy() + }) + + it('preserves cymbal/accent/ghost flags in fourLanePro', () => { + const chart = createEmptyChart() + chart.drumType = 1 + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.redDrum, noteFlags.accent)]) + track.noteEventGroups.push([note(480, noteTypes.yellowDrum, noteFlags.cymbal)]) + track.noteEventGroups.push([note(960, noteTypes.blueDrum, noteFlags.ghost)]) + track.noteEventGroups.push([note(1440, noteTypes.greenDrum, noteFlags.cymbal)]) + const re = roundTrip(chart, '[Song]\npro_drums = True\n') + const notes = flatNotes(findTrack(re, 'drums')) + expect(notes[0].flags & noteFlags.accent).toBeTruthy() + expect(notes[1].flags & noteFlags.cymbal).toBeTruthy() + expect(notes[2].flags & noteFlags.ghost).toBeTruthy() + expect(notes[3].flags & noteFlags.cymbal).toBeTruthy() + }) + + // Flam (N 109) round-trip lives in the MIDI writer tests: the .chart parser + // doesn't recognize N 109, so flam doesn't survive a .chart round-trip. + + it('preserves star power, solo sections, flex lanes, and activation lanes', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addDrumTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.kick)]) + track.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + track.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + track.flexLanes.push({ tick: 960, length: 480, isDouble: false, msTime: 0, msLength: 0 }) + track.flexLanes.push({ tick: 1440, length: 480, isDouble: true, msTime: 0, msLength: 0 }) + track.drumFreestyleSections.push({ tick: 1920, length: 480, isCoda: false, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + const reTrack = findTrack(re, 'drums') + expect(reTrack.starPowerSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 0, l: 960 }]) + expect(reTrack.soloSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 480, l: 480 }]) + expect(reTrack.flexLanes.map(f => ({ t: f.tick, l: f.length, d: f.isDouble }))).toEqual([ + { t: 960, l: 480, d: false }, + { t: 1440, l: 480, d: true }, + ]) + expect(reTrack.drumFreestyleSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 1920, l: 480 }]) + }) +}) + +describe('writeChartFile round-trip: 5-fret tracks', () => { + it('preserves base 5-fret notes on a guitar track', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart, 'guitar') + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.noteEventGroups.push([note(100, noteTypes.red)]) + track.noteEventGroups.push([note(200, noteTypes.yellow)]) + track.noteEventGroups.push([note(300, noteTypes.blue)]) + track.noteEventGroups.push([note(400, noteTypes.orange)]) + track.noteEventGroups.push([note(500, noteTypes.open)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + expect(notes.map(n => ({ tick: n.tick, type: n.type }))).toEqual([ + { tick: 0, type: noteTypes.green }, + { tick: 100, type: noteTypes.red }, + { tick: 200, type: noteTypes.yellow }, + { tick: 300, type: noteTypes.blue }, + { tick: 400, type: noteTypes.orange }, + { tick: 500, type: noteTypes.open }, + ]) + }) + + it('preserves the tap flag', () => { + const chart = createEmptyChart() + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green, noteFlags.tap)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + expect(notes[0].flags & noteFlags.tap).toBeTruthy() + }) + + it('preserves a forced-hopo flag on a note whose natural state is strum', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addFretTrack(chart) + // Two greens far apart — neither is natural HOPO. Flag the second → round-trip keeps the HOPO flag. + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.noteEventGroups.push([note(1920, noteTypes.green, noteFlags.hopo)]) + const re = roundTrip(chart) + const notes = flatNotes(findTrack(re, 'guitar')) + const hopoNote = notes.find(n => n.tick === 1920)! + expect(hopoNote.flags & noteFlags.hopo).toBeTruthy() + }) + + it('preserves star power and solo sections on a guitar track', () => { + const chart = createEmptyChart({ resolution: 480 }) + const track = addFretTrack(chart) + track.noteEventGroups.push([note(0, noteTypes.green)]) + track.starPowerSections.push({ tick: 0, length: 960, msTime: 0, msLength: 0 }) + track.soloSections.push({ tick: 480, length: 480, msTime: 0, msLength: 0 }) + const re = roundTrip(chart) + const reTrack = findTrack(re, 'guitar') + expect(reTrack.starPowerSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 0, l: 960 }]) + expect(reTrack.soloSections.map(s => ({ t: s.tick, l: s.length }))).toEqual([{ t: 480, l: 480 }]) + }) +}) diff --git a/src/__tests__/create-chart.test.ts b/src/__tests__/create-chart.test.ts new file mode 100644 index 0000000..e299857 --- /dev/null +++ b/src/__tests__/create-chart.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for createEmptyChart: the programmatic-build counterpart to parseChartAndIni. + */ + +import { describe, expect, it } from 'vitest' + +import { createEmptyChart } from '../chart/create-chart' +import { defaultIniChartModifiers } from '../chart/note-parsing-interfaces' + +describe('createEmptyChart', () => { + it('uses defaults when no options are provided', () => { + const chart = createEmptyChart() + + expect(chart.resolution).toBe(480) + expect(chart.tempos).toEqual([{ tick: 0, beatsPerMinute: 120, msTime: 0 }]) + expect(chart.timeSignatures).toEqual([ + { tick: 0, numerator: 4, denominator: 4, msTime: 0, msLength: 0 }, + ]) + expect(chart.format).toBe('chart') + }) + + it('honors resolution, bpm, and time signature overrides', () => { + const chart = createEmptyChart({ + resolution: 192, + bpm: 150, + timeSignature: { numerator: 6, denominator: 8 }, + }) + + expect(chart.resolution).toBe(192) + expect(chart.tempos[0].beatsPerMinute).toBe(150) + expect(chart.timeSignatures[0]).toMatchObject({ numerator: 6, denominator: 8 }) + }) + + it('honors format override', () => { + const chart = createEmptyChart({ format: 'mid' }) + expect(chart.format).toBe('mid') + }) + + it('produces a chart with empty metadata, tracks, and sections', () => { + const chart = createEmptyChart() + + expect(chart.metadata).toEqual({}) + expect(chart.drumType).toBeNull() + expect(chart.trackData).toEqual([]) + expect(chart.sections).toEqual([]) + expect(chart.endEvents).toEqual([]) + expect(chart.unrecognizedEventsTrackTextEvents).toEqual([]) + expect(chart.unrecognizedEventsTrackMidiEvents).toEqual([]) + expect(chart.unrecognizedMidiTracks).toEqual([]) + expect(chart.unrecognizedChartSections).toEqual([]) + expect(chart.unrecognizedSyncTrackEvents).toEqual([]) + expect(chart.parseIssues).toEqual([]) + }) + + it('produces a chart with empty vocal tracks', () => { + const chart = createEmptyChart() + expect(chart.vocalTracks).toEqual({ parts: {}, rangeShifts: [], lyricShifts: [] }) + }) + + it('produces a chart with empty chartBytes and default ini modifiers', () => { + const chart = createEmptyChart() + expect(chart.chartBytes).toBeInstanceOf(Uint8Array) + expect(chart.chartBytes.length).toBe(0) + expect(chart.iniChartModifiers).toBe(defaultIniChartModifiers) + }) +}) diff --git a/src/__tests__/ini-writer.test.ts b/src/__tests__/ini-writer.test.ts new file mode 100644 index 0000000..667a932 --- /dev/null +++ b/src/__tests__/ini-writer.test.ts @@ -0,0 +1,134 @@ +/** + * Round-trip tests for writeIniFile. + * + * Exercise writeIniFile through parseChartAndIni: build an ini metadata + * object, write it, pair with a minimal notes.chart, re-parse, and assert + * parsedChart.metadata matches what we wrote. No assertions about the + * serialized ini text itself — the parser is the source of truth for + * observable behavior. + */ + +import { describe, expect, it } from 'vitest' + +import { parseChartAndIni } from '../chart/parse-chart-and-ini' +import { defaultMetadata } from '../ini/ini-scanner' +import { writeIniFile } from '../ini/ini-writer' + +/** Minimal valid notes.chart bytes — enough to parse, empty of content. */ +const EMPTY_CHART = new TextEncoder().encode( + [ + '[Song]', + '{', + ' Resolution = 480', + '}', + '[SyncTrack]', + '{', + ' 0 = TS 4', + ' 0 = B 120000', + '}', + '[Events]', + '{', + '}', + ].join('\r\n'), +) + +type IniInput = Parameters[0] + +function roundTrip(metadata: IniInput) { + const iniText = writeIniFile(metadata) + const result = parseChartAndIni([ + { fileName: 'notes.chart', data: EMPTY_CHART }, + { fileName: 'song.ini', data: new TextEncoder().encode(iniText) }, + ]) + if (!result.parsedChart) throw new Error('round-trip produced no parsedChart') + return result.parsedChart.metadata +} + +describe('writeIniFile round-trip', () => { + it('empty metadata survives a round trip', () => { + const out = roundTrip({}) + expect(out.extraIniFields).toBeUndefined() + }) + + it('preserves string fields', () => { + const input = { + name: 'Round Trip', + artist: 'Tester', + album: 'Test Album', + genre: 'Rock', + year: '2023', + charter: 'Me', + } + const out = roundTrip(input) + for (const key of Object.keys(input) as (keyof typeof input)[]) { + expect(out[key]).toBe(input[key]) + } + }) + + it('preserves boolean fields with correct type', () => { + const input = { pro_drums: true, five_lane_drums: false, modchart: false, end_events: true } + const out = roundTrip(input) + expect(out.pro_drums).toBe(true) + expect(out.five_lane_drums).toBe(false) + expect(out.modchart).toBe(false) + expect(out.end_events).toBe(true) + }) + + it('preserves numeric fields', () => { + const input = { diff_drums: 5, diff_guitar: 3, delay: -250, song_length: 180000, hopo_frequency: 170 } + const out = roundTrip(input) + expect(out.diff_drums).toBe(5) + expect(out.diff_guitar).toBe(3) + expect(out.delay).toBe(-250) + expect(out.song_length).toBe(180000) + expect(out.hopo_frequency).toBe(170) + }) + + it('undefined fields are filled in from defaultMetadata on re-parse', () => { + // writeIniFile skips undefined fields; the re-parser then fills them in + // from defaultMetadata. Verify set fields survive and unset fields land + // on their documented defaults. + const out = roundTrip({ name: 'Song', artist: undefined, album: 'Album' }) + expect(out.name).toBe('Song') + expect(out.album).toBe('Album') + expect(out.artist).toBe(defaultMetadata.artist) + }) + + it('preserves extraIniFields for unknown ini keys', () => { + const out = roundTrip({ + name: 'N', + extraIniFields: { rating: '1', vocal_gender: 'male' }, + }) + expect(out.name).toBe('N') + expect(out.extraIniFields).toEqual({ rating: '1', vocal_gender: 'male' }) + }) + + it('round-trips a full metadata set with mixed types', () => { + const input = { + name: 'Round Trip', + artist: 'Tester', + album: 'Test Album', + genre: 'Rock', + year: '2023', + charter: 'Me', + song_length: 180000, + diff_guitar: 3, + diff_drums: 5, + delay: -50, + hopo_frequency: 170, + eighthnote_hopo: true, + multiplier_note: 116, + modchart: false, + pro_drums: true, + five_lane_drums: false, + end_events: true, + extraIniFields: { rating: '2', playlist: 'Test' }, + } + const out = roundTrip(input) + for (const key of Object.keys(input) as (keyof typeof input)[]) { + if (key === 'extraIniFields') continue + expect(out[key as keyof typeof defaultMetadata]).toEqual(input[key]) + } + expect(out.extraIniFields).toEqual(input.extraIniFields) + }) +}) diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts new file mode 100644 index 0000000..f4fa682 --- /dev/null +++ b/src/chart/chart-writer.ts @@ -0,0 +1,526 @@ +/** + * `.chart` file writer — serializes a ParsedChart back to chart text. + * + * Emits `[Song]`, `[SyncTrack]`, `[Events]`, per-instrument track sections + * (e.g. `[ExpertSingle]`, `[HardDrums]`), and any unrecognized chart sections + * that the parser preserved verbatim. + */ + +import type { Instrument } from '../interfaces' +import { computeHopoThresholdTicks, isNaturalHopo } from './natural-hopo' +import type { NoteEvent, NoteType } from './note-parsing-interfaces' +import { noteFlags, noteTypes } from './note-parsing-interfaces' +import type { ParsedChart } from './parse-chart-and-ini' + +type ParsedTrack = ParsedChart['trackData'][number] + +/** + * Serialize a {@link ParsedChart} to `.chart` file text (CRLF line endings). + * Emits `[Song]`, `[SyncTrack]`, `[Events]`, per-instrument track sections, + * and any chart sections the parser preserved verbatim for round-trip. + */ +export function writeChartFile(chart: ParsedChart): string { + const sections: string[][] = [] + sections.push(serializeSongSection(chart)) + sections.push(serializeSyncTrack(chart)) + sections.push(serializeEventsSection(chart)) + + for (const track of chart.trackData) { + const lines = serializeTrackSection(track, chart) + if (lines.length === 0) continue + sections.push(lines) + } + + for (const us of chart.unrecognizedChartSections) { + const sec: string[] = [`[${us.name}]`, '{'] + for (const ln of us.lines) sec.push(` ${ln}`) + sec.push('}') + sections.push(sec) + } + + const out: string[] = [] + for (const section of sections) { + for (const line of section) out.push(line) + } + return out.join('\r\n') + '\r\n' +} + +// --------------------------------------------------------------------------- +// [Song] section +// --------------------------------------------------------------------------- + +/** + * The subset of `song.ini` fields that the `[Song]` section in a `.chart` + * file supports. Values for these fields in {@link ParsedChart.metadata} get + * re-emitted here; all other ini fields live exclusively in `song.ini`. + */ +function serializeSongSection(chart: ParsedChart): string[] { + const lines: string[] = ['[Song]', '{'] + const m = chart.metadata + + if (m.name != null) lines.push(` Name = "${m.name}"`) + if (m.artist != null) lines.push(` Artist = "${m.artist}"`) + if (m.charter != null) lines.push(` Charter = "${m.charter}"`) + if (m.album != null) lines.push(` Album = "${m.album}"`) + if (m.genre != null) lines.push(` Genre = "${m.genre}"`) + // [Song]'s `Year` is historically written with a leading `, ` separator + // (a GHTCP quirk the scan-chart parser strips back out — see chart-parser). + if (m.year != null) lines.push(` Year = ", ${m.year}"`) + + lines.push(` Resolution = ${chart.resolution}`) + + // `[Song].Offset` is a .chart-only field — distinct from ini's `delay`, + // which games recognize only in song.ini. Read from `metadata.chart_offset` + // (populated by the parser from [Song].Offset) so that ini's `delay` + // never overrides it on the ini-wins merge. `PreviewStart` is seconds in + // the file, ms on ParsedChart. + if (m.chart_offset != null && m.chart_offset !== 0) { + lines.push(` Offset = ${m.chart_offset / 1000}`) + } + if (m.preview_start_time != null) { + lines.push(` PreviewStart = ${m.preview_start_time / 1000}`) + } + if (m.diff_guitar != null) lines.push(` Difficulty = ${m.diff_guitar}`) + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [SyncTrack] section +// --------------------------------------------------------------------------- + +function serializeSyncTrack(chart: ParsedChart): string[] { + const lines: string[] = ['[SyncTrack]', '{'] + + type SyncEvent = + | { tick: number; order: 0; kind: 'ts'; numerator: number; denominator: number } + | { tick: number; order: 1; kind: 'bpm'; beatsPerMinute: number } + + const events: SyncEvent[] = [ + ...chart.timeSignatures.map( + (ts): SyncEvent => ({ + tick: ts.tick, + order: 0, + kind: 'ts', + numerator: ts.numerator, + denominator: ts.denominator, + }), + ), + ...chart.tempos.map( + (t): SyncEvent => ({ tick: t.tick, order: 1, kind: 'bpm', beatsPerMinute: t.beatsPerMinute }), + ), + ] + + // Sort by tick, then TS before B 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 + }) + + for (const ev of events) { + if (ev.kind === 'bpm') { + const millibeats = Math.round(ev.beatsPerMinute * 1000) + lines.push(` ${ev.tick} = B ${millibeats}`) + } else if (ev.denominator === 4) { + lines.push(` ${ev.tick} = TS ${ev.numerator}`) + } else { + lines.push(` ${ev.tick} = TS ${ev.numerator} ${Math.log2(ev.denominator)}`) + } + } + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [Events] section +// --------------------------------------------------------------------------- + +function serializeEventsSection(chart: ParsedChart): string[] { + const lines: string[] = ['[Events]', '{'] + + // Typed events: sections (wrapped as `[section name]`), endEvents. + // Wrapping: scan-chart's section regex `^\[?(?:section|prc)[ _](.*?)\]?$` + // is greedy for the trailing `\]?$` and lazy for `(.*?)`, so an unwrapped + // `section [name]` would have its trailing `]` eaten as the optional + // closing bracket. Wrapping in outer brackets preserves the name. + const events: { tick: number; text: string }[] = [] + for (const s of chart.sections) events.push({ tick: s.tick, text: `[section ${s.name}]` }) + for (const e of chart.endEvents) events.push({ tick: e.tick, text: 'end' }) + + // Unrecognized global events (crowd events, music_start/end, coda, etc.). + // If the chart was originally parsed from .mid, these came in as `[text]` + // (square-bracketed MIDI text meta events) — strip the brackets so the + // .chart output writes the naked text between quotes (the .chart E-event + // convention). + const sourceIsMidi = chart.format === 'mid' + let hasCodaInGlobalEvents = false + for (const ge of chart.unrecognizedEventsTrackTextEvents) { + let text = ge.text + if (sourceIsMidi) { + const trimmed = text.trimEnd() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + text = trimmed.slice(1, -1) + } + } + // endEvents are already emitted above; skip duplicates here. + if (text.trim() === 'end') continue + events.push({ tick: ge.tick, text }) + if (text.trim() === 'coda') hasCodaInGlobalEvents = true + } + + // Coda events from drumFreestyleSections — only when not already present + // in the unrecognizedEvents stream above. + if (!hasCodaInGlobalEvents) { + const codaTicks = new Set() + for (const track of chart.trackData) { + for (const fs of track.drumFreestyleSections) { + if (fs.isCoda) codaTicks.add(fs.tick) + } + } + for (const tick of codaTicks) events.push({ tick, text: 'coda' }) + } + + // Vocal phrases + lyrics from the normalized `vocals` part. .chart supports + // only one vocal track (harmonies are MIDI-only). + type TaggedEvent = { tick: number; text: string; subKey: number } + const eventPriority = (text: string): number => { + if (text === 'phrase_end') return 0 + if (text.startsWith('lyric ')) return 1 + if (text === 'coda') return 2 + if (text.startsWith('section ')) return 3 + if (text === 'phrase_start') return 4 + if (text === 'end') return 5 + return 4 + } + const tagged: TaggedEvent[] = events.map(e => ({ + tick: e.tick, + text: e.text, + subKey: 1_000_000 + eventPriority(e.text), + })) + + const vocalsPart = chart.vocalTracks.parts.vocals + if (vocalsPart) { + // Emit phrase_start for every phrase. Omit phrase_end when the next + // phrase starts at exactly the same tick: the .chart parser closes the + // current phrase implicitly on the next phrase_start, so an explicit + // phrase_end would round-trip as a spurious duplicate. + const phrases = vocalsPart.notePhrases + for (let i = 0; i < phrases.length; i++) { + const phrase = phrases[i] + const endTick = phrase.tick + phrase.length + tagged.push({ tick: phrase.tick, text: 'phrase_start', subKey: i * 2 }) + const next = phrases[i + 1] + const nextStartsAtOurEnd = next && next.tick === endTick + if (!nextStartsAtOurEnd) { + tagged.push({ tick: endTick, text: 'phrase_end', subKey: i * 2 + 1 }) + } + } + for (const phrase of phrases) { + for (const lyric of phrase.lyrics) { + tagged.push({ tick: lyric.tick, text: `lyric ${lyric.text}`, subKey: 1_000_000 + 1 }) + } + } + } + + tagged.sort((a, b) => { + if (a.tick !== b.tick) return a.tick - b.tick + return a.subKey - b.subKey + }) + + for (const ev of tagged) { + lines.push(` ${ev.tick} = E "${ev.text}"`) + } + + lines.push('}') + return lines +} + +// --------------------------------------------------------------------------- +// [] track sections +// --------------------------------------------------------------------------- + +type TrackLineEvent = + | { tick: number; sortKey: 1; kind: 'S'; value: number; length: number } + | { tick: number; sortKey: 0; kind: 'N'; value: number; length: number } + | { tick: number; sortKey: 2; kind: 'E'; text: string } + +const instrumentSectionSuffix: Record = { + guitar: 'Single', + guitarcoop: 'DoubleGuitar', + rhythm: 'DoubleRhythm', + bass: 'DoubleBass', + drums: 'Drums', + keys: 'Keyboard', + guitarghl: 'GHLGuitar', + guitarcoopghl: 'GHLCoop', + rhythmghl: 'GHLRhythm', + bassghl: 'GHLBass', +} + +const difficultyPrefix: Record = { + expert: 'Expert', + hard: 'Hard', + medium: 'Medium', + easy: 'Easy', +} + +const drumNoteTypeToNoteNumber: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 4, +} + +const drumNoteTypeToNoteNumberFiveLane: Partial> = { + [noteTypes.kick]: 0, + [noteTypes.redDrum]: 1, + [noteTypes.yellowDrum]: 2, + [noteTypes.blueDrum]: 3, + [noteTypes.greenDrum]: 5, +} + +const fiveFretNoteTypeToNoteNumber: Partial> = { + [noteTypes.open]: 7, + [noteTypes.green]: 0, + [noteTypes.red]: 1, + [noteTypes.yellow]: 2, + [noteTypes.blue]: 3, + [noteTypes.orange]: 4, +} + +const ghlNoteTypeToNoteNumber: Partial> = { + [noteTypes.open]: 7, + [noteTypes.white1]: 0, + [noteTypes.white2]: 1, + [noteTypes.white3]: 2, + [noteTypes.black1]: 3, + [noteTypes.black2]: 4, + [noteTypes.black3]: 8, +} + +const ghlInstrumentSet = new Set([ + 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl', +]) + +function getNoteNumberMap( + instrument: Instrument, + drumType: number | null | undefined, +): Partial> { + if (instrument === 'drums') { + return drumType === 2 ? drumNoteTypeToNoteNumberFiveLane : drumNoteTypeToNoteNumber + } + if (ghlInstrumentSet.has(instrument)) return ghlNoteTypeToNoteNumber + return fiveFretNoteTypeToNoteNumber +} + +const drumCymbalNoteNumber: Partial> = { + [noteTypes.yellowDrum]: 66, + [noteTypes.blueDrum]: 67, + [noteTypes.greenDrum]: 68, +} + +const drumAccentNoteNumber: Partial> = { + [noteTypes.kick]: 33, + [noteTypes.redDrum]: 34, + [noteTypes.yellowDrum]: 35, + [noteTypes.blueDrum]: 36, + [noteTypes.greenDrum]: 37, +} + +const drumGhostNoteNumber: Partial> = { + [noteTypes.kick]: 39, + [noteTypes.redDrum]: 40, + [noteTypes.yellowDrum]: 41, + [noteTypes.blueDrum]: 42, + [noteTypes.greenDrum]: 43, +} + +// --------------------------------------------------------------------------- +// serializeTrackSection +// --------------------------------------------------------------------------- + +function serializeTrackSection(track: ParsedTrack, chart: ParsedChart): string[] { + const suffix = instrumentSectionSuffix[track.instrument] + const prefix = difficultyPrefix[track.difficulty] + if (suffix == null || prefix == null) return [] + + const lines: string[] = [`[${prefix}${suffix}]`, '{'] + const drumType = chart.drumType + const noteMap = getNoteNumberMap(track.instrument, drumType) + const isDrums = track.instrument === 'drums' + + // Pre-compute natural-HOPO state per group for fret instruments. + const isNaturalHopoByGroup: boolean[] = [] + if (!isDrums) { + const hopoThreshold = computeHopoThresholdTicks( + chart.resolution, + chart.iniChartModifiers.hopo_frequency, + chart.iniChartModifiers.eighthnote_hopo, + 'chart', + ) + let lastGroup: NoteEvent[] | null = null + for (const group of track.noteEventGroups) { + isNaturalHopoByGroup.push(isNaturalHopo(group, lastGroup, hopoThreshold, 'chart')) + lastGroup = group + } + } + + const events: TrackLineEvent[] = [] + + for (const sp of track.starPowerSections) { + events.push({ tick: sp.tick, sortKey: 1, kind: 'S', value: 2, length: sp.length }) + } + for (const fs of track.drumFreestyleSections) { + events.push({ tick: fs.tick, sortKey: 1, kind: 'S', value: 64, length: fs.length }) + } + for (const fl of track.flexLanes) { + events.push({ tick: fl.tick, sortKey: 1, kind: 'S', value: fl.isDouble ? 66 : 65, length: fl.length }) + } + for (const vp of track.versusPhrases) { + events.push({ tick: vp.tick, sortKey: 1, kind: 'S', value: vp.isPlayer2 ? 1 : 0, length: vp.length }) + } + // Solo sections: `length = end - start + 1` in the parser, so subtract 1 to + // round-trip `soloend` to the same tick. + for (const solo of track.soloSections) { + events.push({ tick: solo.tick, sortKey: 2, kind: 'E', text: 'solo' }) + events.push({ tick: solo.tick + Math.max(solo.length - 1, 0), sortKey: 2, kind: 'E', text: 'soloend' }) + } + for (const te of track.textEvents) { + events.push({ tick: te.tick, sortKey: 2, kind: 'E', text: te.text }) + } + + for (let gi = 0; gi < track.noteEventGroups.length; gi++) { + const group = track.noteEventGroups[gi] + let hasFlamInGroup = false + + for (const note of group) { + let noteNumber = noteMap[note.type] + if (noteNumber == null) continue + + // 5-lane cymbal-on-green: parser normalizes the 5-lane orange pad into + // greenDrum, so cymbal-flagged greens go back to N 4 to restore the + // original orange placement. Plain green (tom) stays at N 5. + if (isDrums && drumType === 2 && note.type === noteTypes.greenDrum && (note.flags & noteFlags.cymbal)) { + noteNumber = 4 + } + + // 5-lane drumType detection requires at least one fiveGreenDrum (N 5). + // If a chart has green+cymbal but no plain green, the parser would + // re-detect as fourLane. Heuristic: a blueDrum at the same tick as a + // green+cymbal came from N 5 + N 4 in the original (the + // hasOrangeAndGreen=true case). Emit as N 5 to preserve the layout. + if ( + isDrums && + drumType === 2 && + note.type === noteTypes.blueDrum && + group.some(n => n.type === noteTypes.greenDrum && (n.flags & noteFlags.cymbal)) + ) { + noteNumber = 5 + } + + const isDoubleKick = isDrums && note.type === noteTypes.kick && (note.flags & noteFlags.doubleKick) + if (isDoubleKick) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: 32, length: note.length }) + } else { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: noteNumber, length: note.length }) + } + + if (isDrums) { + // Cymbal markers only emit in fourLanePro. fourLane/fiveLane omit + // markers (cymbal/tom state is implicit); emitting them would cause + // the parser to re-detect the chart as fourLanePro. + if ((note.flags & noteFlags.cymbal) && drumType === 1) { + const cymbalNote = drumCymbalNoteNumber[note.type] + if (cymbalNote != null) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: cymbalNote, length: 0 }) + } + } + + // Accent/ghost markers match the eventType of the emitted note. + // If we remapped green → N 5 (fiveGreen), use N 38/N 44. + const isFiveGreenEmitted = noteNumber === 5 + if (note.flags & noteFlags.accent) { + const accentNote = isFiveGreenEmitted ? 38 : drumAccentNoteNumber[note.type] + if (accentNote != null) events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: accentNote, length: 0 }) + } + if (note.flags & noteFlags.ghost) { + const ghostNote = isFiveGreenEmitted ? 44 : drumGhostNoteNumber[note.type] + if (ghostNote != null) events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: ghostNote, length: 0 }) + } + if (note.flags & noteFlags.flam) hasFlamInGroup = true + } else { + if (note.flags & noteFlags.tap) { + events.push({ tick: note.tick, sortKey: 0, kind: 'N', value: 6, length: 0 }) + } + } + } + + // ForceUnnatural (N 5) when natural HOPO state disagrees with the flag. + if (!isDrums && group.length > 0) { + const firstNote = group[0] + const wantHopo = (firstNote.flags & noteFlags.hopo) !== 0 + const wantStrum = (firstNote.flags & noteFlags.strum) !== 0 + const natural = isNaturalHopoByGroup[gi] + if ((wantHopo && !natural) || (wantStrum && natural)) { + events.push({ tick: firstNote.tick, sortKey: 0, kind: 'N', value: 5, length: 0 }) + } + } + + if (hasFlamInGroup && group.length > 0) { + events.push({ tick: group[0].tick, sortKey: 0, kind: 'N', value: 109, length: 0 }) + } + } + + // Disco-flip state transitions → `mix drums0[...]` text events. + if (isDrums) { + const diffIdx: Record = { easy: 0, medium: 1, hard: 2, expert: 3 } + const di = diffIdx[track.difficulty] ?? 3 + let currentState: 'off' | 'disco' | 'discoNoflip' = 'off' + + for (const group of track.noteEventGroups) { + if (group.length === 0) continue + let newState: 'off' | 'disco' | 'discoNoflip' = 'off' + for (const note of group) { + if (note.type === noteTypes.redDrum || note.type === noteTypes.yellowDrum) { + if (note.flags & noteFlags.discoNoflip) { newState = 'discoNoflip'; break } + if (note.flags & noteFlags.disco) { newState = 'disco'; break } + } + } + if (newState !== currentState) { + const tick = group[0].tick + const suf = newState === 'off' ? 'drums0' : newState === 'disco' ? 'drums0d' : 'drums0dnoflip' + events.push({ tick, sortKey: 2, kind: 'E', text: `mix ${di} ${suf}` }) + currentState = newState + } + } + } + + // Sort: by tick, then N (0) before S (1) before E (2). Preserve insertion + // order within an N-group at the same tick — chord order is load-bearing + // for downstream YARG parent-note selection. + events.sort((a, b) => (a.tick !== b.tick ? a.tick - b.tick : a.sortKey - b.sortKey)) + + // Deduplicate exact same-tick same-value duplicates (possible after modifier + // emission produces redundant markers). + const deduped: TrackLineEvent[] = [] + for (const ev of events) { + const prev = deduped[deduped.length - 1] + if (prev && prev.tick === ev.tick && prev.kind === ev.kind) { + if (ev.kind === 'E' && prev.kind === 'E' && prev.text === ev.text) continue + if (ev.kind !== 'E' && prev.kind !== 'E' && prev.value === ev.value && prev.length === ev.length) continue + } + deduped.push(ev) + } + + for (const ev of deduped) { + if (ev.kind === 'E') lines.push(` ${ev.tick} = E ${ev.text}`) + else lines.push(` ${ev.tick} = ${ev.kind} ${ev.value} ${ev.length}`) + } + + lines.push('}') + return lines +} + diff --git a/src/chart/create-chart.ts b/src/chart/create-chart.ts new file mode 100644 index 0000000..43abddb --- /dev/null +++ b/src/chart/create-chart.ts @@ -0,0 +1,55 @@ +import { defaultIniChartModifiers } from './note-parsing-interfaces' +import type { ParsedChart } from './parse-chart-and-ini' + +/** + * Build a minimal valid {@link ParsedChart} from scratch, without parsing any + * source bytes. Useful for programmatic chart generation and as the counterpart + * to `parseChartAndIni`. + * + * The returned chart has: + * - the requested resolution (default 480) + * - a single tempo event at tick 0 (default 120 BPM) + * - a single time signature at tick 0 (default 4/4) + * - empty metadata, tracks, sections, vocal parts, and unrecognized events + * - `chartBytes` set to an empty `Uint8Array` — there is no source file. + * `scanChart()` will hash empty bytes (deterministic but not an identity); + * callers that need a meaningful `chartHash` should serialize via + * `writeChartFile`/`writeMidiFile` and re-parse. + * - `format` defaults to `'chart'` — the text format is the simpler target + * for programmatic construction; pass `format: 'mid'` when the caller + * needs `.mid` output. + * - `iniChartModifiers` set to the library defaults + */ +export function createEmptyChart(options?: { + format?: 'chart' | 'mid' + resolution?: number + bpm?: number + timeSignature?: { numerator: number; denominator: number } +}): ParsedChart { + const resolution = options?.resolution ?? 480 + const bpm = options?.bpm ?? 120 + const numerator = options?.timeSignature?.numerator ?? 4 + const denominator = options?.timeSignature?.denominator ?? 4 + const format = options?.format ?? 'chart' + + return { + resolution, + drumType: null, + metadata: {}, + parseIssues: [], + vocalTracks: { parts: {}, rangeShifts: [], lyricShifts: [] }, + endEvents: [], + unrecognizedEventsTrackTextEvents: [], + unrecognizedEventsTrackMidiEvents: [], + unrecognizedMidiTracks: [], + unrecognizedChartSections: [], + tempos: [{ tick: 0, beatsPerMinute: bpm, msTime: 0 }], + timeSignatures: [{ tick: 0, numerator, denominator, msTime: 0, msLength: 0 }], + unrecognizedSyncTrackEvents: [], + sections: [], + trackData: [], + chartBytes: new Uint8Array(0), + format, + iniChartModifiers: defaultIniChartModifiers, + } +} diff --git a/src/chart/index.ts b/src/chart/index.ts index 47eaa40..4661673 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,2 +1,4 @@ export * from './chart-scanner' +export * from './chart-writer' +export * from './create-chart' export * from './parse-chart-and-ini' diff --git a/src/index.ts b/src/index.ts index e7a46c7..c10c441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,10 @@ import { scanVideo } from './video' export * from './interfaces' export * from './chart/note-parsing-interfaces' export { parseChartFile } from './chart/notes-parser' -export { parseChartAndIni } from './chart' +export { parseChartAndIni, createEmptyChart } from './chart' export type { ParsedChart, ParseChartAndIniResult } from './chart' export { scanIni } from './ini' +export type { IniMetadata } from './ini' export { calculateTrackHash } from './chart/track-hasher' /** diff --git a/src/ini/index.ts b/src/ini/index.ts index ae8ae25..9114849 100644 --- a/src/ini/index.ts +++ b/src/ini/index.ts @@ -1 +1,2 @@ export * from './ini-scanner' +export * from './ini-writer' diff --git a/src/ini/ini-writer.ts b/src/ini/ini-writer.ts new file mode 100644 index 0000000..61707f4 --- /dev/null +++ b/src/ini/ini-writer.ts @@ -0,0 +1,46 @@ +import { defaultMetadata } from './ini-scanner' + +/** + * Metadata input shape accepted by `writeIniFile`. Matches the shape + * `parseChartAndIni` produces on `parsedChart.metadata`: any subset of the + * known ini fields, plus optional `extraIniFields` for unknown keys preserved + * for round-trip. + */ +export type IniMetadata = Partial & { + extraIniFields?: { [key: string]: string } +} + +/** + * Serialize metadata to a `song.ini` string with CRLF line endings. + * + * Emission rules: + * - Starts with a `[song]` header. + * - Known fields emit in the order defined by `defaultMetadata`. + * - Fields whose value is `undefined` are skipped. + * - Booleans format as `True`/`False` (matching Clone Hero convention). + * - `extraIniFields` are appended after the known fields, in insertion + * order. + */ +export function writeIniFile(metadata: IniMetadata): string { + const lines: string[] = ['[song]'] + + const keys = Object.keys(defaultMetadata) as (keyof typeof defaultMetadata)[] + for (const key of keys) { + const value = metadata[key] + if (value === undefined) continue + lines.push(`${key} = ${formatValue(value)}`) + } + + if (metadata.extraIniFields) { + for (const [key, value] of Object.entries(metadata.extraIniFields)) { + lines.push(`${key} = ${value}`) + } + } + + return lines.join('\r\n') + '\r\n' +} + +function formatValue(value: string | number | boolean): string { + if (typeof value === 'boolean') return value ? 'True' : 'False' + return String(value) +}