Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions conformance/002-track-header.deck
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 79 additions & 0 deletions conformance/002-track-header.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": []
Expand Down
1 change: 1 addition & 0 deletions docs/DECK_GRAMMAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ track <displayName> id <channelId> gen <generatorId|macro> [ * <N|inf> ] [ <para

- `* N` — **pattern length** in bars (default 1). Channel spans `N × 16` steps and repeats. `* inf` / `* infinite` clears an explicit finite length.
- Trailing `key value` pairs — **macro parameter overrides** when `gen` is a macro name.
- `* N` and the `key value` pairs may appear in **any order** after `gen <id>`. 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.

---
Expand Down
12 changes: 11 additions & 1 deletion rust/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
56 changes: 33 additions & 23 deletions src/deckfile/Parser.tish
Original file line number Diff line number Diff line change
Expand Up @@ -456,38 +456,48 @@ export fn parseProgram(source) {
}
}
if (idIdx >= 2 && toks.length >= idIdx + 4) {
// Everything after `gen <id>` is `* <N|inf>` (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],
Expand Down
29 changes: 29 additions & 0 deletions test/coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Loading