From 84b0e833debbce9d21e47272f6c1e2b83ea9ab35 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:45:06 -0700 Subject: [PATCH 1/3] Add createEmptyChart() counterpart to parseChartAndIni Builds a minimal valid ParsedChart from scratch: default 480 resolution, 120 BPM at tick 0, 4/4 time signature at tick 0, empty tracks/sections/ metadata/vocal parts/unrecognized events. chartBytes defaults to an empty Uint8Array (no source bytes), format defaults to 'chart', iniChartModifiers to the library defaults. Options let callers override resolution, bpm, timeSignature, and format. Useful for programmatic chart generation (e.g. downstream code that builds charts up from scratch rather than parsing source bytes). --- src/__tests__/create-chart.test.ts | 66 ++++++++++++++++++++++++++++++ src/chart/create-chart.ts | 55 +++++++++++++++++++++++++ src/chart/index.ts | 1 + src/index.ts | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/create-chart.test.ts create mode 100644 src/chart/create-chart.ts 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/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..23e5c25 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,2 +1,3 @@ export * from './chart-scanner' +export * from './create-chart' export * from './parse-chart-and-ini' diff --git a/src/index.ts b/src/index.ts index e7a46c7..52adc3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ 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 { calculateTrackHash } from './chart/track-hasher' From 75b92a6ee38cb5cad6f2cfeab947eb6c13efa287 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:46:40 -0700 Subject: [PATCH 2/3] Add writeIniFile() for song.ini emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializes IniMetadata (Partial + extraIniFields) back to song.ini text with: - [song] header - Known fields emitted in the canonical defaultMetadata order - Undefined values skipped - Booleans as True/False (Clone Hero convention) - extraIniFields appended in insertion order - CRLF line endings Derives its field order from Object.keys(defaultMetadata) rather than hardcoding a separate FIELD_ORDER list, so new fields added to defaultMetadata automatically participate in writing. Tests exercise writeIniFile only via round-trip through parseChartAndIni, per reviewer feedback: build a metadata object, write it, re-parse, and assert on parsedChart.metadata. No assertions about the serialized ini text itself (CRLF, field order, True/False formatting, quoting) — those are implementation details. --- src/__tests__/ini-writer.test.ts | 134 +++++++++++++++++++++++++++++++ src/index.ts | 1 + src/ini/index.ts | 1 + src/ini/ini-writer.ts | 46 +++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/__tests__/ini-writer.test.ts create mode 100644 src/ini/ini-writer.ts 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/index.ts b/src/index.ts index 52adc3e..c10c441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { parseChartFile } from './chart/notes-parser' 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) +} From 78bd890fc5d05aec10f8895d04c796fcb4f8b5ec Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 18:50:44 -0700 Subject: [PATCH 3/3] Add writeChartFile() with Song/SyncTrack/Events/unrecognized-section emission Port of the non-instrument-track half of the chart writer into scan-chart: - [Song] emits the subset of metadata the chart body supports (Name, Artist, Charter, Album, Genre, Year, Resolution, Offset, PreviewStart, Difficulty). Other fields live exclusively in song.ini. - [SyncTrack] emits tempos as `B millibeats` and time signatures as `TS numerator [denominator-exponent]`, sorted by tick with TS before B at the same tick. - [Events] emits section markers (wrapped as [section name] to survive the parser's greedy trailing-\] regex), end events, unrecognized global events (with bracket-stripping when source is .mid so round-trip to .chart emits naked text), and vocal phrase_start/phrase_end/lyric events from the normalized vocalTracks.parts.vocals. - Unrecognized chart sections are re-emitted verbatim. Instrument tracks ([ExpertSingle] etc.) land in a follow-up PR. --- src/__tests__/chart-writer.test.ts | 191 ++++++++++++++++++++++++ src/chart/chart-writer.ts | 224 +++++++++++++++++++++++++++++ src/chart/index.ts | 1 + 3 files changed, 416 insertions(+) create mode 100644 src/__tests__/chart-writer.test.ts create mode 100644 src/chart/chart-writer.ts diff --git a/src/__tests__/chart-writer.test.ts b/src/__tests__/chart-writer.test.ts new file mode 100644 index 0000000..9afa46b --- /dev/null +++ b/src/__tests__/chart-writer.test.ts @@ -0,0 +1,191 @@ +/** + * Round-trip tests for writeChartFile: Song / SyncTrack / Events / unrecognized + * sections. Instrument-track tests land with the follow-up PR that ports + * serializeTrackSection. + * + * 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) — 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 { parseChartAndIni, type ParsedChart } from '../chart/parse-chart-and-ini' + +function roundTrip(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 +} + +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', () => { + // delay is ini-only; writing a chart with no chart_offset and a `delay` + // value must not surface as chart_offset after round-trip. + 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', () => { + // 0 is the default in-game behavior; the writer skips it so we don't + // round-trip 0 as a meaningful Offset. + 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'] }, + ]) + }) +}) diff --git a/src/chart/chart-writer.ts b/src/chart/chart-writer.ts new file mode 100644 index 0000000..6518d85 --- /dev/null +++ b/src/chart/chart-writer.ts @@ -0,0 +1,224 @@ +/** + * `.chart` file writer — serializes a ParsedChart back to chart text. + * + * This PR covers the non-instrument-track half of the writer: + * - `[Song]` section + * - `[SyncTrack]` section (tempo + time-signature events) + * - `[Events]` section (sections, endEvents, unrecognized global events, + * vocal phrase/lyric events) + * - Pass-through of unrecognizedChartSections + * + * Instrument track emission (`[ExpertSingle]` etc.) lands in a follow-up PR. + */ + +import type { ParsedChart } from './parse-chart-and-ini' + +/** + * Serialize a {@link ParsedChart} to `.chart` file text (CRLF line endings). + * Emits `[Song]`, `[SyncTrack]`, and `[Events]` sections plus any chart sections + * the parser didn't recognize (preserved verbatim for round-trip). + * + * Note: instrument track sections (`[ExpertSingle]`, `[HardDrums]`, etc.) are + * emitted by a follow-up PR. This entry point currently skips them. + */ +export function writeChartFile(chart: ParsedChart): string { + const sections: string[][] = [] + sections.push(serializeSongSection(chart)) + sections.push(serializeSyncTrack(chart)) + sections.push(serializeEventsSection(chart)) + + // Re-emit any [Section] blocks the parser didn't recognize as standard + // (Song/SyncTrack/Events) or as a track section. Stored verbatim by + // scan-chart's unrecognizedChartSections fallback for round-trip preservation. + 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) + } + + // Flatten without using spread (which would exceed stack size on large arrays). + 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' + 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 }) + } + + // 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 +} diff --git a/src/chart/index.ts b/src/chart/index.ts index 23e5c25..4661673 100644 --- a/src/chart/index.ts +++ b/src/chart/index.ts @@ -1,3 +1,4 @@ export * from './chart-scanner' +export * from './chart-writer' export * from './create-chart' export * from './parse-chart-and-ini'