From a75453285c682d986f40fe37e30bcde9731d6ee3 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 27 Aug 2026 16:06:49 -0700 Subject: [PATCH 1/3] feat: read `wave` from the language, and accept the `harmonics` form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wave <32 hex nibbles>` originated here, as a fork of the parser, and survived as a registered dialect extension after the fork was undone. It was never GBA-specific though — deck-player registered its own copy too — so it has been adopted into the language proper (spacedevin/deck#16). That deletes the hex validation and nibble unpacking from this crate. The parser resolves both spellings to 32 levels, and all that is left here is packing two levels per byte for wave RAM. The additive sum lands at bake time, which is the only place it can: the device is no_std on ARM7TDMI with no FPU and no libm, so there is no sin() on the far side. Nothing about the ROM changes — still 16 bytes per table, copied verbatim into WAVE_RAM, so a harmonics table costs exactly what a hex one did. The corpus digest is unchanged across all 54 decks, and a new test pins the part that would be invisible otherwise: a harmonics table and the hex literal it resolves to pack to byte-identical wave RAM. Blocked on the deckfile 1.5.0 release; the pin is bumped ahead of it, so CI will not go green until that publishes. --- crates/tish-gba-scenepack/Cargo.toml | 2 +- crates/tish-gba-scenepack/src/deckpack.rs | 66 +++++++++++++++-------- docs/deck.md | 29 ++++++++-- 3 files changed, 69 insertions(+), 28 deletions(-) diff --git a/crates/tish-gba-scenepack/Cargo.toml b/crates/tish-gba-scenepack/Cargo.toml index 2f96fab6..775f2afa 100644 --- a/crates/tish-gba-scenepack/Cargo.toml +++ b/crates/tish-gba-scenepack/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true # The `.deck` parser, generated from the same @spacedevin/deck source the Deckard host uses — so the # GBA bake and the host cannot disagree about what a file says. This crate keeps only the LOWERING # (channel allocation, wavetables, beats→frames); the grammar lives upstream. -deckfile = "1.3.0" +deckfile = "1.5.0" proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/crates/tish-gba-scenepack/src/deckpack.rs b/crates/tish-gba-scenepack/src/deckpack.rs index de7de670..8c281735 100644 --- a/crates/tish-gba-scenepack/src/deckpack.rs +++ b/crates/tish-gba-scenepack/src/deckpack.rs @@ -498,12 +498,8 @@ fn parse_gen_kvs(parts: &[&str], p: &mut VoiceParams) { fn register_gba_dialect() { use deckfile::Value; - // `wave <32 hex nibbles>` — a named PSG wavetable. Handed back as raw tokens; the hex - // validation stays here so the error text keeps naming the GBA constraint. - deckfile::registerTopLevelStatement( - Value::String("wave".into()), - Value::native(|args: &[Value]| args.get(1).cloned().unwrap_or(Value::Null)), - ); + // `wave` used to be registered here. It is core grammar now — the language resolves both the hex + // and `harmonics` spellings onto `program.waves` — so registering it would be ignored. // `sampleset ` — a voicegroup of real recorded instruments (zones, rates, loop points // and envelopes), as written by a voicegroup-extract tool (M4A-shaped JSON). The path is resolved @@ -653,30 +649,24 @@ pub fn build(path: &Path) -> Result { return Err(format!("{file}: bpm {bpm} out of range 40..300")); } - // `wave <32 hex nibbles>` — collected by the registered handler above. + // `wave <32 hex digits>` or `wave harmonics …`. Both are core grammar now, and + // the parser resolves either spelling to the same 32 levels — including the additive sum, which + // has to happen here at bake time regardless: the device is no_std with no FPU, so there is no + // sin() on the other side. All that is left is packing two levels per byte for wave RAM. let mut named_waves: HashMap = HashMap::new(); - for entry in program.host_statement("wave") { - let name = tok_str(&entry, 1).ok_or_else(|| format!("{file}: wave needs a name"))?; - let hex = tok_str(&entry, 2) - .ok_or_else(|| format!("{file}: wave `{name}` needs 32 hex nibbles"))?; - if hex.len() != 32 { + for w in &program.waves { + if w.levels.len() != 32 { return Err(format!( - "{file}: wave `{name}` needs 32 hex digits, got {}", - hex.len() + "{file}: wave `{}` needs 32 levels, got {}", + w.name, + w.levels.len() )); } - let mut nibs = [0u8; 32]; - for (i, c) in hex.chars().enumerate() { - nibs[i] = c - .to_digit(16) - .ok_or_else(|| format!("{file}: wave `{name}` bad hex `{c}`"))? - as u8; - } let mut packed = [0u8; 16]; for i in 0..16 { - packed[i] = (nibs[i * 2] << 4) | nibs[i * 2 + 1]; + packed[i] = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); } - named_waves.insert(name, packed); + named_waves.insert(w.name.clone(), packed); } // `sampleset ` — the real instruments, if this song plays any. @@ -1252,4 +1242,34 @@ mod tests { "pulse low is not full scale: {signed:?}" ); } +/// The whole point of adopting `wave` into the language: the two spellings are one sound. A + /// `harmonics` line and the hex literal it resolves to must pack to byte-identical wave RAM, or + /// a song would sound different depending only on how its table was written. + /// + /// This is the hardware end of that guarantee — the 16 bytes checked here are copied verbatim + /// into WAVE_RAM by `psg::wave_table`. + #[test] + fn wave_harmonics_and_hex_pack_identically() { + let prog = deckfile::facade::parse( + "deck 1\nwave lit 8beffecbbbbaa9888776554444310014\nwave gen harmonics 1 0.5 0.33 0.2\n", + ); + assert!(prog.errors.is_empty(), "{:?}", prog.errors); + + let pack = |name: &str| -> [u8; 16] { + let w = prog.waves.iter().find(|w| w.name == name).expect("wave present"); + assert_eq!(w.levels.len(), 32); + let mut out = [0u8; 16]; + for i in 0..16 { + out[i] = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); + } + out + }; + + let lit = pack("lit"); + assert_eq!(pack("gen"), lit, "harmonics must bake to the same wave RAM as its hex literal"); + // Every nibble is a 4-bit level, so no byte can carry a value the hardware cannot hold. + for b in lit { + assert!(b >> 4 <= 15 && (b & 0x0f) <= 15); + } + } } diff --git a/docs/deck.md b/docs/deck.md index 39368b7c..a51e663f 100644 --- a/docs/deck.md +++ b/docs/deck.md @@ -61,7 +61,8 @@ still works via `chip_borrow`. ```text deck 1 bpm <40..300> -wave <32 hex nibbles> # optional named PSG wavetable +wave <32 hex digits> # optional named PSG wavetable +wave harmonics [a2 …] # …or write it as harmonic amplitudes track id gen gameBoyDmg|gbaDirectSound [* ] [layer <0..3>] gen … layer <0..3> # alias: intensity / min_intensity @@ -74,6 +75,26 @@ track id gen gameBoyDmg|gbaDirectSound [* ] [layer <0..3>] Beats are quarter notes. `* N` repeats a 1-bar pattern across N bars when notes fit in one bar. +### Wavetables + +A `wave` line names a 32-sample, 4-bit table — the LR35902's wave RAM exactly — and `wave_shape` +selects it on a `type wave` track. The hardware has **one** wave channel, so only one track per song +can play a named table. + +```text +wave organ 8beffecbbbbaa9888776554444310014 +wave organ harmonics 1 0.5 0.33 0.2 +``` + +Those two lines are the same table. The `harmonics` form gives amplitudes — `a1` is the fundamental, +`a2` the octave above it, `a3` the twelfth — and the language sums them into the same 32 levels, +normalized to fill the range, so only the ratios matter. + +That sum happens in the parser, at **bake time**, which is the only place it can: the device is +`no_std` on ARM7TDMI with no FPU and no libm, so there is no `sin()` on the other side. The ROM +still holds 16 bytes per table, copied verbatim into `WAVE_RAM`, so neither form costs anything at +runtime and both are byte-identical in the built ROM. + ## Intensifier stems (crossfading-stem-style) All stems share one playhead. Mark a track with `layer N` (0–3). At runtime: @@ -98,7 +119,7 @@ Stay inside the channel caps (2 pulse / 1 wave / 1 noise / 2 PCM) — layers are ### `gameBoyDmg` params `type` pulse|wave|noise · `duty` 12_5|25|50|75 · `env_mode` step|constant|adsr · `vol` 0–15 · -`noise_mode` long|short · `wave_shape` saw|square|sine · `attack`/`decay`/`sustain`/`release` · +`noise_mode` long|short · `wave_shape` saw|square|sine or a named `wave` · `attack`/`decay`/`sustain`/`release` · `vib_rate`/`vib_amt` · `arp_rate`/`arp_semis` · `pitch_drop`/`pitch_dec` **Hardware surface (LR35902 registers):** @@ -166,11 +187,11 @@ this doc and the bake honest about each other. ### GBA extensions -Two additions, registered as extensions of the shared grammar rather than a fork of it: +Additions registered as extensions of the shared grammar rather than a fork of it. (`wave` used to +be one of these; every host wanted it, so it is core grammar now — see the wavetable note below.) | Extension | Form | |-----------|------| -| Named PSG wavetable | `wave <32 hex nibbles>` (top level) | | Intensifier stem | `layer` / `intensity` / `min_intensity` `<0..3>` (track header or body) | | Sampled instrument bank | `sampleset ` (top level) | | Sampled instrument voice | `gen program ` on a `gbaDirectSound` track | From fa469af25b85cb8cadb39856a8fe6ffa13d39c39 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 27 Aug 2026 16:44:40 -0700 Subject: [PATCH 2/3] chore(deps): pin deckfile 1.5.3 The released crate, in place of the version pinned ahead of it. `wave` is core grammar there, so this is what actually makes the branch build. All 11 tests pass against the published crate, and the corpus digest across all 54 decks is byte-identical to the pre-change baseline. --- crates/tish-gba-scenepack/Cargo.lock | 4 ++-- crates/tish-gba-scenepack/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tish-gba-scenepack/Cargo.lock b/crates/tish-gba-scenepack/Cargo.lock index 00c224eb..1e86a19b 100644 --- a/crates/tish-gba-scenepack/Cargo.lock +++ b/crates/tish-gba-scenepack/Cargo.lock @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "deckfile" -version = "1.4.1" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5751c29745be8df0c0e5980226671d64fcd08746b1a6bae4bd7174956e8cdc2" +checksum = "470e1891c40402c599d660507b15fe59dfa93789ffc7852de996df1912a7967b" dependencies = [ "tishlang_runtime", ] diff --git a/crates/tish-gba-scenepack/Cargo.toml b/crates/tish-gba-scenepack/Cargo.toml index 775f2afa..86db5ba6 100644 --- a/crates/tish-gba-scenepack/Cargo.toml +++ b/crates/tish-gba-scenepack/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true # The `.deck` parser, generated from the same @spacedevin/deck source the Deckard host uses — so the # GBA bake and the host cannot disagree about what a file says. This crate keeps only the LOWERING # (channel allocation, wavetables, beats→frames); the grammar lives upstream. -deckfile = "1.5.0" +deckfile = "1.5.3" proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } From 8201417b9cc5520bb4032e4e50cb03740dea0e91 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 27 Aug 2026 16:50:38 -0700 Subject: [PATCH 3/3] style: satisfy rustfmt and clippy in the wave bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs `cargo fmt --check` on every crate and `cargo clippy --all-targets -D warnings` on this one; the wave changes passed neither. Formatting was mechanical. Clippy found two needless range loops and one real problem: `assert!(b >> 4 <= 15 && (b & 0x0f) <= 15)` is always true, because a nibble of a u8 cannot exceed 15. The assertion proved nothing. Replaced with the invariant actually worth pinning — that packing is lossless and high-nibble-first. Getting that order wrong still yields 16 plausible bytes, so nothing downstream would catch it; the ROM would just play a scrambled waveform. Tests still 11/11 and the corpus digest is unchanged across all 54 decks. --- crates/tish-gba-scenepack/src/deckpack.rs | 36 ++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/crates/tish-gba-scenepack/src/deckpack.rs b/crates/tish-gba-scenepack/src/deckpack.rs index 8c281735..cb224321 100644 --- a/crates/tish-gba-scenepack/src/deckpack.rs +++ b/crates/tish-gba-scenepack/src/deckpack.rs @@ -663,8 +663,8 @@ pub fn build(path: &Path) -> Result { )); } let mut packed = [0u8; 16]; - for i in 0..16 { - packed[i] = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); + for (i, byte) in packed.iter_mut().enumerate() { + *byte = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); } named_waves.insert(w.name.clone(), packed); } @@ -1242,7 +1242,7 @@ mod tests { "pulse low is not full scale: {signed:?}" ); } -/// The whole point of adopting `wave` into the language: the two spellings are one sound. A + /// The whole point of adopting `wave` into the language: the two spellings are one sound. A /// `harmonics` line and the hex literal it resolves to must pack to byte-identical wave RAM, or /// a song would sound different depending only on how its table was written. /// @@ -1256,20 +1256,36 @@ mod tests { assert!(prog.errors.is_empty(), "{:?}", prog.errors); let pack = |name: &str| -> [u8; 16] { - let w = prog.waves.iter().find(|w| w.name == name).expect("wave present"); + let w = prog + .waves + .iter() + .find(|w| w.name == name) + .expect("wave present"); assert_eq!(w.levels.len(), 32); let mut out = [0u8; 16]; - for i in 0..16 { - out[i] = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); + for (i, byte) in out.iter_mut().enumerate() { + *byte = ((w.levels[i * 2] as u8) << 4) | (w.levels[i * 2 + 1] as u8); } out }; let lit = pack("lit"); - assert_eq!(pack("gen"), lit, "harmonics must bake to the same wave RAM as its hex literal"); - // Every nibble is a 4-bit level, so no byte can carry a value the hardware cannot hold. - for b in lit { - assert!(b >> 4 <= 15 && (b & 0x0f) <= 15); + assert_eq!( + pack("gen"), + lit, + "harmonics must bake to the same wave RAM as its hex literal" + ); + // Packing has to be lossless and high-nibble-first. Getting the order wrong still produces + // 16 plausible bytes, so nothing downstream would catch it — the ROM would just play a + // scrambled waveform. + let w = prog.waves.iter().find(|w| w.name == "lit").unwrap(); + assert!( + w.levels.iter().all(|n| (0..=15).contains(n)), + "a level outside 0..=15 would overflow its nibble into the neighbouring sample" + ); + for (i, b) in lit.iter().enumerate() { + assert_eq!(i64::from(b >> 4), w.levels[i * 2]); + assert_eq!(i64::from(b & 0x0f), w.levels[i * 2 + 1]); } } }