mirror of
https://github.com/imjasonh/nescript
synced 2026-07-11 18:20:47 +00:00
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
269 lines
10 KiB
Rust
269 lines
10 KiB
Rust
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use crate::asm;
|
|
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
|
use crate::assets::{MusicData, SfxData};
|
|
use crate::parser::ast::{Mapper, Mirroring};
|
|
use crate::rom::RomBuilder;
|
|
use crate::runtime;
|
|
|
|
/// Link compiled code into a complete NES ROM.
|
|
pub struct Linker {
|
|
mirroring: Mirroring,
|
|
mapper: Mapper,
|
|
}
|
|
|
|
/// CHR data for a sprite, placed at a specific tile index in CHR ROM.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SpriteData {
|
|
pub name: String,
|
|
pub tile_index: u8,
|
|
/// Raw CHR bytes (16 bytes per 8x8 tile).
|
|
pub chr_bytes: Vec<u8>,
|
|
}
|
|
|
|
/// True if `instructions` contains a label definition with the given
|
|
/// name. Labels are emitted as `NOP` pseudo-instructions whose mode
|
|
/// is `AddressingMode::Label(name)`.
|
|
fn has_label(instructions: &[Instruction], name: &str) -> bool {
|
|
instructions
|
|
.iter()
|
|
.any(|i| matches!(&i.mode, AM::Label(n) if n == name))
|
|
}
|
|
|
|
/// A smiley face CHR tile for the default sprite (M1).
|
|
const DEFAULT_SPRITE_CHR: [u8; 16] = [
|
|
// Plane 0 (low bits)
|
|
0b0011_1100,
|
|
0b0100_0010,
|
|
0b1010_0101,
|
|
0b1000_0001,
|
|
0b1010_0101,
|
|
0b1001_1001,
|
|
0b0100_0010,
|
|
0b0011_1100,
|
|
// Plane 1 (high bits) — all zeros means color 1 only
|
|
0b0011_1100,
|
|
0b0111_1110,
|
|
0b1111_1111,
|
|
0b1111_1111,
|
|
0b1111_1111,
|
|
0b1111_1111,
|
|
0b0111_1110,
|
|
0b0011_1100,
|
|
];
|
|
|
|
/// Default palette data for M1 (writes to PPU $3F00).
|
|
const DEFAULT_PALETTE: [u8; 32] = [
|
|
// Background palettes
|
|
0x0F, 0x00, 0x10, 0x20, // palette 0 (black, dark gray, light gray, white)
|
|
0x0F, 0x06, 0x16, 0x26, // palette 1
|
|
0x0F, 0x09, 0x19, 0x29, // palette 2
|
|
0x0F, 0x01, 0x11, 0x21, // palette 3
|
|
// Sprite palettes
|
|
0x0F, 0x00, 0x10, 0x20, // sprite palette 0 (same as bg)
|
|
0x0F, 0x14, 0x24, 0x34, // sprite palette 1
|
|
0x0F, 0x1A, 0x2A, 0x3A, // sprite palette 2
|
|
0x0F, 0x12, 0x22, 0x32, // sprite palette 3
|
|
];
|
|
|
|
impl Linker {
|
|
pub fn new(mirroring: Mirroring) -> Self {
|
|
Self {
|
|
mirroring,
|
|
mapper: Mapper::NROM,
|
|
}
|
|
}
|
|
|
|
pub fn with_mapper(mirroring: Mirroring, mapper: Mapper) -> Self {
|
|
Self { mirroring, mapper }
|
|
}
|
|
|
|
/// Link all code sections into a .nes ROM.
|
|
///
|
|
/// This is a thin wrapper around [`Linker::link_with_assets`] that passes
|
|
/// an empty sprite list, so the CHR ROM only contains the default smiley
|
|
/// tile at index 0.
|
|
pub fn link(&self, user_code: &[Instruction]) -> Vec<u8> {
|
|
self.link_with_assets(user_code, &[])
|
|
}
|
|
|
|
/// Link all code sections into a .nes ROM, placing sprite CHR data at
|
|
/// specific tile indices. No audio data is linked — use
|
|
/// [`Linker::link_with_all_assets`] for audio.
|
|
pub fn link_with_assets(&self, user_code: &[Instruction], sprites: &[SpriteData]) -> Vec<u8> {
|
|
self.link_with_all_assets(user_code, sprites, &[], &[])
|
|
}
|
|
|
|
/// Link all code sections into a .nes ROM, placing both graphic
|
|
/// assets (sprite CHR) and audio assets (sfx envelopes, music
|
|
/// note streams) into the appropriate ROM regions.
|
|
///
|
|
/// Audio data is spliced into PRG ROM under labels derived from
|
|
/// each blob's name (see `SfxData::label` / `MusicData::label`).
|
|
/// The linker only emits these blobs and the audio-driver body
|
|
/// when user code contains the `__audio_used` marker label, so
|
|
/// programs that never touch audio pay zero ROM cost.
|
|
pub fn link_with_all_assets(
|
|
&self,
|
|
user_code: &[Instruction],
|
|
sprites: &[SpriteData],
|
|
sfx: &[SfxData],
|
|
music: &[MusicData],
|
|
) -> Vec<u8> {
|
|
// For NROM: everything fits in one 16 KB PRG bank ($C000-$FFFF)
|
|
// Layout:
|
|
// $C000: RESET handler (init + palette load + user code)
|
|
// ... : NMI handler
|
|
// ... : IRQ handler
|
|
// $FFFA: Vector table (NMI, RESET, IRQ)
|
|
|
|
let mut all_instructions = Vec::new();
|
|
|
|
// RESET entry point
|
|
all_instructions.push(Instruction::new(NOP, AM::Label("__reset".into())));
|
|
|
|
// Hardware initialization
|
|
all_instructions.extend(runtime::gen_init());
|
|
|
|
// Load default palette
|
|
all_instructions.extend(self.gen_palette_load());
|
|
|
|
// User code (var init + main loop)
|
|
all_instructions.extend(user_code.iter().cloned());
|
|
|
|
// Math runtime routines (included always for simplicity)
|
|
all_instructions.extend(runtime::gen_multiply());
|
|
all_instructions.extend(runtime::gen_divide());
|
|
|
|
// Audio subsystem — linked in whenever user code touched
|
|
// audio (detected via the `__audio_used` marker emitted by
|
|
// the IR codegen). The driver body, period table, and
|
|
// user/builtin data blobs are all spliced into PRG here.
|
|
//
|
|
// Order is important: the audio tick references both the
|
|
// period table and the data blobs by label, so those labels
|
|
// must be defined in the same assembly pass. The tick body
|
|
// also has to exist before `__nmi` because NMI JSRs into
|
|
// `__audio_tick` — so we emit it alongside the math
|
|
// routines, well before the NMI handler below.
|
|
let has_audio = has_label(user_code, "__audio_used");
|
|
if has_audio {
|
|
all_instructions.extend(runtime::gen_audio_tick());
|
|
all_instructions.extend(runtime::gen_period_table());
|
|
// Emit one data block per sfx blob: a label followed by
|
|
// the envelope bytes. `play Name` codegen emits a
|
|
// SymbolLo/SymbolHi pair that resolves to this label.
|
|
for blob in sfx {
|
|
all_instructions.extend(runtime::gen_data_block(
|
|
&blob.label(),
|
|
blob.envelope.clone(),
|
|
));
|
|
}
|
|
// Same for music: label + note stream.
|
|
for blob in music {
|
|
all_instructions
|
|
.extend(runtime::gen_data_block(&blob.label(), blob.stream.clone()));
|
|
}
|
|
}
|
|
|
|
// NMI handler
|
|
all_instructions.push(Instruction::new(NOP, AM::Label("__nmi".into())));
|
|
// If user code emits an MMC3 reload hook, splice in a JSR
|
|
// before the regular NMI runs. This reloads the scanline IRQ
|
|
// counter each frame so the handler fires at the right line.
|
|
// The presence of the `__ir_mmc3_reload` label is detected
|
|
// during assembly via the labels map; we unconditionally
|
|
// emit a conditional JSR whose target is resolved at link
|
|
// time. The helper emits an RTS so it's safe to call even
|
|
// when there's no work to do.
|
|
if has_label(user_code, "__ir_mmc3_reload") {
|
|
all_instructions.push(Instruction::new(JSR, AM::Label("__ir_mmc3_reload".into())));
|
|
}
|
|
// Audio tick: if audio is in use, JSR into the per-frame
|
|
// driver tick before the normal NMI body. The tick walks
|
|
// both the sfx envelope and the music note stream, writing
|
|
// APU registers as needed. Programs that never use audio
|
|
// skip this splice entirely — no ROM cost.
|
|
if has_audio {
|
|
all_instructions.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
|
|
}
|
|
all_instructions.extend(runtime::gen_nmi());
|
|
|
|
// IRQ handler
|
|
all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into())));
|
|
all_instructions.extend(runtime::gen_irq());
|
|
|
|
// Assemble everything at $C000
|
|
let base_addr = 0xC000;
|
|
let result = asm::assemble(&all_instructions, base_addr);
|
|
|
|
// Build PRG ROM with vector table
|
|
let mut prg = result.bytes;
|
|
|
|
// Pad to fill the bank up to vector table location
|
|
// Vector table is at $FFFA-$FFFF (relative offset: $3FFA in a 16 KB bank)
|
|
let vector_offset = 0x3FFA;
|
|
if prg.len() > vector_offset {
|
|
panic!("PRG code exceeds 16 KB bank (code is {} bytes)", prg.len());
|
|
}
|
|
prg.resize(vector_offset, 0xFF);
|
|
|
|
// Write vector table. IR codegen emits a richer IRQ handler
|
|
// under `__irq_user` when the program has scanline handlers;
|
|
// prefer that over the generic RTI stub at `__irq`.
|
|
let nmi_addr = result.labels.get("__nmi").copied().unwrap_or(0xC000);
|
|
let reset_addr = result.labels.get("__reset").copied().unwrap_or(0xC000);
|
|
let irq_addr = result
|
|
.labels
|
|
.get("__irq_user")
|
|
.or_else(|| result.labels.get("__irq"))
|
|
.copied()
|
|
.unwrap_or(0xC000);
|
|
|
|
prg.extend_from_slice(&nmi_addr.to_le_bytes());
|
|
prg.extend_from_slice(&reset_addr.to_le_bytes());
|
|
prg.extend_from_slice(&irq_addr.to_le_bytes());
|
|
|
|
// Build ROM
|
|
let mut builder = RomBuilder::new(self.mirroring);
|
|
builder.set_mapper(crate::rom::mapper_number(self.mapper));
|
|
builder.set_prg(prg);
|
|
|
|
// CHR ROM: tile 0 is reserved for the default smiley, followed by
|
|
// any user-declared sprites placed at their assigned tile indices.
|
|
let mut chr = vec![0u8; 8192];
|
|
chr[..16].copy_from_slice(&DEFAULT_SPRITE_CHR);
|
|
for sprite in sprites {
|
|
let offset = sprite.tile_index as usize * 16;
|
|
let end = offset + sprite.chr_bytes.len();
|
|
if end <= chr.len() {
|
|
chr[offset..end].copy_from_slice(&sprite.chr_bytes);
|
|
}
|
|
}
|
|
builder.set_chr(chr);
|
|
|
|
builder.build()
|
|
}
|
|
|
|
/// Generate instructions to load the default palette into the PPU.
|
|
fn gen_palette_load(&self) -> Vec<Instruction> {
|
|
let mut out = Vec::new();
|
|
|
|
// Set PPU address to $3F00 (palette start)
|
|
out.push(Instruction::new(LDA, AM::Absolute(0x2002))); // read PPU status to reset latch
|
|
out.push(Instruction::new(LDA, AM::Immediate(0x3F)));
|
|
out.push(Instruction::new(STA, AM::Absolute(0x2006))); // PPU addr high byte
|
|
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
|
out.push(Instruction::new(STA, AM::Absolute(0x2006))); // PPU addr low byte
|
|
|
|
// Write all 32 palette bytes
|
|
for &color in &DEFAULT_PALETTE {
|
|
out.push(Instruction::new(LDA, AM::Immediate(color)));
|
|
out.push(Instruction::new(STA, AM::Absolute(0x2007))); // PPU data
|
|
}
|
|
|
|
out
|
|
}
|
|
}
|