From 8d2df5c8398891acd3dc323df4753801727aca29 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Mon, 3 Aug 2026 17:59:53 -0700 Subject: [PATCH 1/3] feat: expose the conformance corpus from the crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profiles.json declares which cases a restricted implementation may reject — tish-gba bakes to two sound chips and legitimately rejects most of the language. Nothing verified that declaration. The runner here only checks that every case is ACCOUNTED FOR in a profile, not that the implementation actually behaves that way, so a profile could claim a subset it did not have. The corpus ships inside the crate but a consumer cannot read a dependency's data files, so it was unreachable from the one place that needed it. Now it is embedded via include_str! and exposed as `deckfile::corpus::cases()` and `profiles()`, so a subset implementation can run the same inputs and prove it accepts everything it does not declare it rejects. Generated alongside the rest of the crate, so the embedded copy cannot drift from the corpus it came from. --- scripts/build-rust.mjs | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/scripts/build-rust.mjs b/scripts/build-rust.mjs index 0fdc575..dda8b54 100644 --- a/scripts/build-rust.mjs +++ b/scripts/build-rust.mjs @@ -135,6 +135,62 @@ fs.mkdirSync(path.join(out, "tests"), { recursive: true }) fs.copyFileSync(path.join(root, "rust/conformance.rs"), path.join(out, "tests/conformance.rs")) fs.cpSync(path.join(root, "conformance"), path.join(out, "conformance"), { recursive: true }) +// Expose the corpus as embedded data, not just files on disk. +// +// A restricted implementation — tish-gba bakes a subset of the language — has to be able to run the +// same corpus and prove its `profiles.json` entry is honest: that it accepts everything it does not +// declare it rejects. It cannot read this crate's files (a dependency's data is not reachable from a +// consumer), so `include_str!` them and hand them over. +{ + const dir = path.join(root, "conformance") + const names = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".deck")) + .map((f) => f.replace(/\.deck$/, "")) + .sort() + const rows = names + .map( + (n) => + ` Case { name: ${JSON.stringify(n)}, ` + + `source: include_str!("../conformance/${n}.deck"), ` + + `expected: include_str!("../conformance/${n}.expected.json") },` + ) + .join("\n") + fs.writeFileSync( + path.join(out, "src/corpus.rs"), + `//! The conformance corpus, embedded. +//! +//! GENERATED — see the deck repo's scripts/build-rust.mjs. +//! +//! Every implementation of the \`.deck\` language runs these same inputs. A consumer that supports a +//! SUBSET (tish-gba bakes to two sound chips) uses [\`profiles\`] to declare which cases it may reject +//! and why; anything it does not declare, it must accept and parse identically. + +/// One corpus case: the source, and the expected parse as JSON. +pub struct Case { + pub name: &'static str, + pub source: &'static str, + pub expected: &'static str, +} + +/// Every case, sorted by name. +pub fn cases() -> &'static [Case] { + &CASES +} + +/// \`profiles.json\` verbatim — which cases a restricted profile may reject, and why. +pub fn profiles() -> &'static str { + include_str!("../conformance/profiles.json") +} + +static CASES: [Case; ${names.length}] = [ +${rows} +]; +` + ) + fs.appendFileSync(libPath, "pub mod corpus;\n") +} + // Rewrite the generated manifest: real crate name, version tracking the npm package, and the // metadata crates.io needs. // From 3b87e8f39dbbb93fdb9c8b94dda7668a75e0f476 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Mon, 3 Aug 2026 18:02:40 -0700 Subject: [PATCH 2/3] fix(conformance): make the gba profile true, and give it something to assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gba profile listed 001, 002 and 004 under mustAccept. All three contain constructs the bake rejects by design — `scale`, `remove_track`, `transpose` — so the profile claimed a subset it did not have. Nothing noticed, because until now nothing ran the profile against the implementation. Moved those to mayReject with the actual reason, and added 012-gba-subset: a song built only from what the GBA bake supports, so mustAccept asserts something real instead of being empty. An empty mustAccept would make the test vacuous — it would pass no matter how far the subset shrank. The sharp-name parsing that 001 pins is still covered on the GBA side by 012. --- conformance/012-gba-subset.deck | 20 ++ conformance/012-gba-subset.expected.json | 363 +++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 conformance/012-gba-subset.deck create mode 100644 conformance/012-gba-subset.expected.json diff --git a/conformance/012-gba-subset.deck b/conformance/012-gba-subset.deck new file mode 100644 index 0000000..241d759 --- /dev/null +++ b/conformance/012-gba-subset.deck @@ -0,0 +1,20 @@ +# Squarely inside the GBA bake's subset: two supported generators, no session/automation/scale. +deck 1 +bpm 148 + +track Lead id lead gen gameBoyDmg * 4 + gen type pulse duty 25 env_mode constant vol 13 + gen vib_rate 4 vib_amt 6 + note 60 0 0.25 v 120 + note 64 0.5 0.25 v 110 + note 67 1 0.5 v 100 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape saw vol 15 + note 36 0 2 v 110 + +track Kick id kick gen gbaDirectSound * 4 + gen waveform pulse duty 50 vol 15 bitcrush true pitch_drop -14 pitch_dec 0.05 + adsr a 0 d 0.1 s 0 r 0.03 + step_pitch 36 + steps x . . . x . . . x . . . x . . . diff --git a/conformance/012-gba-subset.expected.json b/conformance/012-gba-subset.expected.json new file mode 100644 index 0000000..374b9aa --- /dev/null +++ b/conformance/012-gba-subset.expected.json @@ -0,0 +1,363 @@ +{ + "program": { + "tplVersion": 1, + "bpm": 148, + "tracks": [ + { + "name": "Lead", + "id": "lead", + "generatorId": "gameBoyDmg", + "rawGenId": "gameBoyDmg", + "genParams": {}, + "loopBars": 4, + "body": [ + { + "lineNo": 6, + "tokens": [ + "gen", + "type", + "pulse", + "duty", + "25", + "env_mode", + "constant", + "vol", + "13" + ], + "raw": "gen type pulse duty 25 env_mode constant vol 13" + }, + { + "lineNo": 7, + "tokens": [ + "gen", + "vib_rate", + "4", + "vib_amt", + "6" + ], + "raw": "gen vib_rate 4 vib_amt 6" + }, + { + "lineNo": 8, + "tokens": [ + "note", + "60", + "0", + "0.25", + "v", + "120" + ], + "raw": "note 60 0 0.25 v 120" + }, + { + "lineNo": 9, + "tokens": [ + "note", + "64", + "0.5", + "0.25", + "v", + "110" + ], + "raw": "note 64 0.5 0.25 v 110" + }, + { + "lineNo": 10, + "tokens": [ + "note", + "67", + "1", + "0.5", + "v", + "100" + ], + "raw": "note 67 1 0.5 v 100" + } + ], + "genBlocks": [] + }, + { + "name": "Bass", + "id": "bass", + "generatorId": "gameBoyDmg", + "rawGenId": "gameBoyDmg", + "genParams": {}, + "loopBars": null, + "body": [ + { + "lineNo": 13, + "tokens": [ + "gen", + "type", + "wave", + "wave_shape", + "saw", + "vol", + "15" + ], + "raw": "gen type wave wave_shape saw vol 15" + }, + { + "lineNo": 14, + "tokens": [ + "note", + "36", + "0", + "2", + "v", + "110" + ], + "raw": "note 36 0 2 v 110" + } + ], + "genBlocks": [] + }, + { + "name": "Kick", + "id": "kick", + "generatorId": "gbaDirectSound", + "rawGenId": "gbaDirectSound", + "genParams": {}, + "loopBars": 4, + "body": [ + { + "lineNo": 17, + "tokens": [ + "gen", + "waveform", + "pulse", + "duty", + "50", + "vol", + "15", + "bitcrush", + "true", + "pitch_drop", + "-14", + "pitch_dec", + "0.05" + ], + "raw": "gen waveform pulse duty 50 vol 15 bitcrush true pitch_drop -14 pitch_dec 0.05" + }, + { + "lineNo": 18, + "tokens": [ + "adsr", + "a", + "0", + "d", + "0.1", + "s", + "0", + "r", + "0.03" + ], + "raw": "adsr a 0 d 0.1 s 0 r 0.03" + }, + { + "lineNo": 19, + "tokens": [ + "step_pitch", + "36" + ], + "raw": "step_pitch 36" + }, + { + "lineNo": 20, + "tokens": [ + "steps", + "x", + ".", + ".", + ".", + "x", + ".", + ".", + ".", + "x", + ".", + ".", + ".", + "x", + ".", + ".", + "." + ], + "raw": "steps x . . . x . . . x . . . x . . ." + } + ], + "genBlocks": [] + } + ], + "removeTrackIds": [], + "macros": {}, + "autos": [], + "masterMixTokens": null, + "actorMixRows": [], + "clipBlocks": [], + "sessionSceneCount": null, + "sessionSlots": [], + "song": null, + "follow": null, + "launchQuant": null, + "songSeed": null, + "xfade": null, + "mainDeck": null, + "swing": null, + "scaleRoot": null, + "scaleMode": null, + "deckMix": null, + "directives": [], + "hostStatements": {}, + "errors": [] + }, + "trackBodies": [ + { + "id": "lead", + "rows": [ + { + "kind": "gen", + "params": { + "type": "pulse", + "duty": 25, + "envMode": "constant", + "vol": 13 + }, + "lineNo": 6 + }, + { + "kind": "gen", + "params": { + "vibRate": 4, + "vibAmt": 6 + }, + "lineNo": 7 + }, + { + "kind": "note", + "midi": 60, + "startBeat": 0, + "durBeats": 0.25, + "vel": 120, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 8 + }, + { + "kind": "note", + "midi": 64, + "startBeat": 0.5, + "durBeats": 0.25, + "vel": 110, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 9 + }, + { + "kind": "note", + "midi": 67, + "startBeat": 1, + "durBeats": 0.5, + "vel": 100, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 10 + } + ], + "errors": [] + }, + { + "id": "bass", + "rows": [ + { + "kind": "gen", + "params": { + "type": "wave", + "waveShape": "saw", + "vol": 15 + }, + "lineNo": 13 + }, + { + "kind": "note", + "midi": 36, + "startBeat": 0, + "durBeats": 2, + "vel": 110, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 14 + } + ], + "errors": [] + }, + { + "id": "kick", + "rows": [ + { + "kind": "gen", + "params": { + "waveform": "pulse", + "duty": 50, + "vol": 15, + "bitcrush": "true", + "pitchDrop": -14, + "pitchDec": 0.05 + }, + "lineNo": 17 + }, + { + "kind": "adsr", + "a": 0, + "d": 0.1, + "s": 0, + "r": 0.03, + "lineNo": 18 + }, + { + "kind": "stepPitch", + "midi": 36, + "bar": null, + "lineNo": 19 + }, + { + "kind": "steps", + "mode": "literal", + "on": [ + true, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + false + ], + "lineNo": 20 + } + ], + "errors": [] + } + ], + "clipBodies": [] +} From e5717a4701a32a3177b690ee95abb99a5298c930 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Mon, 3 Aug 2026 18:05:08 -0700 Subject: [PATCH 3/3] fix(conformance): actually apply the gba profile correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3b87e8f described this change but did not contain it: a `git checkout` cleaning up after a negative test reverted profiles.json between the edit and the commit, so the message claimed a fix the content did not have. CI caught it — 012-gba-subset was unaccounted for in any profile. The correction itself is unchanged: 001, 002 and 004 move to mayReject because each uses a construct the GBA bake rejects by design (`scale`, `remove_track`, `transpose`), and mustAccept becomes 012-gba-subset so it asserts something real rather than being empty. --- conformance/profiles.json | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/conformance/profiles.json b/conformance/profiles.json index 9a356fc..3b636a0 100644 --- a/conformance/profiles.json +++ b/conformance/profiles.json @@ -16,16 +16,23 @@ "008-macros-genblock": "macros and gen_block are not baked", "009-directives": "@ directives are live-performance stream lines", "010-errors": "error-message text is implementation-specific", - "011-golden": "uses generators and sections outside the GBA subset" + "011-golden": "uses generators and sections outside the GBA subset", + "001-comments-and-sharps": "uses `scale`, which has no GBA meaning (the sharp-name parsing it pins is still exercised by 012)", + "002-track-header": "uses `remove_track`, an incremental host edit fragment", + "004-notes": "uses `transpose`, applied host-side" }, "mustAccept": [ - "001-comments-and-sharps", - "002-track-header", - "004-notes" + "012-gba-subset" ], "extensions": { - "topLevel": ["wave"], - "body": ["layer", "intensity", "min_intensity"] + "topLevel": [ + "wave" + ], + "body": [ + "layer", + "intensity", + "min_intensity" + ] } } }