Skip to content
Closed
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: 2 additions & 2 deletions crates/tish-gba-scenepack/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/tish-gba-scenepack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.3"
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
Expand Down
84 changes: 60 additions & 24 deletions crates/tish-gba-scenepack/src/deckpack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,12 +498,8 @@ fn parse_gen_kvs(parts: &[&str], p: &mut VoiceParams) {
fn register_gba_dialect() {
use deckfile::Value;

// `wave <name> <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 <path>` — 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
Expand Down Expand Up @@ -653,30 +649,24 @@ pub fn build(path: &Path) -> Result<proc_macro2::TokenStream, String> {
return Err(format!("{file}: bpm {bpm} out of range 40..300"));
}

// `wave <name> <32 hex nibbles>` — collected by the registered handler above.
// `wave <name> <32 hex digits>` or `wave <name> harmonics <a1> …`. 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<String, [u8; 16]> = 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];
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(name, packed);
named_waves.insert(w.name.clone(), packed);
}

// `sampleset <path>` — the real instruments, if this song plays any.
Expand Down Expand Up @@ -1252,4 +1242,50 @@ 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, 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"
);
// 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]);
}
}
}
29 changes: 25 additions & 4 deletions docs/deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ still works via `chip_borrow`.
```text
deck 1
bpm <40..300>
wave <name> <32 hex nibbles> # optional named PSG wavetable
wave <name> <32 hex digits> # optional named PSG wavetable
wave <name> harmonics <a1> [a2 …] # …or write it as harmonic amplitudes
track <Name> id <id> gen gameBoyDmg|gbaDirectSound [* <bars>] [layer <0..3>]
gen <key> <val> …
layer <0..3> # alias: intensity / min_intensity
Expand All @@ -74,6 +75,26 @@ track <Name> id <id> gen gameBoyDmg|gbaDirectSound [* <bars>] [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:
Expand All @@ -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):**
Expand Down Expand Up @@ -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 <name> <32 hex nibbles>` (top level) |
| Intensifier stem | `layer` / `intensity` / `min_intensity` `<0..3>` (track header or body) |
| Sampled instrument bank | `sampleset <path-to-vgNNN.json>` (top level) |
| Sampled instrument voice | `gen program <n>` on a `gbaDirectSound` track |
Expand Down
Loading