diff --git a/README.md b/README.md index 13610c9..cd98ea4 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ start Main - **IR-based optimizer** -- constant folding, dead code elimination, strength reduction, copy propagation, peephole passes including INC/DEC fold and live-range slot recycling - **Full 16-bit arithmetic** -- u16 add/sub/compare lower to carry-propagating paired operations - **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state) -- **Minimal audio driver** -- `play`/`start_music`/`stop_music` drive a builtin APU engine with no per-program cost when unused -- **Asset pipeline** -- PNG-to-CHR conversion, palette definitions, inline tile data +- **Audio subsystem** -- frame-walking pulse driver with user-declared `sfx`/`music` blocks, builtin effects and tracks, period table, and zero-cost elision when unused +- **Asset pipeline** -- PNG-to-CHR conversion, palette definitions, inline tile data, sfx envelopes, music note streams - **Inline assembly** -- `asm { ... }` with `{var}` substitution, plus `raw asm { ... }` for verbatim blocks - **Hardware intrinsics** -- `poke(addr, value)` / `peek(addr)` for direct register access - **Debug support** -- `--debug` flag, source maps, Mesen-compatible symbol export, `debug.log` / `debug.assert` @@ -78,7 +78,7 @@ start Main | [`mmc1_banked.ne`](examples/mmc1_banked.ne) | MMC1 mapper, bank declarations, multiply | | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | | [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` | -| [`audio_demo.ne`](examples/audio_demo.ne) | Minimal audio driver: `play`, `start_music`, `stop_music` | +| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` | ## Compiler Commands diff --git a/docs/architecture.md b/docs/architecture.md index fe51a66..d70c84d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,10 +65,10 @@ Assigns addresses to code and data segments, resolves fixups/relocations (`fixup Builds the final iNES ROM file. Generates the 16-byte iNES header (`header.rs`) and places the NMI/RESET/IRQ vector table (`vectors.rs`). ### `runtime/` -Contains built-in runtime code that the compiler emits into every ROM: NES hardware initialization (`init.rs`), NMI handler generation (`nmi.rs`), controller read routines (`input.rs`), OAM DMA setup (`oam.rs`), PPU helper routines (`ppu.rs`), and software multiply/divide (`math.rs`). +Contains built-in runtime code that the compiler emits into every ROM: NES hardware initialization, NMI handler generation, controller read routines, OAM DMA setup, software multiply/divide, and the frame-walking audio driver (`gen_audio_tick`, `gen_period_table`, `gen_data_block`). ### `assets/` -The asset pipeline. Converts PNG images to CHR tile data (`chr.rs`), generates nametables from full-screen images (`nametable.rs`), extracts and maps palettes (`palette.rs`), and handles audio import stubs (`audio.rs`). +The asset pipeline. Converts PNG images to CHR tile data (`chr.rs`), maps RGB colors to the NES palette (`palette.rs`), resolves `sprite`/`background` declarations into tile-indexed CHR blocks (`resolve.rs`), and compiles `sfx`/`music` declarations into ROM-ready envelope and note-stream byte tables (`audio.rs`) — plus the builtin effect/track tables used as fallbacks when programs reference audio names without declaring them. ### `debug/` Debug instrumentation output. Generates source maps relating ROM addresses to source locations (`source_map.rs`), symbol tables compatible with Mesen (`symbols.rs`), and runtime check code for debug builds (`checks.rs`). diff --git a/docs/future-work.md b/docs/future-work.md index e3bfdc2..ef39620 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -163,18 +163,26 @@ These are defined in `ErrorCode` but never emitted: ## 12. Audio -**Status**: A minimal APU driver is now in place. `play SfxName` emits -pulse-1 register writes from a builtin SFX table, `start_music` holds a -tone on pulse 2, `stop_music` mutes pulse 2. The NMI handler gains a -`JSR __audio_tick` splice whenever user code triggered audio so the SFX -countdown counter can mute the tone when its frames are up. Unused -programs pay zero ROM cost. +**Status**: A full data-driven audio subsystem is in place. User programs +can declare `sfx Name { duty, pitch, volume }` blocks (frame-accurate +pulse-1 envelopes) and `music Name { duty, volume, repeat, notes }` +blocks (pulse-2 note streams with rests and looping). The resolver +compiles these into ROM-ready byte tables; the IR codegen emits +trigger sequences that load pointers into ZP; the runtime NMI tick +walks the envelope and note stream each frame, indexing into a +builtin 60-note period table. Builtin effects (`coin`, `jump`, `hit`, +`click`, `cancel`, `shoot`, `step`) and tracks (`theme`, `battle`, +`victory`, `gameover`) are synthesized from the same data path so +programs that don't declare their own audio still make sound. +Programs that touch no audio pay zero ROM or cycle cost — the whole +subsystem elides when the `__audio_used` marker is absent. -**Still TODO for a full audio solution**: +**Still TODO for richer audio**: +- Triangle/noise/DMC channels (currently only pulse 1 and 2 are used) +- Multi-channel tracker playback (one `notes` list per channel) - `@sfx("file.nsf")` / `@music("file.ftm")` asset directives - FamiTracker export format parsing -- Multi-channel music driver with note tables and envelopes -- Per-SFX duration override in the declaration rather than the builtin table +- Per-note pitch changes within a sfx (currently pitch is latched once) --- @@ -285,14 +293,16 @@ spills to zero-page $02 for comparisons. A proper allocator would: These items were documented as future work but have since been implemented: -- **Minimal audio driver** — `src/runtime/mod.rs::gen_audio_tick()` - plus codegen for `IrOp::PlaySfx` / `StartMusic` / `StopMusic`. SFX - and music map through a builtin name table (`coin`, `jump`, `hit`, - `theme`, etc.) to APU pulse 1/2 register writes. The NMI handler - gains a `JSR __audio_tick` splice (via the linker's `__audio_used` - marker lookup) that decrements a per-frame counter and mutes the - channel when the tone's time is up. Programs without audio pay - zero ROM cost. +- **Full audio subsystem** — `src/runtime/mod.rs::gen_audio_tick`, + `gen_period_table`, and `gen_data_block` implement a frame-walking + pulse driver. `src/assets/audio.rs` compiles user `sfx`/`music` + declarations (and builtins referenced via `play coin` etc.) into + ROM-ready envelope and note-stream byte tables. `IrCodeGen::with_audio` + threads the compile-time trigger constants into `play`/`start_music`, + which emit pointer loads against per-blob labels. The linker splices + driver body, period table, and every blob into PRG gated on the + `__audio_used` marker so silent programs pay no cost. Full + parser/analyzer/codegen/linker/runtime test coverage. - **u16 arithmetic and comparisons** — new IR ops `LoadVarHi`, `StoreVarHi`, `Add16`, `Sub16`, `CmpEq16` through `CmpGtEq16`. The lowering context tracks variable types via the analyzer's symbol @@ -422,9 +432,10 @@ These items were documented as future work but have since been implemented: - **`for i in start..end { ... }` loops** — half-open range with a u8 index variable. Desugared in IR lowering to a while loop with a proper continue-edge block so `break`/`continue` work. -- **Audio statement parsing** — `play SfxName`, `start_music`, - `stop_music` parse and analyze as no-ops (no driver yet; users - can wire audio via inline `asm` blocks). +- **Audio subsystem** — full data-driven pulse driver with + `sfx`/`music` block declarations, builtin effects, and an + NMI-time tick that walks envelope and note-stream tables. + See section 12 above for the full writeup. - **Constant expression folding** — `const B: u8 = A + 3` evaluates at compile time and feeds through to variable initializers too. - **`on scanline` codegen (minimal)** — MMC3 IRQ setup at startup @@ -490,10 +501,12 @@ For someone picking up this codebase, the recommended order of work: end-to-end (load/store, +/-, comparisons all propagate through 16-bit IR ops). Struct fields and array elements are still u8-only — the layout machinery needs to grow multi-byte field offsets. -2. **Full multi-channel music driver** — the current audio engine - holds a single tone on each of pulse 1 (SFX) and pulse 2 (music). - A proper NSF-style driver with note tables, envelopes, and a - multi-track stream would unblock real game music. +2. **Triangle / noise / DMC channels** — the current audio engine + plays sfx on pulse 1 and music on pulse 2 with a full + data-driven tracker model (envelope walk, period table, + `(pitch, duration)` note streams, loop-back). Wiring triangle + and noise channels into the same model would unblock richer + multi-part compositions. 3. **Register allocator** — proper A/X/Y allocation to replace zero-page spills used by the current IR codegen. Partially mitigated by peephole passes + the new slot recycler, but still diff --git a/docs/language-guide.md b/docs/language-guide.md index de29130..8dcb648 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -715,6 +715,107 @@ Three ways to provide asset data: --- +## Audio + +NEScript ships with a full data-driven audio subsystem. Sound effects run on pulse channel 1 and music runs on pulse channel 2, both driven by an NMI-time tick that walks per-track data tables compiled into PRG ROM. Programs that never touch audio pay zero ROM or cycle cost — the driver and its period table are only linked in when user code contains at least one `play`, `start_music`, or `stop_music` statement. + +### Statements + +``` +play SfxName // trigger a one-shot sound effect +start_music TrackName // begin looping background music +stop_music // silence the music channel +``` + +Each statement looks up the name in the program's user declarations first, then falls back to the builtin table. Unknown names are a hard error (E0505). + +### SFX Declarations + +An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` latches the pulse period on trigger; `volume` runs one entry per frame, so the envelope length controls the effect duration. + +``` +sfx Pickup { + duty: 2 // 0-3, 2 = 50% square (default) + pitch: [0x50, 0x50, 0x50, 0x50, 0x50] // period for each frame + volume: [15, 12, 9, 6, 3] // 0-15, one per frame +} +``` + +Rules: +- `pitch` and `volume` must have the same length (the frame count). +- `volume` values are 0-15 (4-bit pulse volume). +- `duty` is 0-3 and defaults to 2. +- Maximum 120 frames (2 seconds at 60 fps). + +### Music Declarations + +A `music` block is a flat list of `(pitch, duration)` note pairs played on pulse 2. Pitch 0 is a rest; pitches 1-60 are indices into the builtin 60-note period table (C1 through B5, with middle C at index 37). Duration is in frames (so at 60 fps, `30` is half a second). + +``` +music Theme { + duty: 2 // 0-3 (default 2) + volume: 10 // 0-15 (default 10) + repeat: true // loop when track ends (default true) + notes: [ + 37, 20, // C4 for 20 frames + 41, 20, // E4 + 44, 20, // G4 + 49, 20, // C5 + 0, 10, // rest for 10 frames + ] +} +``` + +Rules: +- `notes` must contain an even number of bytes (pitch + duration pairs). +- Pitches are 0 (rest) or 1-60 (period table index). +- Duration must be ≥ 1 frame. +- Maximum 256 notes per track. + +### Builtin Names + +For programs that want classic game audio without writing data tables, NEScript provides a handful of builtin effects and tracks that can be used directly: + +**Builtin SFX** + +| Name | Description | +|------|-------------| +| `coin`, `pickup`, `collect` | Ascending high blip | +| `jump`, `hop` | Descending arc | +| `hit`, `damage`, `explode` | Low blast | +| `click`, `select`, `confirm` | Sharp beep | +| `cancel`, `back`, `error` | Low longer tone | +| `shoot`, `laser`, `fire` | Very high pulse | +| `step`, `footstep` | Short low thud | + +**Builtin Music** + +| Name | Description | +|------|-------------| +| `title`, `theme`, `main` | Major arpeggio (looping) | +| `battle`, `boss` | Driving pulse (looping) | +| `win`, `victory`, `fanfare` | Ascending burst (one-shot) | +| `gameover`, `lose`, `fail` | Descending dirge (looping) | + +A user-declared `sfx` or `music` block takes priority over a builtin with the same name, so `sfx coin { ... }` will shadow the default coin effect. + +### How It Works + +Compile time: + +1. The resolver compiles each `sfx` into `(period_lo, period_hi, envelope[])` and each `music` into `(header, (pitch, duration)[])`, appending builtins for any referenced name that isn't user-declared. +2. The IR codegen emits `play Name` as: write trigger bytes to `$4002`/`$4003`, load envelope pointer into `$0C/$0D`, set the sfx counter. `start_music Name` stamps a state byte into `$07`, loads the stream pointer into `$0E/$0F` (and the loop base into `$05/$06`), and primes the duration counter. +3. The linker splices the audio tick, the 60-entry period table, and every compiled sfx/music blob into PRG ROM, all guarded on a `__audio_used` marker label so silent programs never pay the cost. + +Runtime (every NMI, if audio is in use): + +1. **SFX**: if the counter is nonzero, read one envelope byte through `(ZP_SFX_PTR),Y` and write it to `$4000`. A zero sentinel mutes pulse 1 and stops the tick. +2. **Music**: if active and the note counter hits zero, read the next pitch byte. 0 = rest (mute pulse 2). 1-60 = look up the period in the table and write to `$4006`/`$4007`. `0xFF` = loop back to the base pointer (or mute if `repeat: false`). Then read the duration byte and reload the counter. + +Total memory cost: 8 bytes of zero page, ~200 bytes for the driver body, 120 bytes for the period table, plus the data for each user-declared sfx/music. + +--- + ## Mappers The mapper determines cartridge hardware and available ROM size. diff --git a/examples/audio_demo.ne b/examples/audio_demo.ne index 3744290..b1a6d72 100644 --- a/examples/audio_demo.ne +++ b/examples/audio_demo.ne @@ -1,18 +1,29 @@ -// Audio Demo — shows off the minimal APU driver. +// Audio Demo — showcases the full audio subsystem. // -// Press a button to trigger each sound effect. Start begins the -// background tone; Select stops it. The driver uses pulse 1 for -// SFX and pulse 2 for music, both running out of the built-in -// NMI audio tick. +// NEScript has three audio statements: +// play Name — trigger an SFX on pulse 1 (one-shot) +// start_music Name — play a background music track on pulse 2 +// stop_music — silence the music channel // -// The SFX name is looked up in a small builtin table of tones: -// coin — high short blip (also: pickup, collect) -// jump — mid short tone (also: hop) -// hit — low short blast (also: damage, explode) -// click — short beep (also: select, confirm) -// cancel — low longer tone (also: back, error) -// shoot — very high short pulse (also: laser, fire) -// Unknown names play a generic mid-frequency beep. +// SFX and music tracks are compiled into PRG ROM data tables and +// walked one byte per NMI by the builtin audio driver. You can +// either use the builtin names (coin, jump, theme, ...) or declare +// your own via `sfx Name { ... }` / `music Name { ... }` blocks. +// +// Builtin SFX names: +// coin, pickup, collect — high ascending blip +// jump, hop — descending arc +// hit, damage, explode — low blast +// click, select, confirm — sharp beep +// cancel, back, error — low longer tone +// shoot, laser, fire — very high pulse +// step, footstep — low thud +// +// Builtin music names: +// title, theme, main — major arpeggio (looping) +// battle, boss — driving pulse (looping) +// win, victory, fanfare — ascending burst (one-shot) +// gameover, lose, fail — descending dirge (looping) // // Build: cargo run -- build examples/audio_demo.ne // Output: examples/audio_demo.nes @@ -21,6 +32,51 @@ game "Audio Demo" { mapper: NROM } +// ── User-declared sound effects ── +// +// An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` +// latches the pulse period once on trigger; `volume` runs one entry +// per frame, so the envelope length controls the effect duration. +// `duty` (0-3) picks the pulse waveform shape (2 = 50% square). + +// A gentle rising chirp, longer than the builtin coin. +sfx LongCoin { + duty: 2 + pitch: [0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50] + volume: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1] +} + +// A sharp two-part zap — quick high spike into silence. +sfx Zap { + duty: 3 + pitch: [0x20, 0x20, 0x20, 0x20, 0x20] + volume: [15, 12, 8, 4, 1] +} + +// ── User-declared music tracks ── +// +// A `music` block is a flat list of `(pitch, duration)` note pairs. +// Pitch 0 = rest; 1-60 = period table index (C1..B5, middle C = 37). +// Duration is in frames (so at 60 fps, 30 = half second per note). +// Music loops by default; set `repeat: false` for one-shot cues. + +// A cheerful four-note looping theme. +music Theme { + duty: 2 + volume: 10 + repeat: true + notes: [ + 37, 20, // C4 + 41, 20, // E4 + 44, 20, // G4 + 49, 20, // C5 + 44, 20, // G4 + 41, 20, // E4 + ] +} + +// ── Game state ── + var px: u8 = 128 var py: u8 = 120 var timer: u8 = 0 @@ -33,33 +89,29 @@ on frame { if button.down { py += 1 } if button.up { py -= 1 } - // Trigger SFX on d-pad-press equivalents. Using `_pressed` - // would be nicer but the builtin table keys off the name, so - // holding a button just retriggers the same tone every frame, - // which is audible and good for a demo. - if button.a { play coin } - if button.b { play jump } + // Input-triggered SFX. + if button.a { play LongCoin } + if button.b { play Zap } - // Start/stop the background music loop. - if button.start { start_music theme } + // Start/stop the user-declared theme. + if button.start { start_music Theme } if button.select { stop_music } - // Even without any button presses, the demo auto-plays a - // short coin SFX every 60 frames so the e2e harness (which - // runs headless without simulated input) can capture a - // non-silent audio hash. This exercises the full play-path - // through the APU driver under CI. + // Auto-play a builtin coin SFX every 60 frames so the e2e + // harness (which runs headless without simulated input) can + // capture a non-silent audio hash. Also toggles the music + // every 120 frames to exercise start/stop paths. timer += 1 if timer == 30 { play coin } - if timer == 60 { + if timer == 120 { timer = 0 if music_on { stop_music music_on = false } else { - start_music theme + start_music Theme music_on = true } } diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 08f2289..d998f21 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -47,10 +47,22 @@ const RAM_END: u16 = 0x0800; /// Analyze a parsed program for semantic errors. pub fn analyze(program: &Program) -> AnalysisResult { + // Pre-collect declared and builtin-matchable sfx/music names so + // the statement walker can check `play` / `start_music` targets. + let mut sfx_names = HashSet::new(); + for decl in &program.sfx { + sfx_names.insert(decl.name.clone()); + } + let mut music_names = HashSet::new(); + for decl in &program.music { + music_names.insert(decl.name.clone()); + } let mut analyzer = Analyzer { symbols: HashMap::new(), var_allocations: Vec::new(), diagnostics: Vec::new(), + sfx_names, + music_names, next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer next_zp_addr: 0x10, // $10 is first usable zero-page after reserved area call_graph: HashMap::new(), @@ -78,6 +90,13 @@ struct Analyzer { symbols: HashMap, var_allocations: Vec, diagnostics: Vec, + /// Set of sfx names declared in the program (user-provided + /// `sfx Name { ... }` blocks). Used to validate `play Name` + /// targets. If a name isn't in here and isn't a known builtin, + /// the analyzer emits E0505. + sfx_names: HashSet, + /// Set of music names declared in the program. + music_names: HashSet, next_ram_addr: u16, next_zp_addr: u8, call_graph: HashMap>, @@ -1058,10 +1077,39 @@ impl Analyzer { // codegen parses and validates the body; analysis has // nothing to check. } - Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => { - // Audio statements are parsed and recognized but - // currently generate no code — no audio driver exists. - // Users who need audio can use inline `asm` blocks. + Statement::Play(name, span) => { + // `play Name` is valid if the name refers to a + // declared sfx block or to a builtin effect. Anything + // else is an E0505 — the runtime driver needs a + // known blob to point at, and silently accepting + // bad names would hide typos. + if !self.sfx_names.contains(name) && !crate::assets::is_builtin_sfx(name) { + self.diagnostics.push( + Diagnostic::error(ErrorCode::E0505, format!("unknown sfx '{name}'"), *span) + .with_help( + "declare one with `sfx Name { pitch: [..], volume: [..] }`, \ + or use a builtin: coin, jump, hit, click, cancel, shoot, step", + ), + ); + } + } + Statement::StartMusic(name, span) => { + if !self.music_names.contains(name) && !crate::assets::is_builtin_music(name) { + self.diagnostics.push( + Diagnostic::error( + ErrorCode::E0505, + format!("unknown music track '{name}'"), + *span, + ) + .with_help( + "declare one with `music Name { notes: [..] }`, \ + or use a builtin: theme, battle, victory, gameover", + ), + ); + } + } + Statement::StopMusic(_) => { + // No arguments, nothing to validate. } } } diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index a8c1134..05ce318 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -868,3 +868,103 @@ fn analyze_undefined_variable_emits_e0502() { diag.help ); } + +// ── Audio name validation ── + +#[test] +fn analyze_accepts_builtin_sfx() { + // `play coin` is always valid because `coin` is a builtin + // even without a user `sfx Coin { ... }` declaration. + analyze_ok( + r#" + game "T" { mapper: NROM } + on frame { play coin } + start Main + "#, + ); +} + +#[test] +fn analyze_accepts_user_declared_sfx() { + analyze_ok( + r#" + game "T" { mapper: NROM } + sfx Chime { + pitch: [0x20, 0x22, 0x24, 0x26] + volume: [15, 12, 8, 4] + } + on frame { play Chime } + start Main + "#, + ); +} + +#[test] +fn analyze_rejects_unknown_sfx_name() { + // `play Nonexistent` with no matching user decl or builtin + // should emit E0505. + let codes = analyze_errors( + r#" + game "T" { mapper: NROM } + on frame { play Nonexistent } + start Main + "#, + ); + assert!( + codes.contains(&ErrorCode::E0505), + "expected E0505 for unknown sfx, got {codes:?}" + ); +} + +#[test] +fn analyze_accepts_builtin_music() { + analyze_ok( + r#" + game "T" { mapper: NROM } + on frame { start_music theme } + start Main + "#, + ); +} + +#[test] +fn analyze_accepts_user_declared_music() { + analyze_ok( + r#" + game "T" { mapper: NROM } + music Boss { + notes: [37, 8, 41, 8, 44, 8, 49, 8] + } + on frame { start_music Boss } + start Main + "#, + ); +} + +#[test] +fn analyze_rejects_unknown_music_name() { + let codes = analyze_errors( + r#" + game "T" { mapper: NROM } + on frame { start_music Nonexistent } + start Main + "#, + ); + assert!( + codes.contains(&ErrorCode::E0505), + "expected E0505 for unknown music, got {codes:?}" + ); +} + +#[test] +fn analyze_stop_music_needs_no_name_and_is_always_valid() { + // `stop_music` takes no argument, so there's nothing to + // validate — it should always analyze cleanly. + analyze_ok( + r#" + game "T" { mapper: NROM } + on frame { stop_music } + start Main + "#, + ); +} diff --git a/src/asm/mod.rs b/src/asm/mod.rs index 57ae955..0b1d90a 100644 --- a/src/asm/mod.rs +++ b/src/asm/mod.rs @@ -79,6 +79,15 @@ impl Assembler { fn emit_instruction(&mut self, inst: &Instruction) { match &inst.mode { + AddressingMode::Bytes(payload) if inst.opcode == Opcode::NOP => { + // Raw-data pseudo-instruction: splice the payload + // into the output stream verbatim. No opcode byte + // is emitted — this is how we embed audio tables + // and other data blobs inside a code section while + // still letting the two-pass label resolver see + // adjacent labels at the right addresses. + self.output.extend_from_slice(payload); + } AddressingMode::Label(name) => { // A `NOP` with a `Label` mode is the label-definition // pseudo-instruction: it records the current address and diff --git a/src/asm/opcodes.rs b/src/asm/opcodes.rs index 6470b74..d06ac20 100644 --- a/src/asm/opcodes.rs +++ b/src/asm/opcodes.rs @@ -81,6 +81,13 @@ pub enum AddressingMode { LabelRelative(String), SymbolLo(String), SymbolHi(String), + + /// Raw data payload — emitted verbatim by the assembler as a + /// `NOP` pseudo-instruction. Used to splice data tables (audio + /// envelopes, note streams, period tables) between code + /// sections so they live inside the same PRG bank and get their + /// labels resolved by the normal two-pass assembler. + Bytes(Vec), } impl AddressingMode { @@ -97,6 +104,10 @@ impl AddressingMode { | Self::Relative(_) => 1, Self::Absolute(_) | Self::AbsoluteX(_) | Self::AbsoluteY(_) | Self::Indirect(_) => 2, Self::Label(_) | Self::LabelRelative(_) | Self::SymbolLo(_) | Self::SymbolHi(_) => 0, + // `Bytes` is the full emitted payload — the assembler + // skips the usual opcode byte for `NOP+Bytes` and writes + // the raw vector, so the whole thing is operand. + Self::Bytes(v) => v.len(), } } @@ -117,6 +128,7 @@ impl AddressingMode { Self::Label(_) | Self::LabelRelative(_) | Self::SymbolLo(_) | Self::SymbolHi(_) => { vec![] } + Self::Bytes(v) => v.clone(), } } @@ -151,8 +163,17 @@ impl Instruction { } /// Total size in bytes (opcode + operand). + /// + /// The `NOP`+`Label` pair is a label-definition pseudo — zero + /// bytes. `NOP`+`Bytes(v)` is a raw-data pseudo — exactly `v.len()` + /// bytes (no opcode). All other instructions are 1 byte opcode + /// plus operand. pub fn size(&self) -> usize { - 1 + self.mode.operand_size() + match &self.mode { + AddressingMode::Label(_) if self.opcode == Opcode::NOP => 0, + AddressingMode::Bytes(v) if self.opcode == Opcode::NOP => v.len(), + _ => 1 + self.mode.operand_size(), + } } } diff --git a/src/assets/audio.rs b/src/assets/audio.rs new file mode 100644 index 0000000..6eda238 --- /dev/null +++ b/src/assets/audio.rs @@ -0,0 +1,808 @@ +//! Audio asset resolver. +//! +//! Compiles user-declared `sfx` and `music` blocks into the flat byte +//! tables consumed by the runtime audio driver, and provides builtin +//! fallback effects and tracks for unrecognized names (so legacy +//! programs that `play coin` / `start_music theme` without declaring +//! them still make sound). +//! +//! ## SFX representation +//! +//! Each [`SfxData`] is: +//! +//! - A few *compile-time constants* (`period_lo`, `period_hi`) that +//! the IR codegen emits as immediates in the `play` instruction +//! sequence to trigger a new pulse-1 note. +//! - A raw *envelope byte stream* stored in PRG ROM under the label +//! `__sfx_`, consumed one byte per NMI by the runtime audio +//! tick. Each byte is a complete `$4000` write (duty<<6 | 0x30 | +//! volume). A trailing zero byte is the sentinel: the tick sees +//! it, mutes pulse 1, and stops. +//! +//! ## Music representation +//! +//! Each [`MusicData`] is: +//! +//! - A single compile-time `header` byte cached in ZP by +//! `start_music` and used by the tick to build `$4004` envelope +//! writes on every note change. Bit layout: +//! - bit 0: loop flag +//! - bits 2-5: volume (0-15) +//! - bits 6-7: duty (0-3) +//! - A raw `(pitch, dur)` *note stream* stored in PRG ROM under the +//! label `__music_`, terminated by `(0xFF, 0xFF)`. Pitch 0 is +//! a rest; pitches 1-60 are indices into the period table. + +use crate::parser::ast::{MusicDecl, MusicNote, Program, SfxDecl}; + +/// Compiled sfx data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SfxData { + pub name: String, + /// Pulse-1 period low byte, written to `$4002` by `play` as an + /// immediate. Determines the tone of the effect. + pub period_lo: u8, + /// Pulse-1 length counter + period high byte, written to + /// `$4003`. Triggers a new note on write. + pub period_hi: u8, + /// Per-frame `$4000` envelope bytes terminated by `0x00`. Linked + /// into PRG ROM as a labelled data block. + pub envelope: Vec, +} + +/// Compiled music data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MusicData { + pub name: String, + /// State byte cached by `start_music` into `ZP_MUSIC_STATE`. Bit + /// 1 is OR'd in at runtime to mark the track active. Encodes + /// duty (bits 6-7), volume (bits 2-5), and loop flag (bit 0). + pub header: u8, + /// Raw `(pitch, duration)` note stream terminated by + /// `(0xFF, 0xFF)`. Linked into PRG ROM as a labelled data block. + pub stream: Vec, +} + +impl SfxData { + /// ROM label for the in-PRG envelope blob. The IR codegen + /// emits `LDA #label` pairs to load the + /// pointer into `ZP_SFX_PTR_LO/HI` on `play`. + #[must_use] + pub fn label(&self) -> String { + format!("__sfx_{}", sanitize_label(&self.name)) + } +} + +impl MusicData { + /// ROM label for the in-PRG note-stream blob. + #[must_use] + pub fn label(&self) -> String { + format!("__music_{}", sanitize_label(&self.name)) + } +} + +/// Turn an audio asset name into a label-safe identifier. The +/// public API already restricts names to valid identifiers via the +/// parser, so this only has to protect against nonstandard input +/// from builtins (which currently use lowercase words). +fn sanitize_label(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +/// Per-frame max (user-declared sfx length cap). +/// +/// The driver walks envelope bytes one per NMI — a 60-frame sfx +/// lasts one second on NTSC. Anything much longer than that starts +/// overlapping with the next trigger and sounds muddy; cap at 120 +/// (2 seconds) as a sanity check rather than a hard ROM constraint. +pub const SFX_MAX_FRAMES: usize = 120; + +/// Per-track max notes (user-declared music length cap). The music +/// blob is 2 bytes per note plus a header and a 2-byte sentinel, so +/// 256 notes costs 515 ROM bytes — plenty for typical game loops. +pub const MUSIC_MAX_NOTES: usize = 256; + +/// Resolve all user-declared `sfx` blocks in a program into compiled +/// byte blobs. Also appends builtin sfx data for any name referenced +/// in a `play` statement that isn't user-declared — this keeps legacy +/// programs working without explicit `sfx` declarations. +/// +/// Returns an error if two user declarations share the same name, or +/// if any declaration exceeds the sfx length cap. +pub fn resolve_sfx(program: &Program) -> Result, String> { + let mut out: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + // 1. User-declared sfx come first. Name collisions are an error. + for decl in &program.sfx { + if !seen.insert(decl.name.clone()) { + return Err(format!("duplicate sfx declaration: '{}'", decl.name)); + } + if decl.pitch.len() > SFX_MAX_FRAMES { + return Err(format!( + "sfx '{}' has {} frames, max is {}", + decl.name, + decl.pitch.len(), + SFX_MAX_FRAMES + )); + } + out.push(compile_sfx(decl)); + } + + // 2. Append builtin sfx for any referenced-but-undeclared names. + // Walk every `play name` statement in the program and, for each + // unfamiliar name that matches a builtin, synthesize a builtin + // decl and compile it. We only emit each builtin once. + let referenced = referenced_sfx_names(program); + for name in &referenced { + if seen.contains(name) { + continue; + } + if let Some(decl) = builtin_sfx(name) { + seen.insert(name.clone()); + out.push(compile_sfx(&decl)); + } + } + + Ok(out) +} + +/// Resolve all user-declared `music` blocks. Same shape as +/// [`resolve_sfx`] — user decls first, then builtins for any +/// referenced-but-undeclared names. +pub fn resolve_music(program: &Program) -> Result, String> { + let mut out: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for decl in &program.music { + if !seen.insert(decl.name.clone()) { + return Err(format!("duplicate music declaration: '{}'", decl.name)); + } + if decl.notes.len() > MUSIC_MAX_NOTES { + return Err(format!( + "music '{}' has {} notes, max is {}", + decl.name, + decl.notes.len(), + MUSIC_MAX_NOTES + )); + } + out.push(compile_music(decl)); + } + + let referenced = referenced_music_names(program); + for name in &referenced { + if seen.contains(name) { + continue; + } + if let Some(decl) = builtin_music(name) { + seen.insert(name.clone()); + out.push(compile_music(&decl)); + } + } + + Ok(out) +} + +/// Compile one sfx declaration into its `SfxData`. +/// +/// The compile-time constants (period) are derived from the *first* +/// pitch value in the array — v1 of the driver holds a fixed period +/// for the whole envelope so the pulse channel doesn't retrigger. +/// Pitch variation across frames is ignored in this version; a +/// richer tracker-style format could interleave period and volume +/// updates, but the simple envelope is plenty expressive for the +/// classic set of game sounds. +fn compile_sfx(decl: &SfxDecl) -> SfxData { + let period_lo = decl.pitch.first().copied().unwrap_or(0); + // length_hi: length counter load index 0 (254 frames), period hi = 0. + // Bit 3 of $4003 = length counter enable; bits 0-2 = period high. + let period_hi: u8 = 0x08; + let mut envelope = Vec::with_capacity(decl.volume.len() + 1); + for &vol in &decl.volume { + let duty = decl.duty & 0x03; + // $4000 format: DD LC VVVV. We always set L (length-halt) + // and C (constant volume) so the envelope value is exactly + // the user's volume number without APU envelope decay. + let env = (duty << 6) | 0x30 | (vol & 0x0F); + envelope.push(env); + } + // Zero sentinel — the audio tick sees this, mutes pulse 1, and + // clears the sfx counter so subsequent NMIs don't keep walking. + envelope.push(0x00); + SfxData { + name: decl.name.clone(), + period_lo, + period_hi, + envelope, + } +} + +/// Compile one music declaration into its `MusicData`. +fn compile_music(decl: &MusicDecl) -> MusicData { + let duty = decl.duty & 0x03; + let volume = decl.volume & 0x0F; + let header = (duty << 6) | (volume << 2) | u8::from(decl.loops); + let mut stream = Vec::with_capacity(decl.notes.len() * 2 + 2); + for note in &decl.notes { + stream.push(note.pitch); + stream.push(note.duration); + } + // End-of-track sentinel. The driver loops or mutes based on the + // state header's loop bit (ORed in by `start_music`). + stream.push(0xFF); + stream.push(0xFF); + MusicData { + name: decl.name.clone(), + header, + stream, + } +} + +/// Collect every sfx name referenced by a `play NAME` statement +/// anywhere in the program. +fn referenced_sfx_names(program: &Program) -> Vec { + let mut out = Vec::new(); + for state in &program.states { + for block in state + .on_enter + .iter() + .chain(state.on_exit.iter()) + .chain(state.on_frame.iter()) + .chain(state.on_scanline.iter().map(|(_, b)| b)) + { + walk_for_play(block, &mut out); + } + } + for func in &program.functions { + walk_for_play(&func.body, &mut out); + } + out.sort(); + out.dedup(); + out +} + +/// Collect every music name referenced by a `start_music NAME` +/// statement anywhere in the program. +fn referenced_music_names(program: &Program) -> Vec { + let mut out = Vec::new(); + for state in &program.states { + for block in state + .on_enter + .iter() + .chain(state.on_exit.iter()) + .chain(state.on_frame.iter()) + .chain(state.on_scanline.iter().map(|(_, b)| b)) + { + walk_for_music(block, &mut out); + } + } + for func in &program.functions { + walk_for_music(&func.body, &mut out); + } + out.sort(); + out.dedup(); + out +} + +fn walk_for_play(block: &crate::parser::ast::Block, out: &mut Vec) { + use crate::parser::ast::Statement; + for stmt in &block.statements { + match stmt { + Statement::Play(name, _) => out.push(name.clone()), + Statement::If(_, then_b, elifs, else_b, _) => { + walk_for_play(then_b, out); + for (_, b) in elifs { + walk_for_play(b, out); + } + if let Some(b) = else_b { + walk_for_play(b, out); + } + } + Statement::While(_, b, _) => walk_for_play(b, out), + Statement::Loop(b, _) => walk_for_play(b, out), + Statement::For { body, .. } => walk_for_play(body, out), + _ => {} + } + } +} + +fn walk_for_music(block: &crate::parser::ast::Block, out: &mut Vec) { + use crate::parser::ast::Statement; + for stmt in &block.statements { + match stmt { + Statement::StartMusic(name, _) => out.push(name.clone()), + Statement::If(_, then_b, elifs, else_b, _) => { + walk_for_music(then_b, out); + for (_, b) in elifs { + walk_for_music(b, out); + } + if let Some(b) = else_b { + walk_for_music(b, out); + } + } + Statement::While(_, b, _) => walk_for_music(b, out), + Statement::Loop(b, _) => walk_for_music(b, out), + Statement::For { body, .. } => walk_for_music(body, out), + _ => {} + } + } +} + +/// Return a builtin sfx declaration matching `name`, or `None` if the +/// name isn't a recognized builtin. Builtins cover the six classic +/// game-audio cliches: coin, jump, hit, click, cancel, shoot. Names +/// are matched case-insensitively with a few common aliases. +#[must_use] +pub fn builtin_sfx(name: &str) -> Option { + use crate::lexer::Span; + let lower = name.to_ascii_lowercase(); + let (duty, pitch_base, volume) = match lower.as_str() { + // High, short, ascending blip — classic pickup chirp. + "coin" | "pickup" | "collect" => (2u8, 0x50u8, vec![15, 14, 13, 11, 9, 7, 4, 2]), + // Quick descending arc — jump ack. + "jump" | "hop" => (2, 0x80, vec![13, 13, 12, 11, 9, 7, 5, 3]), + // Low short blast — hit/damage/explode. + "hit" | "damage" | "explode" => (1, 0xA0, vec![15, 14, 12, 10, 8, 6, 4, 2, 1]), + // Sharp high beep — menu click/confirm. + "click" | "select" | "confirm" => (2, 0x40, vec![12, 10, 6, 2]), + // Low longer tone — cancel/back/error. + "cancel" | "back" | "error" => (2, 0xB0, vec![14, 13, 12, 11, 10, 9, 8, 7, 6, 4]), + // Very high, short — laser shoot. + "shoot" | "laser" | "fire" => (3, 0x30, vec![15, 12, 9, 6, 3]), + // Short low thud — footstep. + "step" | "footstep" => (0, 0xC0, vec![10, 6, 2]), + _ => return None, + }; + // Pitch array is constant (one byte, since v1 format latches + // the period once). Use pitch_base as the single entry. + let frames = volume.len(); + Some(SfxDecl { + name: name.to_string(), + duty, + pitch: vec![pitch_base; frames], + volume, + span: Span::dummy(), + }) +} + +/// Return a builtin music declaration matching `name`, or `None`. +/// Builtins are short single-channel loops played on pulse 2. +#[must_use] +pub fn builtin_music(name: &str) -> Option { + use crate::lexer::Span; + // Note indexes reference the period table in + // `runtime::gen_period_table`: 1 = C1, 13 = C2, 25 = C3, 37 = C4. + // Middle C = 37. + const REST: u8 = 0; + const C4: u8 = 37; + const D4: u8 = 39; + const E4: u8 = 41; + const F4: u8 = 42; + const G4: u8 = 44; + const A4: u8 = 46; + const B4: u8 = 48; + const C5: u8 = 49; + let lower = name.to_ascii_lowercase(); + let notes: Vec = match lower.as_str() { + // Cheerful major arpeggio — default theme. + "title" | "theme" | "main" => [ + (C4, 12), + (E4, 12), + (G4, 12), + (C5, 12), + (G4, 12), + (E4, 12), + (C4, 12), + (REST, 12), + ] + .iter() + .map(|&(p, d)| MusicNote { + pitch: p, + duration: d, + }) + .collect(), + // Fast driving pulse — battle/boss. + "battle" | "boss" => [ + (A4, 8), + (C5, 8), + (E4, 8), + (A4, 8), + (G4, 8), + (B4, 8), + (D4, 8), + (G4, 8), + ] + .iter() + .map(|&(p, d)| MusicNote { + pitch: p, + duration: d, + }) + .collect(), + // Fanfare — short ascending burst. + "win" | "victory" | "fanfare" => [(C4, 10), (E4, 10), (G4, 10), (C5, 20), (REST, 10)] + .iter() + .map(|&(p, d)| MusicNote { + pitch: p, + duration: d, + }) + .collect(), + // Gloomy descending — game over. + "gameover" | "lose" | "fail" => { + [(C4, 20), (B4, 20), (A4, 20), (G4, 20), (F4, 30), (REST, 20)] + .iter() + .map(|&(p, d)| MusicNote { + pitch: p, + duration: d, + }) + .collect() + } + _ => return None, + }; + Some(MusicDecl { + name: name.to_string(), + duty: 2, + volume: 10, + loops: !matches!(lower.as_str(), "win" | "victory" | "fanfare"), + notes, + span: Span::dummy(), + }) +} + +/// Return true if `name` matches a builtin sfx entry. +#[must_use] +pub fn is_builtin_sfx(name: &str) -> bool { + builtin_sfx(name).is_some() +} + +/// Return true if `name` matches a builtin music entry. +#[must_use] +pub fn is_builtin_music(name: &str) -> bool { + builtin_music(name).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::Span; + use crate::parser::ast::*; + + fn empty_program() -> Program { + Program { + game: GameDecl { + name: "T".to_string(), + mapper: Mapper::NROM, + mirroring: Mirroring::Horizontal, + span: Span::dummy(), + }, + globals: Vec::new(), + constants: Vec::new(), + enums: Vec::new(), + structs: Vec::new(), + functions: Vec::new(), + states: Vec::new(), + sprites: Vec::new(), + palettes: Vec::new(), + backgrounds: Vec::new(), + sfx: Vec::new(), + music: Vec::new(), + banks: Vec::new(), + start_state: "Main".to_string(), + span: Span::dummy(), + } + } + + #[test] + fn compile_sfx_splits_trigger_constants_from_envelope() { + let decl = SfxDecl { + name: "Test".to_string(), + duty: 2, + pitch: vec![0x50, 0x50, 0x50, 0x50], + volume: vec![15, 10, 5, 0], + span: Span::dummy(), + }; + let data = compile_sfx(&decl); + // Compile-time constants land in fields, not bytes. + assert_eq!(data.period_lo, 0x50); + assert_eq!(data.period_hi, 0x08); + // Envelope = 4 frames + sentinel. + assert_eq!(data.envelope.len(), 5); + // First envelope byte: duty 2 << 6 | 0x30 | 15 = 0xBF. + assert_eq!(data.envelope[0], 0xBF); + // Second: 0x80 | 0x30 | 10 = 0xBA. + assert_eq!(data.envelope[1], 0xBA); + // Last byte is the mute sentinel. + assert_eq!(*data.envelope.last().unwrap(), 0x00); + } + + #[test] + fn compile_sfx_duty_0_clears_top_bits() { + let decl = SfxDecl { + name: "Soft".to_string(), + duty: 0, + pitch: vec![0x20], + volume: vec![8], + span: Span::dummy(), + }; + let data = compile_sfx(&decl); + // env = 0 << 6 | 0x30 | 8 = 0x38 + assert_eq!(data.envelope[0], 0x38); + } + + #[test] + fn compile_music_header_encodes_loop_duty_volume() { + let decl = MusicDecl { + name: "Loop".to_string(), + duty: 2, + volume: 10, + loops: true, + notes: vec![MusicNote { + pitch: 37, + duration: 8, + }], + span: Span::dummy(), + }; + let data = compile_music(&decl); + // header = (2<<6) | (10<<2) | 1 = 0xA9 + let expected_header: u8 = (2 << 6) | (10 << 2) | 1; + assert_eq!(data.header, expected_header); + // Stream = (37, 8), (0xFF, 0xFF). + assert_eq!(data.stream, vec![37, 8, 0xFF, 0xFF]); + } + + #[test] + fn compile_music_non_looping_clears_header_bit() { + let decl = MusicDecl { + name: "Once".to_string(), + duty: 0, + volume: 0, + loops: false, + notes: vec![MusicNote { + pitch: 1, + duration: 1, + }], + span: Span::dummy(), + }; + let data = compile_music(&decl); + assert_eq!(data.header & 0x01, 0, "loop bit must be clear"); + } + + #[test] + fn sfx_label_is_deterministic_per_name() { + let decl = SfxDecl { + name: "Pickup".to_string(), + duty: 2, + pitch: vec![0x50], + volume: vec![8], + span: Span::dummy(), + }; + let data = compile_sfx(&decl); + assert_eq!(data.label(), "__sfx_Pickup"); + } + + #[test] + fn music_label_sanitizes_special_chars() { + let data = MusicData { + name: "Title Screen".to_string(), + header: 0, + stream: vec![0xFF, 0xFF], + }; + assert_eq!(data.label(), "__music_Title_Screen"); + } + + #[test] + fn resolve_sfx_includes_user_decls() { + let mut prog = empty_program(); + prog.sfx.push(SfxDecl { + name: "Zap".to_string(), + duty: 2, + pitch: vec![0x40, 0x40], + volume: vec![15, 8], + span: Span::dummy(), + }); + let resolved = resolve_sfx(&prog).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "Zap"); + // No play statements, so no builtins pulled in. + assert!(!resolved.iter().any(|s| s.name == "coin")); + } + + #[test] + fn resolve_sfx_appends_builtin_when_referenced() { + let mut prog = empty_program(); + // Simulate `on frame { play coin }` as a direct AST build. + prog.states.push(StateDecl { + name: "Main".to_string(), + locals: Vec::new(), + on_enter: None, + on_exit: None, + on_frame: Some(Block { + statements: vec![Statement::Play("coin".to_string(), Span::dummy())], + span: Span::dummy(), + }), + on_scanline: Vec::new(), + span: Span::dummy(), + }); + let resolved = resolve_sfx(&prog).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "coin"); + } + + #[test] + fn resolve_sfx_user_decl_shadows_builtin() { + // A user's `sfx coin { ... }` should take priority over the + // builtin coin effect — otherwise `sfx coin` would be + // confusing (users expect their definition to win). + let mut prog = empty_program(); + prog.sfx.push(SfxDecl { + name: "coin".to_string(), + duty: 0, + pitch: vec![0xAA], + volume: vec![1], + span: Span::dummy(), + }); + prog.states.push(StateDecl { + name: "Main".to_string(), + locals: Vec::new(), + on_enter: None, + on_exit: None, + on_frame: Some(Block { + statements: vec![Statement::Play("coin".to_string(), Span::dummy())], + span: Span::dummy(), + }), + on_scanline: Vec::new(), + span: Span::dummy(), + }); + let resolved = resolve_sfx(&prog).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].period_lo, 0xAA, "user period should win"); + } + + #[test] + fn resolve_sfx_rejects_duplicates() { + let mut prog = empty_program(); + prog.sfx.push(SfxDecl { + name: "Boom".to_string(), + duty: 2, + pitch: vec![0x40], + volume: vec![8], + span: Span::dummy(), + }); + prog.sfx.push(SfxDecl { + name: "Boom".to_string(), + duty: 2, + pitch: vec![0x40], + volume: vec![8], + span: Span::dummy(), + }); + assert!(resolve_sfx(&prog).is_err()); + } + + #[test] + fn resolve_sfx_rejects_oversize() { + let mut prog = empty_program(); + prog.sfx.push(SfxDecl { + name: "Long".to_string(), + duty: 2, + pitch: vec![0; SFX_MAX_FRAMES + 1], + volume: vec![8; SFX_MAX_FRAMES + 1], + span: Span::dummy(), + }); + assert!(resolve_sfx(&prog).is_err()); + } + + #[test] + fn resolve_music_includes_user_decls() { + let mut prog = empty_program(); + prog.music.push(MusicDecl { + name: "Theme".to_string(), + duty: 2, + volume: 10, + loops: true, + notes: vec![MusicNote { + pitch: 37, + duration: 8, + }], + span: Span::dummy(), + }); + let resolved = resolve_music(&prog).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "Theme"); + } + + #[test] + fn resolve_music_appends_builtin_when_referenced() { + let mut prog = empty_program(); + prog.states.push(StateDecl { + name: "Main".to_string(), + locals: Vec::new(), + on_enter: None, + on_exit: None, + on_frame: Some(Block { + statements: vec![Statement::StartMusic("theme".to_string(), Span::dummy())], + span: Span::dummy(), + }), + on_scanline: Vec::new(), + span: Span::dummy(), + }); + let resolved = resolve_music(&prog).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "theme"); + } + + #[test] + fn builtin_sfx_aliases_work() { + assert!(builtin_sfx("coin").is_some()); + assert!(builtin_sfx("COIN").is_some()); + assert!(builtin_sfx("Pickup").is_some()); + assert!(builtin_sfx("unknown_nonsense").is_none()); + } + + #[test] + fn builtin_music_aliases_work() { + assert!(builtin_music("theme").is_some()); + assert!(builtin_music("BATTLE").is_some()); + assert!(builtin_music("Victory").is_some()); + assert!(builtin_music("not_a_track").is_none()); + } + + #[test] + fn is_builtin_helpers_match_option_result() { + for name in ["coin", "jump", "hit", "click", "cancel", "shoot", "step"] { + assert!(is_builtin_sfx(name), "builtin sfx '{name}'"); + } + for name in ["theme", "battle", "victory", "gameover"] { + assert!(is_builtin_music(name), "builtin music '{name}'"); + } + assert!(!is_builtin_sfx("totally_made_up")); + assert!(!is_builtin_music("also_made_up")); + } + + #[test] + fn walk_for_play_finds_nested_references() { + // `play` inside an `if` inside a `while` should still be + // collected — the asset resolver needs to see every + // referenced name so it can link in the right blob. + let mut prog = empty_program(); + prog.states.push(StateDecl { + name: "Main".to_string(), + locals: Vec::new(), + on_enter: None, + on_exit: None, + on_frame: Some(Block { + statements: vec![Statement::While( + Expr::BoolLiteral(true, Span::dummy()), + Block { + statements: vec![Statement::If( + Expr::BoolLiteral(true, Span::dummy()), + Block { + statements: vec![Statement::Play( + "jump".to_string(), + Span::dummy(), + )], + span: Span::dummy(), + }, + Vec::new(), + None, + Span::dummy(), + )], + span: Span::dummy(), + }, + Span::dummy(), + )], + span: Span::dummy(), + }), + on_scanline: Vec::new(), + span: Span::dummy(), + }); + let names = referenced_sfx_names(&prog); + assert_eq!(names, vec!["jump".to_string()]); + } +} diff --git a/src/assets/mod.rs b/src/assets/mod.rs index bb08e42..c85b966 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -1,9 +1,14 @@ +pub mod audio; mod chr; mod palette; pub mod resolve; #[cfg(test)] mod tests; +pub use audio::{ + builtin_music, builtin_sfx, is_builtin_music, is_builtin_sfx, resolve_music, resolve_sfx, + MusicData, SfxData, +}; pub use chr::png_to_chr; pub use palette::{nearest_nes_color, NES_COLORS}; pub use resolve::resolve_sprites; diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 4cc5ddc..b34cefe 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -90,6 +90,8 @@ mod tests { sprites: vec![sprite], palettes: Vec::new(), backgrounds: Vec::new(), + sfx: Vec::new(), + music: Vec::new(), banks: Vec::new(), start_state: "Main".to_string(), span: Span::dummy(), diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 05821d3..71dfba3 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -26,8 +26,12 @@ use std::collections::HashMap; use crate::analyzer::VarAllocation; use crate::asm::{AddressingMode as AM, Instruction, Opcode::*}; +use crate::assets::{MusicData, SfxData}; use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId}; -use crate::runtime::ZP_OAM_CURSOR; +use crate::runtime::{ + ZP_MUSIC_BASE_HI, ZP_MUSIC_BASE_LO, ZP_MUSIC_COUNTER, ZP_MUSIC_PTR_HI, ZP_MUSIC_PTR_LO, + ZP_MUSIC_STATE, ZP_OAM_CURSOR, ZP_SFX_COUNTER, ZP_SFX_PTR_HI, ZP_SFX_PTR_LO, +}; /// Base zero-page address for IR temp slots. const TEMP_BASE: u8 = 0x80; @@ -72,6 +76,15 @@ pub struct IrCodeGen<'a> { use_counts: HashMap, /// Sprite name to tile index mapping. sprite_tiles: HashMap, + /// Sfx name to `(period_lo, period_hi, envelope_label)`. Populated + /// by `with_audio` from the resolved [`SfxData`] list — lets + /// `play Name` emit the right literals for the trigger bytes + /// and the right label for the envelope pointer. + sfx_info: HashMap, + /// Music name to `(header_byte, stream_label)`. Populated by + /// `with_audio`. `start_music Name` stamps `header | 0x02` into + /// `ZP_MUSIC_STATE` and loads the pointer from `stream_label`. + music_info: HashMap, /// State name to dispatch index mapping. state_indices: HashMap, /// Set of function names defined in the IR program (for existence checks). @@ -147,6 +160,8 @@ impl<'a> IrCodeGen<'a> { free_slots: Vec::new(), use_counts: HashMap::new(), sprite_tiles: HashMap::new(), + sfx_info: HashMap::new(), + music_info: HashMap::new(), state_indices: HashMap::new(), function_names, in_frame_handler: false, @@ -177,6 +192,22 @@ impl<'a> IrCodeGen<'a> { self } + /// Register resolved audio assets with the codegen so that + /// `play`/`start_music` can emit literal trigger constants and + /// symbolic pointers to the in-ROM data blobs. + #[must_use] + pub fn with_audio(mut self, sfx: &[SfxData], music: &[MusicData]) -> Self { + for s in sfx { + self.sfx_info + .insert(s.name.clone(), (s.period_lo, s.period_hi, s.label())); + } + for m in music { + self.music_info + .insert(m.name.clone(), (m.header, m.label())); + } + self + } + fn emit(&mut self, opcode: crate::asm::Opcode, mode: AM) { self.instructions.push(Instruction::new(opcode, mode)); } @@ -919,65 +950,97 @@ impl<'a> IrCodeGen<'a> { } } - /// Emit pulse-1 register writes for a one-shot sound effect and - /// set the SFX countdown frames. The name is looked up in the - /// builtin SFX table; unrecognized names play a generic beep. - /// Triggers inclusion of the audio driver body via the - /// `__audio_used` marker label (idempotent). + /// Emit the `play Name` sequence. + /// + /// This is the trigger side of the audio driver: it writes the + /// initial period to pulse 1, sets the SFX counter to "active", + /// and loads the envelope pointer into ZP. The per-frame + /// envelope walk happens in `runtime::gen_audio_tick`, which + /// reads through the pointer and writes the next `$4000` byte + /// each NMI. + /// + /// If `name` is not a declared sfx or a recognized builtin, we + /// emit a silent `play` (period 0, zero-length envelope) rather + /// than failing hard — the analyzer will have already issued a + /// diagnostic for the unknown name. fn gen_play_sfx(&mut self, name: &str) { self.emit_audio_marker(); - let (vol, period_lo, period_hi, duration) = lookup_sfx(name); - // $4000: volume envelope byte (duty | length halt | constant | volume) - self.emit(LDA, AM::Immediate(vol)); - self.emit(STA, AM::Absolute(0x4000)); - // $4002: period low byte + let Some((period_lo, period_hi, label)) = self.sfx_info.get(name).cloned() else { + // Unknown name. The analyzer warns on this; emit a no-op + // sequence so the rest of the code still assembles. The + // unknown branch is easy to spot in `--asm-dump`: it + // writes to the APU status register without touching + // any other pulse-1 state. + return; + }; + // $4000: we don't write a volume envelope here. The first + // envelope byte is consumed by the next NMI audio tick. We + // only need to set up the trigger (period + length). + // + // $4002 / $4003: write period bytes. The write to $4003 also + // loads the length counter and re-triggers the note — that's + // what makes holding down a sfx button re-start the tone + // every frame (audible, useful for demos). self.emit(LDA, AM::Immediate(period_lo)); self.emit(STA, AM::Absolute(0x4002)); - // $4003: length counter load + period high 3 bits. - // The length counter load also "triggers" the envelope - // so this is the canonical "start note" write. self.emit(LDA, AM::Immediate(period_hi)); self.emit(STA, AM::Absolute(0x4003)); - // Set the duration counter so the NMI audio tick knows - // when to mute. - self.emit(LDA, AM::Immediate(duration)); - self.emit(STA, AM::ZeroPage(0x0A)); + // Point ZP_SFX_PTR at the envelope blob. Each subsequent + // NMI advances this pointer and writes the byte to $4000. + self.emit(LDA, AM::SymbolLo(label.clone())); + self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_LO)); + self.emit(LDA, AM::SymbolHi(label)); + self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_HI)); + // Mark sfx as active. The audio tick checks this and bails + // on zero. We use `$FF` (any nonzero value works) as a flag; + // the tick zeros it when it hits the envelope sentinel. + self.emit(LDA, AM::Immediate(0xFF)); + self.emit(STA, AM::ZeroPage(ZP_SFX_COUNTER)); } - /// Emit pulse-2 register writes for sustained music, using the - /// sentinel `$FF` as "play forever" in the NMI tick. A subsequent - /// `stop_music` clears the counter and mutes pulse 2. + /// Emit the `start_music Name` sequence. + /// + /// Stores the track's header byte into `ZP_MUSIC_STATE` (with + /// bit 1 OR'd in as the "active" flag) and seeds both the + /// current pointer and the loop base with the track's stream + /// label. Also zeroes `ZP_MUSIC_COUNTER` so the very next audio + /// tick immediately advances to the first note. fn gen_start_music(&mut self, name: &str) { self.emit_audio_marker(); - let (vol, period_lo, period_hi) = lookup_music(name); - // Music: write to pulse 2 registers ($4004-$4007). - self.emit(LDA, AM::Immediate(vol)); - self.emit(STA, AM::Absolute(0x4004)); - self.emit(LDA, AM::Immediate(period_lo)); - self.emit(STA, AM::Absolute(0x4006)); - self.emit(LDA, AM::Immediate(period_hi)); - self.emit(STA, AM::Absolute(0x4007)); - // Music counter = $FF is the "infinite sustain" sentinel. - // The NMI tick only ever looks at the SFX counter ($0A) - // for mute — pulse 2 sustains through the halted length - // counter (the `0x30 | 0x0F` volume byte clears the halt - // bit, letting the envelope hold). - self.emit(LDA, AM::Immediate(0xFF)); - self.emit(STA, AM::ZeroPage(0x0B)); + let Some((header, label)) = self.music_info.get(name).cloned() else { + return; + }; + // State byte: header | 0x02 (active flag). Header already + // encodes duty, volume, and loop bit. + self.emit(LDA, AM::Immediate(header | 0x02)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_STATE)); + // Stream pointer = label. Also seed the loop-back base + // so the tick's loop branch can rewind. + self.emit(LDA, AM::SymbolLo(label.clone())); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_PTR_LO)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_BASE_LO)); + self.emit(LDA, AM::SymbolHi(label)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_PTR_HI)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_BASE_HI)); + // Counter = 1. The tick will decrement to 0 on the next + // NMI and immediately advance to the first note. We don't + // use 0 here because the tick's "bit 1 set AND counter + // hit zero" check would fire before the first real note + // was even read. + self.emit(LDA, AM::Immediate(1)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)); } - /// Emit a mute for pulse 2 (music) and zero out both countdown - /// counters. Leaves pulse 1 alone — SFX tails naturally expire - /// via the audio tick. + /// Emit the `stop_music` sequence. Mutes pulse 2 and clears the + /// music state byte so the audio tick's bit-1 active check + /// fails and it skips the music work entirely. fn gen_stop_music(&mut self) { self.emit_audio_marker(); - // Silence pulse 2: write the canonical mute byte ($30). self.emit(LDA, AM::Immediate(0x30)); self.emit(STA, AM::Absolute(0x4004)); - // Clear the music sustain sentinel so subsequent - // `start_music` calls don't race with a stale counter. self.emit(LDA, AM::Immediate(0)); - self.emit(STA, AM::ZeroPage(0x0B)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_STATE)); + self.emit(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)); } /// Emit the `__audio_used` marker label at most once per program. @@ -1490,70 +1553,12 @@ fn build_use_counts(func: &IrFunction) -> HashMap { counts } -/// Look up APU parameters for a named sound effect. Returns -/// `(volume_byte, period_lo, period_hi, duration_frames)` suitable -/// for direct writes to $4000/$4002/$4003 and a frame countdown to -/// store at the SFX counter. -/// -/// The table is a small set of builtin names; anything not matched -/// falls back to a generic 1/16th-second beep. Names are matched -/// case-insensitively so `Coin`, `COIN`, and `coin` all work. -/// -/// Volume byte format: `DD LC VVVV` where `DD` is the duty cycle, -/// `L` halts the length counter (1 = sustained), `C` selects -/// constant volume (1 = constant, 0 = envelope decay), and `VVVV` -/// is the volume level (0–15). We use constant volume + short -/// duration counter for all SFX so the envelope doesn't surprise -/// users. -/// -/// Period: NES pulse channels compute frequency as -/// `CPU / (16 * (period + 1))`. Lower period = higher pitch. The -/// table uses periods chosen to land roughly on NES-era notes. -fn lookup_sfx(name: &str) -> (u8, u8, u8, u8) { - // Duty = 10 (50% square), length halt off, constant volume, - // volume 11 (~-8 dB) — a comfortable loudness that doesn't - // dominate the mix. `0xBF = 0b1011_1111`. - const VOL: u8 = 0xBF; - let lower = name.to_ascii_lowercase(); - match lower.as_str() { - // High, short blip — classic pickup sound. - "coin" | "pickup" | "collect" => (VOL, 0x50, 0x08, 6), - // Mid tone with slight length — jump ack. - "jump" | "hop" => (VOL, 0x80, 0x0A, 8), - // Low short blast — hit/damage. - "hit" | "damage" | "explode" => (0xBC, 0xA0, 0x0D, 10), - // Very low short thud — footstep. - "step" | "footstep" => (VOL, 0xC0, 0x0D, 3), - // Higher short beep — menu click. - "click" | "select" | "confirm" => (VOL, 0x40, 0x08, 4), - // Low long-ish tone — cancel/back. - "cancel" | "back" | "error" => (VOL, 0xB0, 0x0C, 10), - // Very high short beep — laser shoot. - "shoot" | "laser" | "fire" => (VOL, 0x30, 0x08, 5), - // Default generic beep. - _ => (VOL, 0x70, 0x0A, 6), - } -} - -/// Look up APU parameters for a named music track. Returns -/// `(volume_byte, period_lo, period_hi)` for pulse 2. Music is -/// sustained (halt bit set) until `stop_music` runs. The table -/// maps a few well-known names to a single held tone; picking a -/// real tune is out of scope for the minimal driver. -fn lookup_music(name: &str) -> (u8, u8, u8) { - // `0x3F` = duty 00 (12.5% pulse), length halt on, constant - // volume, volume 15. The halt bit keeps the note playing - // without envelope decay. - const VOL: u8 = 0x3F; - let lower = name.to_ascii_lowercase(); - match lower.as_str() { - "title" | "theme" | "main" => (VOL, 0xFD, 0x02), - "battle" | "boss" => (VOL, 0xA0, 0x01), - "win" | "victory" | "fanfare" => (VOL, 0x60, 0x01), - "gameover" | "lose" | "fail" => (VOL, 0xE0, 0x03), - _ => (VOL, 0xC0, 0x01), - } -} +// SFX and music parameters used to live in hardcoded `lookup_sfx` / +// `lookup_music` tables here. Those have moved to +// `crate::assets::audio::{builtin_sfx, builtin_music}` so the same +// data path handles user-declared effects, builtin fallbacks, and +// the asset resolver — the codegen only needs the compile-time +// trigger constants which flow through `IrCodeGen::with_audio`. /// Group all scanline handlers by state name, returning /// `(state_name, sorted_scanlines)` pairs. Within each state the @@ -1592,6 +1597,7 @@ fn group_scanline_handlers(ir: &IrProgram) -> Vec<(String, Vec)> { mod tests { use super::*; use crate::analyzer; + use crate::assets; use crate::ir; use crate::parser; @@ -1600,7 +1606,13 @@ mod tests { let prog = prog.unwrap(); let analysis = analyzer::analyze(&prog); let ir_program = ir::lower(&prog, &analysis); - IrCodeGen::new(&analysis.var_allocations, &ir_program).generate(&ir_program) + // Resolve audio the same way the real pipeline does so `play` + // and `start_music` tests can reference builtin names. + let sfx = assets::resolve_sfx(&prog).expect("sfx"); + let music = assets::resolve_music(&prog).expect("music"); + IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_audio(&sfx, &music) + .generate(&ir_program) } fn has_instruction(insts: &[Instruction], opcode: crate::asm::Opcode, mode: &AM) -> bool { @@ -1733,6 +1745,7 @@ mod tests { mod more_tests { use super::*; use crate::analyzer; + use crate::assets; use crate::ir; use crate::parser; @@ -1741,7 +1754,11 @@ mod more_tests { let prog = prog.unwrap(); let analysis = analyzer::analyze(&prog); let ir_program = ir::lower(&prog, &analysis); - IrCodeGen::new(&analysis.var_allocations, &ir_program).generate(&ir_program) + let sfx = assets::resolve_sfx(&prog).expect("sfx"); + let music = assets::resolve_music(&prog).expect("music"); + IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_audio(&sfx, &music) + .generate(&ir_program) } #[test] @@ -2128,7 +2145,16 @@ mod more_tests { } #[test] - fn ir_codegen_play_sfx_emits_pulse1_writes_and_marker() { + fn ir_codegen_play_sfx_triggers_pulse1_and_loads_envelope_pointer() { + // `play coin` must: + // 1. Write the period trigger bytes to $4002 and $4003 + // (starting the note on pulse 1). + // 2. Load the envelope blob pointer into ZP_SFX_PTR_LO/HI + // via SymbolLo/SymbolHi of the `__sfx_coin` label. + // 3. Set ZP_SFX_COUNTER nonzero so the audio tick starts + // walking the envelope. + // 4. Emit the `__audio_used` marker label so the linker + // splices in the driver and period table. let insts = lower_and_gen( r#" game "T" { mapper: NROM } @@ -2136,12 +2162,7 @@ mod more_tests { start Main "#, ); - // `play` must emit writes to $4000 (volume), $4002 (period - // low) and $4003 (length + period high). - assert!( - has_inst(&insts, STA, &AM::Absolute(0x4000)), - "play should write pulse-1 volume register $4000" - ); + // Trigger bytes on pulse 1. assert!( has_inst(&insts, STA, &AM::Absolute(0x4002)), "play should write pulse-1 period-lo register $4002" @@ -2150,14 +2171,21 @@ mod more_tests { has_inst(&insts, STA, &AM::Absolute(0x4003)), "play should write pulse-1 length+period-hi register $4003" ); - // The SFX countdown counter at $0A must be set so the NMI - // audio tick knows how long to sustain the tone. + // Envelope pointer loaded via SymbolLo/SymbolHi of sfx label. + let has_ptr_lo = insts + .iter() + .any(|i| i.opcode == LDA && matches!(&i.mode, AM::SymbolLo(n) if n == "__sfx_coin")); + let has_ptr_hi = insts + .iter() + .any(|i| i.opcode == LDA && matches!(&i.mode, AM::SymbolHi(n) if n == "__sfx_coin")); + assert!(has_ptr_lo, "play should load envelope pointer low byte"); + assert!(has_ptr_hi, "play should load envelope pointer high byte"); + // ZP_SFX_COUNTER (0x0A) set to a nonzero "active" marker. assert!( has_inst(&insts, STA, &AM::ZeroPage(0x0A)), - "play should set the SFX counter at $0A" + "play should set ZP_SFX_COUNTER to flag the sfx as active" ); - // The `__audio_used` marker label must be present so the - // linker knows to splice the audio tick into NMI. + // __audio_used marker. let has_marker = insts .iter() .any(|i| matches!(&i.mode, AM::Label(l) if l == "__audio_used")); @@ -2165,7 +2193,14 @@ mod more_tests { } #[test] - fn ir_codegen_start_music_writes_pulse2() { + fn ir_codegen_start_music_sets_state_and_stream_pointer() { + // `start_music theme` must: + // 1. Load a state byte (header OR'd with 0x02 = active) + // into ZP_MUSIC_STATE (0x07). + // 2. Load the music stream pointer into ZP_MUSIC_PTR and + // ZP_MUSIC_BASE (so the loop branch can rewind). + // 3. Seed ZP_MUSIC_COUNTER with 1 so the next tick + // immediately advances to the first note. let insts = lower_and_gen( r#" game "T" { mapper: NROM } @@ -2173,23 +2208,42 @@ mod more_tests { start Main "#, ); - // Music uses pulse 2 ($4004-$4007). + // State byte stored at 0x07. assert!( - has_inst(&insts, STA, &AM::Absolute(0x4004)), - "start_music should write pulse-2 volume register $4004" + has_inst(&insts, STA, &AM::ZeroPage(0x07)), + "start_music should store the state byte at ZP_MUSIC_STATE ($07)" + ); + // Pointer load via SymbolLo of __music_theme. + let has_ptr_lo = insts + .iter() + .any(|i| i.opcode == LDA && matches!(&i.mode, AM::SymbolLo(n) if n == "__music_theme")); + let has_ptr_hi = insts + .iter() + .any(|i| i.opcode == LDA && matches!(&i.mode, AM::SymbolHi(n) if n == "__music_theme")); + assert!(has_ptr_lo, "start_music should load stream ptr low"); + assert!(has_ptr_hi, "start_music should load stream ptr high"); + // Both PTR and BASE should be written (4 stores total for + // the pointer pair: PTR_LO, BASE_LO, PTR_HI, BASE_HI). + assert!( + has_inst(&insts, STA, &AM::ZeroPage(0x0E)), + "start_music should store ZP_MUSIC_PTR_LO ($0E)" ); assert!( - has_inst(&insts, STA, &AM::Absolute(0x4006)), - "start_music should write pulse-2 period-lo register $4006" + has_inst(&insts, STA, &AM::ZeroPage(0x05)), + "start_music should store ZP_MUSIC_BASE_LO ($05) for loop-back" ); assert!( - has_inst(&insts, STA, &AM::Absolute(0x4007)), - "start_music should write pulse-2 length+period-hi register $4007" + has_inst(&insts, STA, &AM::ZeroPage(0x0F)), + "start_music should store ZP_MUSIC_PTR_HI ($0F)" + ); + assert!( + has_inst(&insts, STA, &AM::ZeroPage(0x06)), + "start_music should store ZP_MUSIC_BASE_HI ($06) for loop-back" ); } #[test] - fn ir_codegen_stop_music_mutes_pulse2() { + fn ir_codegen_stop_music_mutes_pulse2_and_clears_state() { let insts = lower_and_gen( r#" game "T" { mapper: NROM } @@ -2197,7 +2251,7 @@ mod more_tests { start Main "#, ); - // Stop must write the mute byte ($30) to $4004. + // Mute $4004 and clear ZP_MUSIC_STATE ($07). assert!( has_inst(&insts, LDA, &AM::Immediate(0x30)), "stop_music should load the mute byte $30" @@ -2206,6 +2260,10 @@ mod more_tests { has_inst(&insts, STA, &AM::Absolute(0x4004)), "stop_music should store to pulse-2 volume register $4004" ); + assert!( + has_inst(&insts, STA, &AM::ZeroPage(0x07)), + "stop_music should clear ZP_MUSIC_STATE ($07)" + ); } #[test] diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 7d21b7b..3a8414f 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -3,6 +3,7 @@ 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; @@ -89,8 +90,28 @@ impl Linker { } /// Link all code sections into a .nes ROM, placing sprite CHR data at - /// specific tile indices. + /// 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 { + 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 { // For NROM: everything fits in one 16 KB PRG bank ($C000-$FFFF) // Layout: // $C000: RESET handler (init + palette load + user code) @@ -116,13 +137,35 @@ impl Linker { all_instructions.extend(runtime::gen_multiply()); all_instructions.extend(runtime::gen_divide()); - // Audio driver body — linked in whenever user code touched - // audio. The driver needs to exist before `__nmi` (the NMI - // JSR target must be defined before the caller), so emit it - // alongside the math routines. No-cost elision when unused: - // the `has_label` check below skips the whole block. - if has_label(user_code, "__audio_used") { + // 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 @@ -138,14 +181,11 @@ impl Linker { if has_label(user_code, "__ir_mmc3_reload") { all_instructions.push(Instruction::new(JSR, AM::Label("__ir_mmc3_reload".into()))); } - // Audio tick: if the user program ever triggered an SFX or - // music (detected by the marker label `__audio_used` emitted - // by `IrCodeGen` when it sees any audio op), JSR into the - // driver's per-frame tick before the normal NMI body. The - // tick decrements the SFX counter and silences pulse 1 when - // it reaches zero. Programs that never play audio skip both - // the splice and the driver body entirely — no ROM cost. - let has_audio = has_label(user_code, "__audio_used"); + // 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()))); } diff --git a/src/linker/tests.rs b/src/linker/tests.rs index 5c66786..57bf035 100644 --- a/src/linker/tests.rs +++ b/src/linker/tests.rs @@ -163,6 +163,138 @@ fn link_omits_audio_tick_when_no_marker() { assert_eq!(info.prg_banks, 1); } +#[test] +fn link_with_audio_data_places_sfx_blobs_in_prg() { + // When user code has the `__audio_used` marker AND we pass in + // sfx data, the linker must: + // 1. Splice in the audio tick (driver body) + // 2. Splice in the period table + // 3. Splice in the envelope blob under its label + // 4. Resolve SymbolLo/SymbolHi references from user code to + // the blob's address (second pass of the assembler) + let linker = Linker::new(Mirroring::Horizontal); + // User code: `LDA #<__sfx_test`, `STA $0C`, `LDA #>__sfx_test`, + // `STA $0D` — simulates what IR codegen's gen_play_sfx emits. + let user_code = vec![ + Instruction::new(NOP, AM::Label("__audio_used".into())), + Instruction::new(LDA, AM::SymbolLo("__sfx_test".into())), + Instruction::new(STA, AM::ZeroPage(0x0C)), + Instruction::new(LDA, AM::SymbolHi("__sfx_test".into())), + Instruction::new(STA, AM::ZeroPage(0x0D)), + ]; + let sfx = vec![SfxData { + name: "test".into(), + period_lo: 0x50, + period_hi: 0x08, + envelope: vec![0xBF, 0xB8, 0xB4, 0xB0, 0x00], + }]; + let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]); + let info = rom::validate_ines(&rom).unwrap(); + assert_eq!(info.prg_banks, 1); + // The envelope bytes must appear somewhere in PRG. Find them. + let prg = &rom[16..16 + 16384]; + let needle = [0xBF, 0xB8, 0xB4, 0xB0, 0x00]; + let found = prg.windows(needle.len()).any(|w| w == needle); + assert!(found, "sfx envelope bytes should be spliced into PRG ROM"); +} + +#[test] +fn link_with_audio_data_places_music_stream_in_prg() { + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![ + Instruction::new(NOP, AM::Label("__audio_used".into())), + Instruction::new(LDA, AM::SymbolLo("__music_test".into())), + Instruction::new(STA, AM::ZeroPage(0x0E)), + Instruction::new(LDA, AM::SymbolHi("__music_test".into())), + Instruction::new(STA, AM::ZeroPage(0x0F)), + ]; + let music = vec![MusicData { + name: "test".into(), + header: 0xA9, + stream: vec![37, 8, 41, 8, 44, 8, 0xFF, 0xFF], + }]; + let rom = linker.link_with_all_assets(&user_code, &[], &[], &music); + let prg = &rom[16..16 + 16384]; + let needle = [37, 8, 41, 8, 44, 8, 0xFF, 0xFF]; + let found = prg.windows(needle.len()).any(|w| w == needle); + assert!(found, "music note stream should be spliced into PRG ROM"); +} + +#[test] +fn link_with_audio_resolves_sfx_pointer_references() { + // The SymbolLo/SymbolHi references in user code must get + // fixed up to the *actual* PRG address of the envelope blob. + // We can verify this by reading back the user code bytes and + // checking that the LDA immediates point somewhere in the + // valid PRG range ($C000-$FFFF). + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![ + Instruction::new(NOP, AM::Label("__audio_used".into())), + Instruction::new(LDA, AM::SymbolLo("__sfx_test".into())), + Instruction::new(LDA, AM::SymbolHi("__sfx_test".into())), + ]; + let sfx = vec![SfxData { + name: "test".into(), + period_lo: 0x50, + period_hi: 0x08, + envelope: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00], + }]; + let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]); + // The user code starts at RESET ($C000) after init+palette_load. + // Rather than compute the exact offset, verify the envelope + // bytes appear at a byte that matches what the LDA immediate + // pair would produce. We find the immediate pair by searching + // for `A9 xx A9 yy` and checking `$xxyy` points at the needle. + let prg = &rom[16..16 + 16384]; + let needle = [0xDE, 0xAD, 0xBE, 0xEF, 0x00]; + // Find where the envelope lives in ROM. + let env_offset = prg + .windows(needle.len()) + .position(|w| w == needle) + .expect("envelope should be in PRG"); + let env_addr = 0xC000u16 + env_offset as u16; + // Find any LDA-immediate pair that matches the envelope address. + let lo = (env_addr & 0xFF) as u8; + let hi = (env_addr >> 8) as u8; + let pattern = [0xA9u8, lo, 0xA9, hi]; + let matched = prg.windows(pattern.len()).any(|w| w == pattern); + assert!( + matched, + "should find `LDA #env_addr` in PRG ($A9 ${lo:02X} $A9 ${hi:02X})" + ); +} + +#[test] +fn link_without_audio_marker_does_not_emit_period_table() { + // Programs that never use audio must not pay the cost of the + // period table, driver body, or any blobs. We verify this + // indirectly: the `__period_table` label should NOT appear at + // a distinct ROM address from the main body. + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![Instruction::implied(NOP)]; + let rom = linker.link_with_all_assets( + &user_code, + &[], + &[SfxData { + name: "unused".into(), + period_lo: 0, + period_hi: 0, + envelope: vec![0xAA, 0xBB, 0x00], + }], + &[], + ); + // The envelope bytes `AA BB 00` must NOT appear in PRG — the + // linker should have elided the whole audio section because + // the marker is absent. + let prg = &rom[16..16 + 16384]; + let needle = [0xAAu8, 0xBB, 0x00]; + let found = prg.windows(needle.len()).any(|w| w == needle); + assert!( + !found, + "unused sfx data should not be spliced when __audio_used is absent" + ); +} + #[test] fn palette_load_writes_to_ppu() { let linker = Linker::new(Mirroring::Horizontal); diff --git a/src/main.rs b/src/main.rs index f3b4c68..4b45703 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,9 +287,20 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { eprintln!("error: {e}"); })?; + // Resolve audio assets: user-declared sfx/music plus any + // builtins referenced via `play foo` / `start_music bar` for + // names that aren't in the program's sfx/music declarations. + let sfx = assets::resolve_sfx(&program).map_err(|e| { + eprintln!("error: {e}"); + })?; + let music = assets::resolve_music(&program).map_err(|e| { + eprintln!("error: {e}"); + })?; + // IR-based code generation. Lower → optimize → emit 6502. let mut instructions = IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) + .with_audio(&sfx, &music) .with_debug(debug) .generate(&ir_program); @@ -301,9 +312,10 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { dump_asm(&instructions); } - // Link into ROM with sprite CHR data placed at each sprite's tile index. - let linker = Linker::new(program.game.mirroring); - let rom = linker.link_with_assets(&instructions, &sprites); + // Link into ROM with both graphic assets (sprite CHR) and audio + // assets (sfx envelopes, music note streams) spliced in. + let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper); + let rom = linker.link_with_all_assets(&instructions, &sprites, &sfx, &music); Ok(rom) } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 66ff1b7..5306450 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -12,6 +12,8 @@ pub struct Program { pub sprites: Vec, pub palettes: Vec, pub backgrounds: Vec, + pub sfx: Vec, + pub music: Vec, pub banks: Vec, pub start_state: String, pub span: Span, @@ -67,6 +69,58 @@ pub struct BackgroundDecl { pub span: Span, } +/// `sfx Name { ... }` — a sound effect played on pulse 1. SFX are +/// frame-accurate envelopes: `pitch[i]` and `volume[i]` describe the +/// $4002/$4000 register state for frame `i`, advancing one entry per +/// NMI tick. `duty` selects the pulse duty cycle (0-3) for the whole +/// effect. The two arrays must have the same length; the runtime +/// drops pulse 1's volume to 0 one frame after the last entry. +#[derive(Debug, Clone)] +pub struct SfxDecl { + pub name: String, + /// Duty cycle bits (0-3). Each bit pattern picks a different + /// pulse waveform; 2 (50%) sounds like a square wave. + pub duty: u8, + /// One period byte per frame, written to $4002. The $4003 high + /// nibble is always zero (keeps notes in the audible range). + pub pitch: Vec, + /// One volume byte per frame (0-15), combined with the duty bits + /// and written to $4000. + pub volume: Vec, + pub span: Span, +} + +/// `music Name { ... }` — a background music track played on pulse 2. +/// Music is encoded as a list of `(note_index, duration_frames)` +/// pairs. Note index 0 is a rest; indexes 1..=60 look up a period in +/// the builtin period table (C1 through B5). The track loops by +/// default when it reaches the end. +#[derive(Debug, Clone)] +pub struct MusicDecl { + pub name: String, + /// Pulse-2 duty cycle (0-3). + pub duty: u8, + /// Constant volume (0-15) for pulse 2. + pub volume: u8, + /// True: the track loops when it reaches the end. + /// False: the track mutes itself on completion. + pub loops: bool, + /// List of `(note_index, duration_frames)` pairs. A `note_index` + /// of 0 is a rest; otherwise it's an index into the builtin + /// period table (see `runtime::gen_period_table`). + pub notes: Vec, + pub span: Span, +} + +/// A single note in a music track: pitch + duration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MusicNote { + /// 0 = rest; 1-60 = period table index; other values are invalid. + pub pitch: u8, + /// Number of frames to hold this note (1-255). 0 is invalid. + pub duration: u8, +} + #[derive(Debug, Clone)] pub enum AssetSource { Chr(String), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2704f8b..130a658 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -129,6 +129,8 @@ impl Parser { let mut sprites = Vec::new(); let mut palettes = Vec::new(); let mut backgrounds = Vec::new(); + let mut sfx = Vec::new(); + let mut music = Vec::new(); let mut banks = Vec::new(); let mut start_state = None; let mut on_frame = None; @@ -169,6 +171,12 @@ impl Parser { TokenKind::KwBackground => { backgrounds.push(self.parse_background_decl()?); } + TokenKind::KwSfx => { + sfx.push(self.parse_sfx_decl()?); + } + TokenKind::KwMusic => { + music.push(self.parse_music_decl()?); + } TokenKind::KwBank => { banks.push(self.parse_bank_decl()?); } @@ -237,6 +245,8 @@ impl Parser { sprites, palettes, backgrounds, + sfx, + music, banks, start_state, span, @@ -762,6 +772,275 @@ impl Parser { }) } + // ── SFX / Music declarations ── + + /// `sfx Name { duty: N, pitch: [..], volume: [..] }`. Pitch and + /// volume arrays must be the same length — each index is one + /// frame of the envelope. Duty is optional, default 2 (50%). + fn parse_sfx_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwSfx)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + + let mut duty: u8 = 2; + let mut pitch: Option> = None; + let mut volume: Option> = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let (key, key_span) = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "duty" => { + duty = self.parse_u8_literal("duty")?; + if duty > 3 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("sfx 'duty' must be 0-3, got {duty}"), + key_span, + )); + } + } + "pitch" => { + pitch = Some(self.parse_byte_array("pitch")?); + } + "volume" => { + let vals = self.parse_byte_array("volume")?; + for v in &vals { + if *v > 15 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("sfx 'volume' entries must be 0-15, got {v}"), + key_span, + )); + } + } + volume = Some(vals); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown sfx property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let pitch = pitch.ok_or_else(|| { + Diagnostic::error(ErrorCode::E0201, "sfx requires 'pitch' property", start) + })?; + let volume = volume.ok_or_else(|| { + Diagnostic::error(ErrorCode::E0201, "sfx requires 'volume' property", start) + })?; + + if pitch.is_empty() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "sfx 'pitch' array must have at least one frame", + start, + )); + } + if pitch.len() != volume.len() { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "sfx 'pitch' and 'volume' arrays must have the same length \ + (pitch has {}, volume has {})", + pitch.len(), + volume.len() + ), + start, + )); + } + + Ok(SfxDecl { + name, + duty, + pitch, + volume, + span: Span::new(start.file_id, start.start, self.current_span().end), + }) + } + + /// `music Name { duty: N, volume: N, repeat: true|false, notes: [..] }`. + /// Notes are encoded as a flat list: `[pitch1, dur1, pitch2, dur2, ...]`. + /// Pitch 0 is a rest; nonzero pitches are indices into the builtin + /// period table (1 = C1, 60 = B5). Duration is in frames (1-255). + fn parse_music_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwMusic)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + + let mut duty: u8 = 2; + let mut volume: u8 = 10; + let mut loops: bool = true; + let mut notes: Option> = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let (key, key_span) = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "duty" => { + duty = self.parse_u8_literal("duty")?; + if duty > 3 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music 'duty' must be 0-3, got {duty}"), + key_span, + )); + } + } + "volume" => { + volume = self.parse_u8_literal("volume")?; + if volume > 15 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music 'volume' must be 0-15, got {volume}"), + key_span, + )); + } + } + "repeat" => match self.peek().clone() { + TokenKind::BoolLiteral(b) => { + self.advance(); + loops = b; + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected bool for 'repeat', got '{}'", self.peek()), + key_span, + )); + } + }, + "notes" => { + let flat = self.parse_byte_array("notes")?; + if flat.len() % 2 != 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music 'notes' must have an even number of entries \ + (pitch, duration, pitch, duration, ...)", + key_span, + )); + } + let mut n = Vec::with_capacity(flat.len() / 2); + for pair in flat.chunks(2) { + let pitch = pair[0]; + let duration = pair[1]; + if duration == 0 { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music note duration must be >= 1", + key_span, + )); + } + if pitch > 60 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("music note pitch must be 0 (rest) or 1-60, got {pitch}"), + key_span, + )); + } + n.push(MusicNote { pitch, duration }); + } + notes = Some(n); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown music property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let notes = notes.ok_or_else(|| { + Diagnostic::error(ErrorCode::E0201, "music requires 'notes' property", start) + })?; + + if notes.is_empty() { + return Err(Diagnostic::error( + ErrorCode::E0201, + "music 'notes' must contain at least one (pitch, duration) pair", + start, + )); + } + + Ok(MusicDecl { + name, + duty, + volume, + loops, + notes, + span: Span::new(start.file_id, start.start, self.current_span().end), + }) + } + + /// Parse a `[byte, byte, ...]` array. Used by sfx/music property + /// parsing — the main `parse_asset_source` also does this, but + /// without the array-literal-only restriction we want here. + fn parse_byte_array(&mut self, prop: &str) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut out = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + if let TokenKind::IntLiteral(v) = self.peek().clone() { + self.advance(); + if v > 0xFF { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("'{prop}' entries must fit in a u8, got {v}"), + self.current_span(), + )); + } + out.push(v as u8); + } else { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected byte value in '{prop}', found '{}'", self.peek()), + self.current_span(), + )); + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + Ok(out) + } + + /// Parse a single u8 integer literal for a scalar property. + fn parse_u8_literal(&mut self, prop: &str) -> Result { + match self.peek().clone() { + TokenKind::IntLiteral(v) => { + self.advance(); + if v > 0xFF { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("'{prop}' must fit in a u8, got {v}"), + self.current_span(), + )); + } + Ok(v as u8) + } + other => Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected integer for '{prop}', got '{other}'"), + self.current_span(), + )), + } + } + fn parse_asset_source(&mut self) -> Result { match self.peek() { TokenKind::At => { diff --git a/src/parser/tests.rs b/src/parser/tests.rs index ee6bc33..c404018 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -583,6 +583,212 @@ fn parse_set_palette_statement() { } } +// ── Audio subsystem: sfx / music declarations ── + +#[test] +fn parse_sfx_decl_minimal() { + let src = r#" + game "T" { mapper: NROM } + sfx Pickup { + pitch: [0x60, 0x58, 0x50, 0x48] + volume: [15, 12, 8, 4] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.sfx.len(), 1); + let sfx = &prog.sfx[0]; + assert_eq!(sfx.name, "Pickup"); + assert_eq!(sfx.pitch, vec![0x60, 0x58, 0x50, 0x48]); + assert_eq!(sfx.volume, vec![15, 12, 8, 4]); + // Default duty is 2 (50% square). + assert_eq!(sfx.duty, 2); +} + +#[test] +fn parse_sfx_decl_with_duty() { + let src = r#" + game "T" { mapper: NROM } + sfx Zap { + duty: 3 + pitch: [0x20, 0x22, 0x24] + volume: [15, 10, 5] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.sfx[0].duty, 3); +} + +#[test] +fn parse_sfx_decl_rejects_mismatched_array_lengths() { + let src = r#" + game "T" { mapper: NROM } + sfx Bad { + pitch: [0x20, 0x22] + volume: [15, 10, 5] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "mismatched pitch/volume lengths should error" + ); +} + +#[test] +fn parse_sfx_decl_rejects_volume_over_15() { + let src = r#" + game "T" { mapper: NROM } + sfx Bad { + pitch: [0x20] + volume: [16] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "volume > 15 should error" + ); +} + +#[test] +fn parse_sfx_decl_rejects_duty_over_3() { + let src = r#" + game "T" { mapper: NROM } + sfx Bad { + duty: 4 + pitch: [0x20] + volume: [8] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "duty > 3 should error" + ); +} + +#[test] +fn parse_music_decl_minimal() { + let src = r#" + game "T" { mapper: NROM } + music Theme { + notes: [37, 8, 41, 8, 44, 8, 49, 16] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.music.len(), 1); + let m = &prog.music[0]; + assert_eq!(m.name, "Theme"); + assert_eq!(m.notes.len(), 4); + assert_eq!(m.notes[0].pitch, 37); + assert_eq!(m.notes[0].duration, 8); + assert_eq!(m.notes[3].pitch, 49); + assert_eq!(m.notes[3].duration, 16); + // Default music loops. + assert!(m.loops); +} + +#[test] +fn parse_music_decl_with_full_properties() { + let src = r#" + game "T" { mapper: NROM } + music Victory { + duty: 0 + volume: 12 + repeat: false + notes: [37, 10, 41, 10, 44, 10, 49, 20] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + let m = &prog.music[0]; + assert_eq!(m.duty, 0); + assert_eq!(m.volume, 12); + assert!(!m.loops, "repeat: false should disable looping"); +} + +#[test] +fn parse_music_decl_rejects_odd_note_count() { + let src = r#" + game "T" { mapper: NROM } + music Bad { + notes: [37, 8, 41] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "odd note count should error — notes must come in (pitch, duration) pairs" + ); +} + +#[test] +fn parse_music_decl_rejects_pitch_above_60() { + let src = r#" + game "T" { mapper: NROM } + music Bad { + notes: [100, 8] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "pitch above 60 should error" + ); +} + +#[test] +fn parse_music_decl_rejects_zero_duration() { + let src = r#" + game "T" { mapper: NROM } + music Bad { + notes: [37, 0] + } + on frame { wait_frame } + start Main + "#; + let (_, diags) = parse(src); + assert!( + diags.iter().any(crate::errors::Diagnostic::is_error), + "zero duration should error" + ); +} + +#[test] +fn parse_play_and_start_music_statements() { + let src = r#" + game "T" { mapper: NROM } + on frame { + play coin + start_music theme + stop_music + } + start Main + "#; + let prog = parse_ok(src); + let stmts = &prog.states[0].on_frame.as_ref().unwrap().statements; + assert!(matches!(&stmts[0], Statement::Play(n, _) if n == "coin")); + assert!(matches!(&stmts[1], Statement::StartMusic(n, _) if n == "theme")); + assert!(matches!(&stmts[2], Statement::StopMusic(_))); +} + // ── Milestone 4: Optimization & Polish ── #[test] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 0a1dd1b..b599364 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -25,14 +25,38 @@ pub const ZP_INPUT_P2: u8 = 0x08; /// the oldest slot gets overwritten — the classic NES flicker /// fallback. pub const ZP_OAM_CURSOR: u8 = 0x09; -/// Pulse-1 SFX countdown frames. `play SfxName` sets this to the -/// SFX duration and writes the tone registers; the NMI audio tick -/// decrements it every frame, silencing pulse 1 when it reaches 0. +/// Pulse-1 SFX envelope pointer (2 bytes, lo/hi) — points at the +/// *current* frame's $4000 envelope byte inside the sfx blob. The +/// audio tick reads through this byte, writes to $4000, advances +/// the pointer, and keeps going until it reads a zero sentinel. +pub const ZP_SFX_PTR_LO: u8 = 0x0C; +pub const ZP_SFX_PTR_HI: u8 = 0x0D; +/// Pulse-2 music note-stream pointer (2 bytes, lo/hi) — points at +/// the *current* (pitch, duration) note pair inside the music blob. +pub const ZP_MUSIC_PTR_LO: u8 = 0x0E; +pub const ZP_MUSIC_PTR_HI: u8 = 0x0F; +/// Music base pointer (2 bytes) — start of the currently-loaded +/// track. Used by the loop-back branch when the driver hits the +/// end-of-track sentinel and the header loop flag is set. +pub const ZP_MUSIC_BASE_LO: u8 = 0x05; +pub const ZP_MUSIC_BASE_HI: u8 = 0x06; +/// Music state byte. Bit layout: +/// bit 0: 1 = track is looping, 0 = one-shot +/// bit 1: 1 = music is active (non-zero means "playing") +/// bits 2-5: latched pulse-2 envelope volume 0-15 +/// bits 6-7: latched pulse-2 duty +/// Set on `start_music`, cleared (to 0) on `stop_music`. The driver +/// writes a fresh $4004 envelope byte every time it advances to a +/// new note using these bits so held notes don't decay. +pub const ZP_MUSIC_STATE: u8 = 0x07; +/// Pulse-1 SFX countdown — `0` means no sfx is playing. +/// Nonzero means the audio tick should read one envelope byte from +/// `ZP_SFX_PTR` each NMI and write it to $4000. When the tick reads +/// a zero sentinel it mutes pulse 1 and clears this byte. pub const ZP_SFX_COUNTER: u8 = 0x0A; -/// Pulse-2 music countdown frames. `start_music TrackName` sets -/// this to `$FF` (infinite sustain) and writes a pulse-2 tone; -/// `stop_music` zeros it and mutes pulse 2. `$FF` skips the NMI -/// decrement so music plays until explicitly stopped. +/// Pulse-2 music duration countdown — frames remaining on the +/// currently-held music note. When it reaches zero, the tick +/// advances to the next (pitch, duration) pair. pub const ZP_MUSIC_COUNTER: u8 = 0x0B; /// Generate the NES hardware initialization sequence. @@ -256,46 +280,338 @@ pub fn gen_multiply() -> Vec { out } -/// Generate the per-NMI audio tick that ages the SFX counter and -/// silences pulse 1 when the counter hits zero. Music on pulse 2 -/// uses the sentinel `$FF` for infinite sustain and is never -/// decremented here — `stop_music` handles mute explicitly. +/// Generate the per-NMI audio tick. This is the heart of the audio +/// driver — it walks both the SFX envelope and the music note stream +/// every frame and writes the resulting APU register values. /// /// The linker splices a `JSR __audio_tick` into the NMI handler -/// whenever user code contains any audio ops, so programs that -/// never call `play`/`start_music`/`stop_music` pay zero cost. +/// whenever user code contains any audio op (detected by the +/// `__audio_used` marker label), so programs that never call +/// `play`/`start_music`/`stop_music` pay zero ROM or cycle cost. /// -/// Contract: -/// - Input: `ZP_SFX_COUNTER` = remaining frames for pulse 1's tone -/// - Effect: decrements the counter; on 0→transition mutes $4000 -/// - Clobbers: A (which the NMI handler restores via PLA) +/// ## SFX channel (pulse 1) +/// +/// State: +/// - `ZP_SFX_COUNTER` — nonzero while an sfx is playing +/// - `ZP_SFX_PTR_LO/HI` — pointer into the current sfx blob, +/// advanced one byte per frame +/// +/// Each frame: if the counter is nonzero, read one byte through the +/// pointer, write it to `$4000`, and advance the pointer. A zero +/// byte is the sentinel; on it the driver mutes pulse 1 and clears +/// the counter. +/// +/// ## Music channel (pulse 2) +/// +/// State: +/// - `ZP_MUSIC_COUNTER` — frames remaining on the current note +/// - `ZP_MUSIC_STATE` — bit 1 set = active; bits encode duty/volume/loop +/// - `ZP_MUSIC_PTR_LO/HI` — pointer to the next (pitch,dur) pair +/// - `ZP_MUSIC_BASE_LO/HI` — loop-back start of the current track +/// +/// Each frame: if the state says "active" and the counter is nonzero, +/// decrement the counter and bail. When it hits zero, advance past +/// the current (pitch,dur) pair and read the next one. `0xFF,0xFF` +/// is the end-of-track sentinel; the driver either rewinds to the +/// base pointer (looping tracks) or mutes pulse 2 (one-shot tracks). +/// +/// ## Clobbers +/// +/// A, X, Y. The NMI handler calls this from inside its own +/// save/restore block so caller registers are safe. pub fn gen_audio_tick() -> Vec { let mut out = Vec::new(); out.push(Instruction::new(NOP, AM::Label("__audio_tick".into()))); - // SFX counter check: if 0, nothing to do. + // ── SFX tick ── + // If counter is zero, no sfx is playing; skip. out.push(Instruction::new(LDA, AM::ZeroPage(ZP_SFX_COUNTER))); out.push(Instruction::new( BEQ, - AM::LabelRelative("__audio_tick_done".into()), + AM::LabelRelative("__audio_sfx_done".into()), )); - out.push(Instruction::new(DEC, AM::ZeroPage(ZP_SFX_COUNTER))); - // If still non-zero, leave the tone alone. + // Read next envelope byte via (ZP_SFX_PTR),Y with Y=0. + out.push(Instruction::new(LDY, AM::Immediate(0))); + out.push(Instruction::new(LDA, AM::IndirectY(ZP_SFX_PTR_LO))); + // If it's the zero sentinel, silence pulse 1 and clear state. out.push(Instruction::new( BNE, - AM::LabelRelative("__audio_tick_done".into()), + AM::LabelRelative("__audio_sfx_write".into()), )); - // Counter just hit 0: silence pulse 1 (volume envelope = mute). + // Sentinel branch: write mute byte to $4000 and clear counter. out.push(Instruction::new(LDA, AM::Immediate(0x30))); out.push(Instruction::new(STA, AM::Absolute(0x4000))); + out.push(Instruction::new(LDA, AM::Immediate(0))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_SFX_COUNTER))); + out.push(Instruction::new(JMP, AM::Label("__audio_sfx_done".into()))); + // Non-sentinel branch: write envelope byte to $4000, advance ptr. + out.push(Instruction::new(NOP, AM::Label("__audio_sfx_write".into()))); + out.push(Instruction::new(STA, AM::Absolute(0x4000))); + // Advance the 16-bit pointer (lo, hi) by 1. + out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_LO))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__audio_sfx_ptr_ok".into()), + )); + out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_HI))); + out.push(Instruction::new( + NOP, + AM::Label("__audio_sfx_ptr_ok".into()), + )); + out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into()))); - out.push(Instruction::new(NOP, AM::Label("__audio_tick_done".into()))); + // ── Music tick ── + // Bit 1 of ZP_MUSIC_STATE is "music is active". If clear, skip. + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE))); + out.push(Instruction::new(AND, AM::Immediate(0x02))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__audio_music_done".into()), + )); + // Active. Decrement the note counter; if nonzero after, bail. + out.push(Instruction::new(DEC, AM::ZeroPage(ZP_MUSIC_COUNTER))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__audio_music_done".into()), + )); + // Counter just hit zero — time to advance. Fall through to the + // "advance to next note" block below. The runtime calls this + // block from two places: end-of-note and start_music (which sets + // counter=0 then jumps here to trigger the first note). + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_advance".into()), + )); + // Read the next pitch byte. LDA sets Z based on the value so + // we can dispatch on it cheaply: + // pitch == 0 → rest (fall through to __rest) + // pitch == 0xFF → sentinel (BNE past rest, then CMP + BEQ) + // otherwise → pitched (fall through to __pitched) + out.push(Instruction::new(LDY, AM::Immediate(0))); + out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO))); + // Zero? → rest branch (mute pulse 2, skip period lookup). + out.push(Instruction::new( + BNE, + AM::LabelRelative("__audio_music_not_rest".into()), + )); + out.push(Instruction::new(LDA, AM::Immediate(0x30))); + out.push(Instruction::new(STA, AM::Absolute(0x4004))); + out.push(Instruction::new( + JMP, + AM::Label("__audio_music_load_dur".into()), + )); + // Not zero — check sentinel, otherwise it's a real note. + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_not_rest".into()), + )); + out.push(Instruction::new(CMP, AM::Immediate(0xFF))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__audio_music_eot".into()), + )); + // Fall through to the pitched branch — A still holds pitch. + out.push(Instruction::new( + JMP, + AM::Label("__audio_music_pitched".into()), + )); + // Pitched branch: A already holds pitch (1..=60). Index the + // period table and write $4006 (period lo) and $4007 (period + // hi + length counter). Each table entry is 2 bytes. + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_pitched".into()), + )); + // Rewrite envelope byte ($4004) from music state so we don't + // depend on pulse-2 length counter. Extract duty (bits 6-7) and + // volume (bits 2-5) from state, shift into position, OR with $30 + // (length-halt + constant volume), write $4004. + // + // Save pitch in X so we still have it for the period lookup. + out.push(Instruction::new(TAX, AM::Implied)); + // Build envelope byte. + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE))); + out.push(Instruction::new(AND, AM::Immediate(0xC0))); // keep duty bits + out.push(Instruction::new(STA, AM::ZeroPage(0x04))); // scratch + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE))); + out.push(Instruction::new(AND, AM::Immediate(0x3C))); // keep volume<<2 + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(ORA, AM::ZeroPage(0x04))); + out.push(Instruction::new(ORA, AM::Immediate(0x30))); + out.push(Instruction::new(STA, AM::Absolute(0x4004))); + + // Period lookup via a ZP pointer. X holds pitch (1..=60). + // + // 1. Set (ZP_SCRATCH = __period_table). + // 2. A = (pitch - 1) * 2 — byte offset in the 2-byte-per-entry + // table. + // 3. Y = A. + // 4. LDA (ZP_SCRATCH),Y → period_lo → STA $4006. + // 5. INY; LDA (ZP_SCRATCH),Y → period_hi → STA $4007. + // + // `$02`/`$03` are the multiply/divide scratch slots but the NMI + // audio tick never calls mul/div, so they're free to reuse here. + // A proper `Absolute,Y` addressing mode with a symbolic label + // would save the pointer setup, but our asm layer doesn't have + // that yet and the extra 8 cycles per frame are negligible. + out.push(Instruction::new(LDA, AM::SymbolLo("__period_table".into()))); + out.push(Instruction::new(STA, AM::ZeroPage(0x02))); + out.push(Instruction::new(LDA, AM::SymbolHi("__period_table".into()))); + out.push(Instruction::new(STA, AM::ZeroPage(0x03))); + out.push(Instruction::new(TXA, AM::Implied)); + out.push(Instruction::new(SEC, AM::Implied)); + out.push(Instruction::new(SBC, AM::Immediate(1))); + out.push(Instruction::new(ASL, AM::Accumulator)); + out.push(Instruction::new(TAY, AM::Implied)); + out.push(Instruction::new(LDA, AM::IndirectY(0x02))); + out.push(Instruction::new(STA, AM::Absolute(0x4006))); + out.push(Instruction::new(INY, AM::Implied)); + out.push(Instruction::new(LDA, AM::IndirectY(0x02))); + // The period-table high byte already has the length-counter + // load bits baked in (see `gen_period_table`), so a raw store + // here retriggers the note. But retriggering every time the + // duration expires is fine — it's how trackers work. + out.push(Instruction::new(STA, AM::Absolute(0x4007))); + + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_load_dur".into()), + )); + // Advance pointer past the pitch byte we just consumed. + out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__audio_music_dur_hi_ok".into()), + )); + out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI))); + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_dur_hi_ok".into()), + )); + // Read duration through the advanced pointer and stash it in + // ZP_MUSIC_COUNTER. + out.push(Instruction::new(LDY, AM::Immediate(0))); + out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER))); + // Advance past the duration byte. + out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__audio_music_ptr2_ok".into()), + )); + out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI))); + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_ptr2_ok".into()), + )); + out.push(Instruction::new( + JMP, + AM::Label("__audio_music_done".into()), + )); + + // ── End-of-track branch ── + out.push(Instruction::new(NOP, AM::Label("__audio_music_eot".into()))); + // Check loop flag (bit 0 of ZP_MUSIC_STATE). If set, rewind ptr + // to base and re-enter the advance path. Otherwise stop. + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE))); + out.push(Instruction::new(AND, AM::Immediate(0x01))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__audio_music_stop".into()), + )); + // Looping: copy base pointer back into current pointer and + // re-enter the advance path. + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_LO))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_LO))); + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_HI))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_HI))); + out.push(Instruction::new( + JMP, + AM::Label("__audio_music_advance".into()), + )); + // Non-looping stop: mute pulse 2 and clear music state. + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_stop".into()), + )); + out.push(Instruction::new(LDA, AM::Immediate(0x30))); + out.push(Instruction::new(STA, AM::Absolute(0x4004))); + out.push(Instruction::new(LDA, AM::Immediate(0))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_STATE))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER))); + + out.push(Instruction::new( + NOP, + AM::Label("__audio_music_done".into()), + )); out.push(Instruction::implied(RTS)); out } +/// Generate the builtin period table that the music tick uses to +/// translate note indices into pulse-channel period values. The +/// table covers five octaves (C1–B5) for 60 entries, 2 bytes each. +/// +/// Entry 0 is `C1` (index 1 in user notes), entry 59 is `B5` (index +/// 60). Pitch 0 is the "rest" sentinel and is not present in the +/// table — the driver handles rests before indexing. +/// +/// The high byte of each entry is `((period >> 8) & 0x07) | 0x08`. +/// Setting bit 3 pre-loads the length counter to index 1 (254 frames) +/// so any note held beyond the envelope will still play out naturally +/// when the track later falls into a rest — without this, pulse 2 +/// would silence itself after ~4 frames on hardware. +#[must_use] +pub fn gen_period_table() -> Vec { + // NTSC CPU = 1.789773 MHz. Pulse channel frequency: + // f = CPU / (16 * (period + 1)) + // Solving for period given a target frequency f: + // period = CPU / (16 * f) - 1 + // + // We compute the 60 entries once at build time (here) using + // equal-tempered tuning anchored at A4 = 440 Hz. + const CPU: f64 = 1_789_773.0; + const A4_HZ: f64 = 440.0; + + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__period_table".into()))); + // Semitone offset from A4 for index `i` (0-based from C1). + // A4 is MIDI 69. C1 is MIDI 24. So semitones from A4 to C1 is + // -45 — our table starts at C1 so `offset(i) = i - 45`. + let mut bytes: Vec = Vec::with_capacity(120); + for i in 0i32..60 { + let semitone_offset = f64::from(i - 45); + let freq = A4_HZ * 2f64.powf(semitone_offset / 12.0); + let period_f = CPU / (16.0 * freq) - 1.0; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let period = period_f.round().clamp(0.0, 2047.0) as u16; + let lo = (period & 0xFF) as u8; + // High 3 bits of period + length counter load bits. + // 0x08 = length counter index 1 = 254 frames. + let hi = ((period >> 8) as u8 & 0x07) | 0x08; + bytes.push(lo); + bytes.push(hi); + } + out.push(Instruction::new(NOP, AM::Bytes(bytes))); + out +} + +/// Generate a labelled data block emitting `bytes` verbatim into the +/// ROM at the address the assembler places this block. Used by the +/// linker to splice compiled sfx and music blobs into the code +/// section so that `LDA #) -> Vec { + vec![ + Instruction::new(NOP, AM::Label(label.to_string())), + Instruction::new(NOP, AM::Bytes(bytes)), + ] +} + /// Generate 8 / 8 -> 8 software divide routine (restoring division). /// /// Input: A = dividend, zero-page $02 = divisor diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index aaa6148..283705d 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -164,6 +164,195 @@ fn multiply_routine_assembles() { ); } +// ── Audio driver tests ── + +#[test] +fn audio_tick_defines_required_labels() { + let tick = gen_audio_tick(); + // The IR codegen JSRs into `__audio_tick`; that's the entry. + let has_entry = tick + .iter() + .any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_tick")); + assert!(has_entry, "audio tick must define __audio_tick entry label"); + // The tick references `__period_table` via SymbolLo/SymbolHi — + // the period table itself is linked in separately. + let refs_period = tick.iter().any(|i| { + matches!(&i.mode, AM::SymbolLo(n) if n == "__period_table") + || matches!(&i.mode, AM::SymbolHi(n) if n == "__period_table") + }); + assert!( + refs_period, + "audio tick must reference the __period_table label" + ); +} + +#[test] +fn audio_tick_ends_with_rts() { + let tick = gen_audio_tick(); + assert_eq!( + tick.last().unwrap().opcode, + RTS, + "audio tick must return to caller" + ); +} + +#[test] +fn audio_tick_reads_sfx_envelope_via_indirect_y() { + // The sfx branch walks the envelope via (ZP_SFX_PTR_LO),Y with + // Y=0 — each NMI reads one byte through the pointer and writes + // it to $4000. Verify the indirect-indexed load is present. + let tick = gen_audio_tick(); + let has_load = tick + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO)); + assert!( + has_load, + "audio tick must read envelope via (ZP_SFX_PTR_LO),Y" + ); +} + +#[test] +fn audio_tick_writes_pulse1_envelope_register() { + // After reading the envelope byte the tick writes it to $4000. + let tick = gen_audio_tick(); + let has_store = tick + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000)); + assert!(has_store, "audio tick must write pulse-1 envelope to $4000"); +} + +#[test] +fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() { + // When a non-looping track hits the (0xFF, 0xFF) sentinel, the + // tick writes $30 to $4004 and clears ZP_MUSIC_STATE. We verify + // the mute path exists by checking both writes exist somewhere + // in the tick body. + let tick = gen_audio_tick(); + let has_mute = tick + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004)); + assert!(has_mute, "audio tick must mute pulse-2 on end-of-track"); + let has_state_clear = tick + .iter() + .any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_MUSIC_STATE)); + assert!( + has_state_clear, + "audio tick must clear ZP_MUSIC_STATE on stop" + ); +} + +#[test] +fn audio_tick_assembles_without_error() { + // Splice the period table into the same assembly pass so the + // tick's SymbolLo/SymbolHi references resolve. The tick also + // uses label-relative branches internally which need to fit + // within ±127 bytes — if the body grows past that the branches + // will panic at assemble time. + let mut combined = gen_audio_tick(); + combined.extend(gen_period_table()); + let result = asm::assemble(&combined, 0xC000); + assert!( + !result.bytes.is_empty(), + "audio tick + period table should assemble" + ); + assert!( + result.labels.contains_key("__audio_tick"), + "audio tick entry label should be exported" + ); + assert!( + result.labels.contains_key("__period_table"), + "period table label should be exported" + ); +} + +#[test] +fn period_table_has_60_entries_of_2_bytes() { + // The table covers C1..B5 inclusive = 60 semitones, 2 bytes + // each for period_lo and period_hi. Total = 120 data bytes + // plus the leading label pseudo-instruction. + let table = gen_period_table(); + // Count the raw bytes in the single `Bytes` block. + let total: usize = table + .iter() + .filter_map(|i| match &i.mode { + AM::Bytes(v) => Some(v.len()), + _ => None, + }) + .sum(); + assert_eq!(total, 120, "period table should be 60 entries × 2 bytes"); +} + +#[test] +fn period_table_high_bytes_include_length_counter_bit() { + // Every period_hi byte must have bit 3 set ($08) so the length + // counter holds the note indefinitely. Without that bit, pulse + // 2 would silence after a few frames. + let table = gen_period_table(); + let bytes: Vec = table + .iter() + .filter_map(|i| match &i.mode { + AM::Bytes(v) => Some(v.clone()), + _ => None, + }) + .flatten() + .collect(); + for (i, chunk) in bytes.chunks(2).enumerate() { + let hi = chunk[1]; + assert!( + hi & 0x08 != 0, + "period table entry {i} high byte ${hi:02X} missing length-counter bit" + ); + } +} + +#[test] +fn period_table_a4_matches_440hz() { + // Entry for A4 should produce ~253 period. Sanity check the + // rounding: CPU/(16*440)-1 ≈ 253.12. + let table = gen_period_table(); + let bytes: Vec = table + .iter() + .filter_map(|i| match &i.mode { + AM::Bytes(v) => Some(v.clone()), + _ => None, + }) + .flatten() + .collect(); + // A4 is semitone 69 in MIDI. C1 is MIDI 24 (entry 0 in the + // table). A4 = entry 69 - 24 = 45. Each entry is 2 bytes. + let lo = bytes[45 * 2]; + let hi = bytes[45 * 2 + 1] & 0x07; // strip length-counter bit + let period = u16::from_le_bytes([lo, hi]); + // Expect period ≈ 253 (±1 for rounding). + assert!( + (252..=254).contains(&period), + "A4 period {period} should be ~253" + ); +} + +#[test] +fn gen_data_block_emits_label_and_bytes() { + let block = gen_data_block("__sfx_test", vec![0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!(block.len(), 2); + assert!(matches!(&block[0].mode, AM::Label(n) if n == "__sfx_test")); + match &block[1].mode { + AM::Bytes(v) => assert_eq!(v, &[0xDE, 0xAD, 0xBE, 0xEF]), + other => panic!("expected Bytes, got {other:?}"), + } +} + +#[test] +fn data_block_assembles_verbatim() { + // A labelled data block must emit exactly the payload bytes + // (no opcode prefix) and register the label at the payload's + // address. Verifies the `NOP+Bytes` pseudo doesn't accidentally + // get wrapped with an instruction byte. + let block = gen_data_block("__test", vec![0x11, 0x22, 0x33]); + let result = asm::assemble(&block, 0x8000); + assert_eq!(result.bytes, vec![0x11, 0x22, 0x33]); + assert_eq!(result.labels.get("__test").copied(), Some(0x8000)); +} + #[test] fn divide_routine_assembles() { let div = gen_divide(); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index bd6c10e..2cb3613 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -33,13 +33,17 @@ fn compile(source: &str) -> Vec { let sprites = assets::resolve_sprites(&program, Path::new(".")) .expect("sprite resolution should succeed"); + let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed"); + let music = assets::resolve_music(&program).expect("music resolution should succeed"); - let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program).with_sprites(&sprites); + let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); let mut instructions = codegen.generate(&ir_program); nescript::codegen::peephole::optimize(&mut instructions); let linker = Linker::new(program.game.mirroring); - linker.link_with_assets(&instructions, &sprites) + linker.link_with_all_assets(&instructions, &sprites, &sfx, &music) } // ── M1 Tests ── @@ -608,10 +612,10 @@ fn program_with_u16_arithmetic_and_compare() { #[test] fn program_with_audio_driver() { - // Exercises the minimal audio driver: play, start_music, - // stop_music all lowering into APU register writes plus the - // NMI audio tick splice. The linker must include the driver - // body and wire up the JSR from NMI. + // Exercises the audio driver end-to-end with builtin sfx/music + // names: play, start_music, stop_music all lower into the + // data-driven driver, the linker splices the tick/period-table/ + // data blobs, and the resulting ROM is valid iNES. let source = r#" game "Audio" { mapper: NROM } on frame { @@ -625,6 +629,175 @@ fn program_with_audio_driver() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn program_with_user_declared_sfx_and_music() { + // Full user-declared audio pipeline: `sfx` and `music` blocks, + // references via `play`/`start_music`, full ROM emission. The + // resolved envelope and note-stream bytes should land in PRG + // under stable labels so the IR codegen's SymbolLo/SymbolHi + // references resolve. + let source = r#" + game "Audio Assets" { mapper: NROM } + + sfx Zap { + duty: 2 + pitch: [0x20, 0x22, 0x24, 0x26, 0x28, 0x2A] + volume: [15, 13, 11, 9, 6, 3] + } + + music Loop { + duty: 2 + volume: 10 + repeat: true + notes: [37, 8, 41, 8, 44, 8, 49, 8] + } + + var t: u8 = 0 + + on frame { + t += 1 + if t == 30 { play Zap } + if t == 60 { + t = 0 + start_music Loop + } + } + start Main + "#; + let rom_data = compile(source); + let info = rom::validate_ines(&rom_data).expect("should be valid iNES"); + assert_eq!(info.mapper, 0); + + // Verify the user-declared envelope appears in PRG. The + // resolver encodes `Zap` as + // duty << 6 | 0x30 | volume + // per frame, terminated by a zero sentinel. + let prg = &rom_data[16..16 + 16384]; + let env = |v: u8| (2u8 << 6) | 0x30u8 | v; + let zap_env: [u8; 7] = [env(15), env(13), env(11), env(9), env(6), env(3), 0x00]; + assert!( + prg.windows(zap_env.len()).any(|w| w == zap_env), + "Zap envelope bytes should be in PRG ROM" + ); + + // Verify the music stream is in PRG: (37, 8, 41, 8, 44, 8, 49, 8, 0xFF, 0xFF) + let loop_stream: [u8; 10] = [37, 8, 41, 8, 44, 8, 49, 8, 0xFF, 0xFF]; + assert!( + prg.windows(loop_stream.len()).any(|w| w == loop_stream), + "Loop music note stream should be in PRG ROM" + ); +} + +#[test] +fn program_without_audio_has_no_audio_driver_in_prg() { + // Programs that never touch audio should pay zero ROM cost: + // no period table, no driver body, no data blobs. We verify + // indirectly by checking that the `__audio_tick` entry point + // wouldn't have anything to JSR to (because the NMI splice + // is gated on the `__audio_used` marker which never exists). + // + // The cheapest observable signal: a period-table fingerprint. + // The period table always starts with a distinct 2-byte + // sequence that appears at C1's period; if we don't see it in + // PRG, the audio subsystem wasn't linked in. + let source = r#" + game "Silent" { mapper: NROM } + var x: u8 = 0 + on frame { x += 1 } + start Main + "#; + let rom_data = compile(source); + // Pull the period table for C1 and make sure it's NOT in PRG. + // C1 ≈ 32.7 Hz → period ≈ 3421 → but that's too big for 11 + // bits, so it clamps. Instead, use the distinctive combined + // LDA #imm / LDA #imm pattern from the audio tick itself that + // would only appear if the driver body was linked in. + // + // A robust fingerprint: the `JSR __audio_tick` opcode byte + // ($20) followed by any 2 bytes only appears in the NMI + // handler when audio was used. We test the absence of the + // label instead via an indirect method: count the total + // number of STA $4004 writes (pulse-2 register). When audio + // is unused, there should be none. When audio is used, there + // would be several in the driver. + let prg = &rom_data[16..16 + 16384]; + // `STA $4006` ($8D $06 $40) is written exclusively by the + // music tick's period-lookup path. The init code pre-silences + // $4004 but never touches $4006, so its presence is a reliable + // "the audio driver was linked in" signal. + let pattern: [u8; 3] = [0x8D, 0x06, 0x40]; + let count = prg.windows(pattern.len()).filter(|w| *w == pattern).count(); + assert_eq!( + count, 0, + "silent program should not contain the music tick's $4006 write" + ); +} + +#[test] +fn unknown_sfx_name_is_a_hard_error() { + // The analyzer must reject `play NoSuchSfx` (neither a user + // decl nor a builtin) with E0505. Regression test for the + // old behavior, which silently accepted any name. + let source = r#" + game "T" { mapper: NROM } + on frame { play NoSuchSfx } + start Main + "#; + let (program, _) = nescript::parser::parse(source); + let analysis = analyzer::analyze(&program.unwrap()); + assert!( + analysis + .diagnostics + .iter() + .any(nescript::errors::Diagnostic::is_error), + "unknown sfx should produce an error" + ); +} + +#[test] +fn audio_pipeline_drops_period_table_cost_when_unused() { + // Regression test for the "no-cost elision" invariant: a + // program with no audio statements should produce a ROM + // smaller than one that uses audio. The exact byte count + // varies with codegen changes, so we test the *ordering* of + // sizes: a silent program < an audio program. + let silent = compile( + r#" + game "Silent" { mapper: NROM } + var x: u8 = 0 + on frame { x += 1 } + start Main + "#, + ); + // Both ROMs are the same file size (16 header + 16 KB PRG + 8 + // KB CHR = 24592), but the silent program's PRG fills with + // $FF padding past the code; an audio program's PRG has the + // driver and tables eating into that padding space. So we + // count $FF bytes in PRG: the silent version must have more. + let audio = compile( + r#" + game "Audio" { mapper: NROM } + on frame { play coin } + start Main + "#, + ); + let silent_prg = &silent[16..16 + 16384]; + let audio_prg = &audio[16..16 + 16384]; + // Count padding bytes ($FF = PRG fill) in each ROM. Using a + // raw filter().count() is clippy-noisy ("naive_bytecount"), + // but pulling in the `bytecount` crate for a one-line test + // helper isn't worth it — the test runs once per build. + #[allow(clippy::naive_bytecount)] + let silent_ff = silent_prg.iter().filter(|&&b| b == 0xFF).count(); + #[allow(clippy::naive_bytecount)] + let audio_ff = audio_prg.iter().filter(|&&b| b == 0xFF).count(); + assert!( + silent_ff > audio_ff, + "silent program should have more $FF padding than an audio program \ + (silent={silent_ff}, audio={audio_ff})" + ); +} + // ── M3 Tests ── #[test] @@ -689,13 +862,17 @@ fn compile_with_mapper(source: &str) -> Vec { let sprites = assets::resolve_sprites(&program, Path::new(".")) .expect("sprite resolution should succeed"); + let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed"); + let music = assets::resolve_music(&program).expect("music resolution should succeed"); - let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program).with_sprites(&sprites); + let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); let mut instructions = codegen.generate(&ir_program); nescript::codegen::peephole::optimize(&mut instructions); let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper); - linker.link_with_assets(&instructions, &sprites) + linker.link_with_all_assets(&instructions, &sprites, &sfx, &music) } #[test]