diff --git a/conformance/002-track-header.deck b/conformance/002-track-header.deck index b22eb9d..b0166c9 100644 --- a/conformance/002-track-header.deck +++ b/conformance/002-track-header.deck @@ -7,4 +7,8 @@ track Pad id c4 gen fm * inf mix gain 0.5 track Lead id c5 gen mymacro cutoff 1200 wave saw mix gain 0.7 +track Layered id c6 gen fm layer 0 * 16 + mix gain 0.6 +track Mixed id c7 gen fm layer 2 * 4 cutoff 900 + mix gain 0.6 remove_track obsolete diff --git a/conformance/002-track-header.expected.json b/conformance/002-track-header.expected.json index b9542f6..db6e21d 100644 --- a/conformance/002-track-header.expected.json +++ b/conformance/002-track-header.expected.json @@ -85,6 +85,51 @@ } ], "genBlocks": [] + }, + { + "name": "Layered", + "id": "c6", + "generatorId": "fm", + "rawGenId": "fm", + "genParams": { + "layer": 0 + }, + "loopBars": 16, + "body": [ + { + "lineNo": 11, + "tokens": [ + "mix", + "gain", + "0.6" + ], + "raw": "mix gain 0.6" + } + ], + "genBlocks": [] + }, + { + "name": "Mixed", + "id": "c7", + "generatorId": "fm", + "rawGenId": "fm", + "genParams": { + "layer": 2, + "cutoff": 900 + }, + "loopBars": 4, + "body": [ + { + "lineNo": 13, + "tokens": [ + "mix", + "gain", + "0.6" + ], + "raw": "mix gain 0.6" + } + ], + "genBlocks": [] } ], "removeTrackIds": [ @@ -179,6 +224,40 @@ } ], "errors": [] + }, + { + "id": "c6", + "rows": [ + { + "kind": "mix", + "gain": 0.6, + "pan": null, + "mute": null, + "solo": null, + "eqLo": null, + "eqMid": null, + "eqHi": null, + "lineNo": 11 + } + ], + "errors": [] + }, + { + "id": "c7", + "rows": [ + { + "kind": "mix", + "gain": 0.6, + "pan": null, + "mute": null, + "solo": null, + "eqLo": null, + "eqMid": null, + "eqHi": null, + "lineNo": 13 + } + ], + "errors": [] } ], "clipBodies": [] diff --git a/docs/DECK_GRAMMAR.md b/docs/DECK_GRAMMAR.md index dd81a61..ad66f4e 100644 --- a/docs/DECK_GRAMMAR.md +++ b/docs/DECK_GRAMMAR.md @@ -72,6 +72,7 @@ track id gen [ * ] [ `. Emit writes `* N` first; a `*` that names no valid length is an error, never a silently dropped token. - `generatorId` spellings are host-registered (`registerGeneratorIdAliases`). Undeclared ids pass through as-is. --- diff --git a/rust/conformance.rs b/rust/conformance.rs index fc91fd0..55fe317 100644 --- a/rust/conformance.rs +++ b/rust/conformance.rs @@ -110,10 +110,20 @@ fn pinned_divergences() { let p = deckfile::parse(&read("002-track-header", "deck")); let names: Vec<&str> = p.tracks.iter().map(|t| t.name.as_str()).collect(); - assert_eq!(names, ["MOS 6581", "Bass", "Pad", "Lead"], "multi-word names"); + assert_eq!( + names, + ["MOS 6581", "Bass", "Pad", "Lead", "Layered", "Mixed"], + "multi-word names" + ); assert_eq!(p.tracks[1].loop_bars, Some(2), "`* 2`"); assert_eq!(p.tracks[2].loop_bars, None, "`* inf` clears the length, it is not an error"); assert_eq!(p.tracks[3].gen_params.num("cutoff"), Some(1200.0), "macro param override"); + // `* N` after other params. This is the case that silently played at 1 bar instead of 16 and + // made two of tish-gba's songs bake differently. + assert_eq!(p.tracks[4].loop_bars, Some(16), "`* N` after a key/value pair"); + assert_eq!(p.tracks[4].gen_params.num("layer"), Some(0.0), "params survive alongside a late `* N`"); + assert_eq!(p.tracks[5].loop_bars, Some(4), "`* N` between key/value pairs"); + assert_eq!(p.tracks[5].gen_params.num("cutoff"), Some(900.0), "params on both sides of `* N`"); let p = deckfile::parse(&read("003-steps-and-locks", "deck")); let steps = p.tracks[0] diff --git a/src/deckfile/Parser.tish b/src/deckfile/Parser.tish index 1b255d0..f0713d4 100644 --- a/src/deckfile/Parser.tish +++ b/src/deckfile/Parser.tish @@ -456,38 +456,48 @@ export fn parseProgram(source) { } } if (idIdx >= 2 && toks.length >= idIdx + 4) { + // Everything after `gen ` is `* ` (pattern length) plus `key value` pairs (macro + // parameter overrides), in ANY order. + // + // `* N` used to be recognized only in the first slot. Anywhere else it fell through to the + // key/value loop and became a param literally named `*` — so `track Pad id pad gen x layer 0 + // * 16` silently lost its length and played as one bar, with no error. Emit always writes + // `* N` first, but hand-written and generated files do not, and a length that quietly + // becomes 1 is the worst kind of wrong. let after = idIdx + 4 let loopBars = null - if (toks.length >= after + 2 && toks[after] === "*") { - let lx = toks[after + 1] - if (lx === "inf" || lx === "infinite") { - loopBars = null - } else { - if (isNumberToken(lx)) { - let nn = Math.floor(Number(lx)) - if (nn >= 1) { - loopBars = nn + let genParams = {} + let gp = after + while (gp < toks.length) { + if (toks[gp] === "*") { + if (gp + 1 < toks.length) { + let lx = toks[gp + 1] + if (lx === "inf" || lx === "infinite") { + loopBars = null + } else if (isNumberToken(lx)) { + let nn = Math.floor(Number(lx)) + if (nn >= 1) { + loopBars = nn + } else { + errors.push({ line: i, msg: "track * N: N must be a positive integer" }) + } } else { - errors.push({ line: i, msg: "track * N: N must be a positive integer" }) + errors.push({ line: i, msg: "track * N: expected number or inf/infinite" }) } } else { errors.push({ line: i, msg: "track * N: expected number or inf/infinite" }) } + gp = gp + 2 + } else if (gp + 1 < toks.length) { + let k = toks[gp] + let v = toks[gp + 1] + let nv = Number(v) + genParams[k] = (nv === nv && v.length > 0) ? nv : v + gp = gp + 2 + } else { + gp = gp + 1 } } - // Trailing `key value` pairs after the gen id (and optional `* N`) are macro parameter overrides. - let genParams = {} - let gp = after - if (toks.length >= after + 2 && toks[after] === "*") { - gp = after + 2 - } - while (gp + 1 < toks.length) { - let k = toks[gp] - let v = toks[gp + 1] - let nv = Number(v) - genParams[k] = (nv === nv && v.length > 0) ? nv : v - gp = gp + 2 - } currentTrack = { name: toks.slice(1, idIdx).join(" "), id: toks[idIdx + 1], diff --git a/test/coverage.mjs b/test/coverage.mjs index c8b750e..5599eb4 100644 --- a/test/coverage.mjs +++ b/test/coverage.mjs @@ -373,6 +373,35 @@ check("directives no errors", dir.errors.length === 0) check("directives do not block parse", dir.bpm === 120) check("bare @ errors", parseProgram("@\n").errors.some((e) => e.msg.includes("directive verb"))) +// ── Track header: `* N` and key/value params in any order ──────────────────── +// `* N` was only recognized in the first slot; anywhere else it became a param literally named `*`, +// so the pattern length silently became 1. +const hdr = (s) => parseProgram(s).tracks[0] +check("star first", hdr("track P id p gen g * 16 layer 0\n").loopBars === 16) +check("star last", hdr("track P id p gen g layer 0 * 16\n").loopBars === 16) +check("star middle", hdr("track P id p gen g layer 0 * 4 cutoff 900\n").loopBars === 4) +check("star last keeps params", hdr("track P id p gen g layer 0 * 16\n").genParams.layer === 0) +check("star middle keeps params", hdr("track P id p gen g layer 0 * 4 cutoff 900\n").genParams.cutoff === 900) +check("star is not a param", hdr("track P id p gen g layer 0 * 16\n").genParams["*"] === undefined) +check("inf last", hdr("track P id p gen g layer 2 * inf\n").loopBars === null) +check("infinite last", hdr("track P id p gen g layer 2 * infinite\n").loopBars === null) +check("no star", hdr("track P id p gen g layer 0\n").loopBars === null) +// A `*` that names no length is an error, not a silently dropped token. +check( + "star zero errors", + parseProgram("track P id p gen g layer 0 * 0\n").errors.some((e) => e.msg.includes("positive integer")) +) +check( + "star non-numeric errors", + parseProgram("track P id p gen g layer 0 * zz\n").errors.some((e) => e.msg.includes("inf/infinite")) +) +check( + "bare trailing star errors", + parseProgram("track P id p gen g layer 0 *\n").errors.some((e) => e.msg.includes("inf/infinite")) +) +// An odd trailing token (no value) is skipped rather than looping forever. +check("odd trailing token", hdr("track P id p gen g layer 0 dangling\n").genParams.layer === 0) + // ── Body lines ──────────────────────────────────────────────────────────────── const body = (s) => parseBodyLine(tokenize(s))