mirror of
https://github.com/imjasonh/nescript
synced 2026-07-19 07:05:58 +00:00
audio: triangle and noise sfx channels
Adds `channel: triangle` / `channel: noise` to the `sfx` declaration form. The existing pulse-1 / pulse-2 driver is unchanged (and is still byte-identical for programs that don't use the new channels) — when a program declares a triangle or noise sfx the runtime splices in an additional per-channel slot that writes to $4008- $400B (triangle) or $400C-$400F (noise) on play. Includes a new `examples/noise_triangle_sfx.ne` demo with committed golden PNG + audio hash. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
parent
8610aecdac
commit
201664ea04
18 changed files with 1116 additions and 52 deletions
|
|
@ -86,6 +86,7 @@ start Main
|
|||
| [`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) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
|
||||
| [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks |
|
||||
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
|
||||
|
||||
## Compiler Commands
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply |
|
||||
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
|
||||
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
|
||||
| `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. |
|
||||
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
|
||||
## Emulator Controls
|
||||
|
|
|
|||
70
examples/noise_triangle_sfx.ne
Normal file
70
examples/noise_triangle_sfx.ne
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Noise / Triangle SFX Demo
|
||||
//
|
||||
// Showcases the newer `channel:` property on `sfx` blocks. The audio
|
||||
// driver's per-frame tick gains a noise channel (writes to $400C) and
|
||||
// a triangle channel (writes to $4008) whenever the program declares
|
||||
// at least one sfx targeting those channels. Programs that stick to
|
||||
// pulse 1 still emit the exact same driver code as before.
|
||||
//
|
||||
// Every 30 frames we trigger one of the two effects and move a small
|
||||
// smiley back and forth so it's obvious the program is still running.
|
||||
// The emulator harness sees the registers get poked and hashes the
|
||||
// APU output, so the golden locks in both the pixels *and* the sound.
|
||||
//
|
||||
// Build: cargo run -- build examples/noise_triangle_sfx.ne
|
||||
// Output: examples/noise_triangle_sfx.nes
|
||||
|
||||
game "Noise Triangle SFX" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
// A short, sharp noise burst — perfect for explosions / footsteps.
|
||||
// `pitch: 4` indexes the APU's 16-entry noise period table; lower
|
||||
// values are higher-pitched. `volume` is a per-frame amplitude ramp,
|
||||
// exactly like a pulse sfx.
|
||||
sfx Crash {
|
||||
channel: noise
|
||||
pitch: 4
|
||||
volume: [15, 13, 11, 9, 7, 5, 3, 1]
|
||||
}
|
||||
|
||||
// A sustained triangle "bass" note. Triangle has no volume register,
|
||||
// so `volume:` entries are just "hold" flags — nonzero means sustain,
|
||||
// zero means silence. The numeric value doesn't matter.
|
||||
// `pitch: 60` picks a period-table entry; triangle shares the pulse
|
||||
// period table, and 60 is the lowest note in it (C1).
|
||||
sfx Bass {
|
||||
channel: triangle
|
||||
pitch: 60
|
||||
volume: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||
}
|
||||
|
||||
var px: u8 = 120
|
||||
var timer: u8 = 0
|
||||
var bounce: u8 = 0
|
||||
|
||||
on frame {
|
||||
// Bounce the smiley between x=110 and x=140 so frame 180 is
|
||||
// not a still frame — the golden image diff would otherwise
|
||||
// miss the "program is running" signal.
|
||||
timer += 1
|
||||
if bounce == 0 {
|
||||
px += 1
|
||||
if px == 140 { bounce = 1 }
|
||||
} else {
|
||||
px -= 1
|
||||
if px == 110 { bounce = 0 }
|
||||
}
|
||||
|
||||
// Cycle through the two sfx every 30 frames so each channel
|
||||
// retriggers at least twice inside the 180-frame capture window.
|
||||
if timer == 30 { play Crash }
|
||||
if timer == 60 {
|
||||
timer = 0
|
||||
play Bass
|
||||
}
|
||||
|
||||
draw Smiley at: (px, 120)
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
examples/noise_triangle_sfx.nes
Normal file
BIN
examples/noise_triangle_sfx.nes
Normal file
Binary file not shown.
|
|
@ -311,6 +311,72 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate sfx declarations' channel-specific constraints.
|
||||
// Triangle has no volume register so `volume:` is treated as
|
||||
// a per-frame hold flag (nonzero = sustain, zero = release);
|
||||
// duty bits are also meaningless for triangle and noise.
|
||||
// Pulse 2 is rejected outright on sfx declarations because
|
||||
// the pulse-2 channel is owned by the music driver.
|
||||
for decl in &program.sfx {
|
||||
match decl.channel {
|
||||
Channel::Pulse1 => {}
|
||||
Channel::Pulse2 => {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"sfx '{}' targets pulse2, which is reserved for the music driver",
|
||||
decl.name
|
||||
),
|
||||
decl.span,
|
||||
));
|
||||
}
|
||||
Channel::Triangle => {
|
||||
// Parser default `duty` is 2, so only flag
|
||||
// explicit non-default values. This keeps the
|
||||
// common `channel: triangle, pitch: N, volume: [..]`
|
||||
// form warning-free.
|
||||
if decl.duty != 2 {
|
||||
self.diagnostics.push(Diagnostic::warning(
|
||||
ErrorCode::W0107,
|
||||
format!(
|
||||
"sfx '{}' targets triangle; 'duty' has no effect on this channel",
|
||||
decl.name
|
||||
),
|
||||
decl.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
Channel::Noise => {
|
||||
if decl.duty != 2 {
|
||||
self.diagnostics.push(Diagnostic::warning(
|
||||
ErrorCode::W0107,
|
||||
format!(
|
||||
"sfx '{}' targets noise; 'duty' has no effect on this channel",
|
||||
decl.name
|
||||
),
|
||||
decl.span,
|
||||
));
|
||||
}
|
||||
// Noise pitch is a 4-bit period-table index plus
|
||||
// an optional "mode" bit in position 7. Any other
|
||||
// bits set is a user mistake.
|
||||
for p in &decl.pitch {
|
||||
if *p & !0x8F != 0 {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"sfx '{}' noise pitch {:#04x} has bits outside the valid range (low 4 bits index + optional bit 7 mode)",
|
||||
decl.name, p
|
||||
),
|
||||
decl.span,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register functions as symbols
|
||||
for fun in &program.functions {
|
||||
self.register_fun(fun);
|
||||
|
|
|
|||
|
|
@ -997,6 +997,82 @@ fn analyze_rejects_unknown_sfx_name() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_noise_sfx() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Zap {
|
||||
channel: noise
|
||||
pitch: 5
|
||||
volume: [15, 10, 5]
|
||||
}
|
||||
on frame { play Zap }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_triangle_sfx() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Bass {
|
||||
channel: triangle
|
||||
pitch: 60
|
||||
volume: [1, 1, 1, 1, 1]
|
||||
}
|
||||
on frame { play Bass }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_pulse2_sfx() {
|
||||
// pulse 2 is reserved for the music driver; declaring an sfx
|
||||
// on it should be an error.
|
||||
let codes = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Nope {
|
||||
channel: pulse2
|
||||
pitch: 5
|
||||
volume: [8]
|
||||
}
|
||||
on frame { play Nope }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for pulse2 sfx, got {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_noise_sfx_with_out_of_range_pitch() {
|
||||
// Noise pitch is a 4-bit period index + optional bit 7 mode.
|
||||
// Setting bit 5 (0x20) is outside that envelope.
|
||||
let codes = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Bad {
|
||||
channel: noise
|
||||
pitch: 0x20
|
||||
volume: [8]
|
||||
}
|
||||
on frame { play Bad }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&ErrorCode::E0201),
|
||||
"expected E0201 for invalid noise pitch, got {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_builtin_music() {
|
||||
analyze_ok(
|
||||
|
|
|
|||
|
|
@ -33,21 +33,54 @@
|
|||
//! label `__music_<name>`, 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};
|
||||
use crate::parser::ast::{Channel, MusicDecl, MusicNote, Program, SfxDecl};
|
||||
|
||||
/// Compiled sfx data.
|
||||
///
|
||||
/// Holds both the compile-time *trigger constants* written by the
|
||||
/// `play` sequence (these depend on the destination channel) and
|
||||
/// the per-frame *envelope blob* walked by the runtime audio tick
|
||||
/// on every NMI. The envelope byte meaning also depends on the
|
||||
/// channel:
|
||||
///
|
||||
/// - Pulse 1 / Pulse 2: each byte is a complete `$4000` / `$4004`
|
||||
/// write (`DDlcvvvv` where `DD` = duty, `lc` = length-halt +
|
||||
/// constant volume, `vvvv` = volume 0-15). A trailing `0x00` is
|
||||
/// the mute sentinel.
|
||||
/// - Noise: each byte is a complete `$400C` write using the exact
|
||||
/// same `lcvvvv` encoding as pulse (the noise register has no
|
||||
/// duty bits and ignores the top two). Trailing `0x00` is again
|
||||
/// the mute sentinel.
|
||||
/// - Triangle: triangle has no volume register, so each envelope
|
||||
/// byte is instead a "linear counter reload" value for `$4008`.
|
||||
/// The runtime writes it back on every tick so held notes don't
|
||||
/// decay when the length counter underruns. The mute sentinel
|
||||
/// (`0x80` — linear counter = 0 with the control bit set) tells
|
||||
/// the runtime to silence the channel by writing `$80` to
|
||||
/// `$4008` one last time.
|
||||
#[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.
|
||||
/// Low byte of the trigger register written by `play`:
|
||||
/// - Pulse 1 / 2: `$4002` / `$4006` period low.
|
||||
/// - Triangle: `$400A` period low.
|
||||
/// - Noise: `$400E` period — low 4 bits select the period-table
|
||||
/// index; we also stash a mode bit in bit 7 but default to 0
|
||||
/// for tonal (non-metallic) noise.
|
||||
pub period_lo: u8,
|
||||
/// Pulse-1 length counter + period high byte, written to
|
||||
/// `$4003`. Triggers a new note on write.
|
||||
/// High byte of the trigger register written by `play`:
|
||||
/// - Pulse: `$4003` / `$4007` length-counter + period-high.
|
||||
/// - Triangle: `$400B` length-counter + period-high.
|
||||
/// - Noise: `$400F` length-counter load byte (pitch goes via
|
||||
/// `$400E`, so this is just the length-counter reload).
|
||||
pub period_hi: u8,
|
||||
/// Per-frame `$4000` envelope bytes terminated by `0x00`. Linked
|
||||
/// into PRG ROM as a labelled data block.
|
||||
/// Per-frame envelope bytes walked by the audio tick one byte
|
||||
/// per NMI. Terminated by a channel-specific mute sentinel
|
||||
/// (`0x00` for pulse/noise, `0x80` for triangle). Linked into
|
||||
/// PRG ROM as a labelled data block.
|
||||
pub envelope: Vec<u8>,
|
||||
/// APU channel this sfx drives.
|
||||
pub channel: Channel,
|
||||
}
|
||||
|
||||
/// Compiled music data.
|
||||
|
|
@ -271,12 +304,20 @@ pub fn resolve_music(program: &Program) -> Result<Vec<MusicData>, String> {
|
|||
///
|
||||
/// 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.
|
||||
/// for the whole envelope so the channel doesn't retrigger mid-run.
|
||||
/// Pitch variation across frames is ignored in this version.
|
||||
///
|
||||
/// The exact bytes emitted depend on the destination channel — see
|
||||
/// [`SfxData`] for the per-channel format.
|
||||
fn compile_sfx(decl: &SfxDecl) -> SfxData {
|
||||
match decl.channel {
|
||||
Channel::Pulse1 | Channel::Pulse2 => compile_pulse_sfx(decl),
|
||||
Channel::Triangle => compile_triangle_sfx(decl),
|
||||
Channel::Noise => compile_noise_sfx(decl),
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_pulse_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.
|
||||
|
|
@ -290,14 +331,92 @@ fn compile_sfx(decl: &SfxDecl) -> SfxData {
|
|||
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.
|
||||
// Zero sentinel — the audio tick sees this, mutes the channel,
|
||||
// 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,
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Triangle envelope: each byte is a linear-counter reload value
|
||||
/// written back to `$4008`. Nonzero means "continue holding the
|
||||
/// note"; the sentinel `0x80` (linear counter = 0 with control bit
|
||||
/// set to halt the length counter) tells the runtime to silence
|
||||
/// the channel and stop walking the blob.
|
||||
///
|
||||
/// We map each user `volume` value to the linear-counter reload
|
||||
/// value via `0x80 | 0x7F` = `0xFF` for the "hold" case — this
|
||||
/// gives the maximum sustain count of 127 per frame, which the
|
||||
/// runtime rewrites every tick anyway. Values of `0` in the user
|
||||
/// array collapse to the mute sentinel immediately.
|
||||
fn compile_triangle_sfx(decl: &SfxDecl) -> SfxData {
|
||||
// $400A = period low, $400B = length + period high.
|
||||
let period_lo = decl.pitch.first().copied().unwrap_or(0);
|
||||
// Bit 3 of $400B = length counter enable; we set it along with
|
||||
// period-high bits (always 0 at the user level).
|
||||
let period_hi: u8 = 0x08;
|
||||
let mut envelope = Vec::with_capacity(decl.volume.len() + 1);
|
||||
for &vol in &decl.volume {
|
||||
if vol == 0 {
|
||||
// Early release: user wrote a zero in the hold array.
|
||||
envelope.push(0x80);
|
||||
} else {
|
||||
// 0xFF = control bit set (halt length counter on 0) |
|
||||
// 0x7F reload value (~2.1 seconds of sustain). The
|
||||
// runtime rewrites this every tick so the channel
|
||||
// never underruns the linear counter.
|
||||
envelope.push(0xFF);
|
||||
}
|
||||
}
|
||||
// Sentinel: linear-counter control + 0 reload = silence.
|
||||
envelope.push(0x80);
|
||||
SfxData {
|
||||
name: decl.name.clone(),
|
||||
period_lo,
|
||||
period_hi,
|
||||
envelope,
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Noise envelope: each byte is a complete `$400C` write using the
|
||||
/// same `lcvvvv` encoding as the pulse channels (duty bits are
|
||||
/// unused by noise so we mask them out). The mute sentinel is
|
||||
/// `0x00`, same as the pulse channels — it resolves to "constant
|
||||
/// volume, volume = 0" which silences the channel.
|
||||
///
|
||||
/// The trigger byte (`period_lo`) is interpreted as a 4-bit index
|
||||
/// into the APU's internal 16-entry noise period table (`$400E`
|
||||
/// low nibble), plus an optional "mode" bit in position 7 that
|
||||
/// switches between the long and short feedback-shift-register
|
||||
/// patterns. We default to mode 0 (tonal).
|
||||
fn compile_noise_sfx(decl: &SfxDecl) -> SfxData {
|
||||
// $400E = mode + period index. The user's `pitch` scalar is
|
||||
// the low-nibble index 0-15. Mask to be safe.
|
||||
let period_lo = decl.pitch.first().copied().unwrap_or(0) & 0x8F;
|
||||
// $400F length counter load: same bit layout as pulse/triangle,
|
||||
// load-index 1 = 254 frames. The length counter keeps the
|
||||
// channel gated until our envelope sentinel mutes it.
|
||||
let period_hi: u8 = 0x08;
|
||||
let mut envelope = Vec::with_capacity(decl.volume.len() + 1);
|
||||
for &vol in &decl.volume {
|
||||
// $400C format: ..LC VVVV (top two bits unused).
|
||||
// We set length-halt + constant-volume just like pulse.
|
||||
let env = 0x30 | (vol & 0x0F);
|
||||
envelope.push(env);
|
||||
}
|
||||
envelope.push(0x00);
|
||||
SfxData {
|
||||
name: decl.name.clone(),
|
||||
period_lo,
|
||||
period_hi,
|
||||
envelope,
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,6 +564,7 @@ pub fn builtin_sfx(name: &str) -> Option<SfxDecl> {
|
|||
duty,
|
||||
pitch: vec![pitch_base; frames],
|
||||
volume,
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
})
|
||||
}
|
||||
|
|
@ -583,6 +703,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0x50, 0x50, 0x50, 0x50],
|
||||
volume: vec![15, 10, 5, 0],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
};
|
||||
let data = compile_sfx(&decl);
|
||||
|
|
@ -606,6 +727,7 @@ mod tests {
|
|||
duty: 0,
|
||||
pitch: vec![0x20],
|
||||
volume: vec![8],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
};
|
||||
let data = compile_sfx(&decl);
|
||||
|
|
@ -613,6 +735,68 @@ mod tests {
|
|||
assert_eq!(data.envelope[0], 0x38);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_sfx_noise_channel_strips_duty_and_pitches() {
|
||||
let decl = SfxDecl {
|
||||
name: "Zap".to_string(),
|
||||
duty: 3, // meaningless for noise; must not leak
|
||||
pitch: vec![0x05, 0x05, 0x05],
|
||||
volume: vec![15, 10, 5],
|
||||
channel: Channel::Noise,
|
||||
span: Span::dummy(),
|
||||
};
|
||||
let data = compile_sfx(&decl);
|
||||
assert_eq!(data.channel, Channel::Noise);
|
||||
// Trigger: period_lo = pitch & 0x8F.
|
||||
assert_eq!(data.period_lo, 0x05);
|
||||
// Envelope bytes: top two duty bits should be zero on noise.
|
||||
// 0x30 | 15 = 0x3F, 0x30 | 10 = 0x3A, 0x30 | 5 = 0x35, + sentinel.
|
||||
assert_eq!(data.envelope, vec![0x3F, 0x3A, 0x35, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_sfx_triangle_channel_uses_hold_sentinel() {
|
||||
let decl = SfxDecl {
|
||||
name: "Bass".to_string(),
|
||||
duty: 2,
|
||||
pitch: vec![60, 60],
|
||||
volume: vec![1, 0], // hold then release
|
||||
channel: Channel::Triangle,
|
||||
span: Span::dummy(),
|
||||
};
|
||||
let data = compile_sfx(&decl);
|
||||
assert_eq!(data.channel, Channel::Triangle);
|
||||
// Nonzero hold becomes 0xFF; zero release becomes 0x80.
|
||||
// Terminal mute sentinel is also 0x80.
|
||||
assert_eq!(data.envelope, vec![0xFF, 0x80, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sfx_data_channel_roundtrips_through_resolve() {
|
||||
let mut prog = empty_program();
|
||||
prog.sfx.push(SfxDecl {
|
||||
name: "Bang".to_string(),
|
||||
duty: 2,
|
||||
pitch: vec![3],
|
||||
volume: vec![15, 8],
|
||||
channel: Channel::Noise,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
prog.sfx.push(SfxDecl {
|
||||
name: "Drone".to_string(),
|
||||
duty: 2,
|
||||
pitch: vec![60],
|
||||
volume: vec![1, 1, 1],
|
||||
channel: Channel::Triangle,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
let resolved = resolve_sfx(&prog).unwrap();
|
||||
assert_eq!(resolved.len(), 2);
|
||||
// The channel field survives the resolve/compile passes.
|
||||
assert_eq!(resolved[0].channel, Channel::Noise);
|
||||
assert_eq!(resolved[1].channel, Channel::Triangle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_music_header_encodes_loop_duty_volume() {
|
||||
let decl = MusicDecl {
|
||||
|
|
@ -658,6 +842,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0x50],
|
||||
volume: vec![8],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
};
|
||||
let data = compile_sfx(&decl);
|
||||
|
|
@ -682,6 +867,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0x40, 0x40],
|
||||
volume: vec![15, 8],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
let resolved = resolve_sfx(&prog).unwrap();
|
||||
|
|
@ -723,6 +909,7 @@ mod tests {
|
|||
duty: 0,
|
||||
pitch: vec![0xAA],
|
||||
volume: vec![1],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
prog.states.push(StateDecl {
|
||||
|
|
@ -750,6 +937,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0x40],
|
||||
volume: vec![8],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
prog.sfx.push(SfxDecl {
|
||||
|
|
@ -757,6 +945,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0x40],
|
||||
volume: vec![8],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
assert!(resolve_sfx(&prog).is_err());
|
||||
|
|
@ -770,6 +959,7 @@ mod tests {
|
|||
duty: 2,
|
||||
pitch: vec![0; SFX_MAX_FRAMES + 1],
|
||||
volume: vec![8; SFX_MAX_FRAMES + 1],
|
||||
channel: Channel::Pulse1,
|
||||
span: Span::dummy(),
|
||||
});
|
||||
assert!(resolve_sfx(&prog).is_err());
|
||||
|
|
|
|||
|
|
@ -28,11 +28,14 @@ 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::parser::ast::Channel;
|
||||
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_PENDING_BG_ATTRS_HI, ZP_PENDING_BG_ATTRS_LO,
|
||||
ZP_PENDING_BG_TILES_HI, ZP_PENDING_BG_TILES_LO, ZP_PENDING_PALETTE_HI, ZP_PENDING_PALETTE_LO,
|
||||
ZP_PPU_UPDATE_FLAGS, ZP_SFX_COUNTER, ZP_SFX_PTR_HI, ZP_SFX_PTR_LO,
|
||||
AUDIO_NOISE_COUNTER, AUDIO_NOISE_PTR_HI, AUDIO_NOISE_PTR_LO, AUDIO_TRIANGLE_COUNTER,
|
||||
AUDIO_TRIANGLE_PTR_HI, AUDIO_TRIANGLE_PTR_LO, 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_PENDING_BG_ATTRS_HI, ZP_PENDING_BG_ATTRS_LO, ZP_PENDING_BG_TILES_HI, ZP_PENDING_BG_TILES_LO,
|
||||
ZP_PENDING_PALETTE_HI, ZP_PENDING_PALETTE_LO, ZP_PPU_UPDATE_FLAGS, ZP_SFX_COUNTER,
|
||||
ZP_SFX_PTR_HI, ZP_SFX_PTR_LO,
|
||||
};
|
||||
|
||||
/// Base zero-page address for IR temp slots.
|
||||
|
|
@ -79,11 +82,15 @@ pub struct IrCodeGen<'a> {
|
|||
use_counts: HashMap<IrTemp, u32>,
|
||||
/// Sprite name to tile index mapping.
|
||||
sprite_tiles: HashMap<String, u8>,
|
||||
/// 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<String, (u8, u8, String)>,
|
||||
/// Sfx name to `(period_lo, period_hi, envelope_label, channel)`.
|
||||
/// Populated by `with_audio` from the resolved [`SfxData`] list.
|
||||
/// `play Name` consults this to pick:
|
||||
/// - the right trigger register pair (pulse 1 = $4002/$4003,
|
||||
/// triangle = $400A/$400B, noise = $400E/$400F);
|
||||
/// - the right per-channel envelope pointer slot (pulse 1 uses
|
||||
/// zero-page `ZP_SFX_PTR`, triangle/noise live in main RAM
|
||||
/// at the `AUDIO_*_PTR_*` addresses).
|
||||
sfx_info: HashMap<String, (u8, u8, String, Channel)>,
|
||||
/// 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`.
|
||||
|
|
@ -103,6 +110,14 @@ pub struct IrCodeGen<'a> {
|
|||
/// marker label at most once per program so the linker can
|
||||
/// decide whether to splice the audio tick into NMI.
|
||||
audio_used: bool,
|
||||
/// Set to true the first time a `play` op targets a noise sfx.
|
||||
/// Emits the `__noise_used` marker label so the linker knows
|
||||
/// to append the noise channel block to `gen_audio_tick` and
|
||||
/// reserve the main-RAM state slots.
|
||||
noise_used: bool,
|
||||
/// Same as `noise_used`, but for triangle sfx. Drives the
|
||||
/// `__triangle_used` marker label.
|
||||
triangle_used: bool,
|
||||
/// Set to true the first time we emit any PPU update op
|
||||
/// (`set_palette` / `load_background`). The linker uses the
|
||||
/// resulting `__ppu_update_used` marker label to decide whether
|
||||
|
|
@ -211,6 +226,8 @@ impl<'a> IrCodeGen<'a> {
|
|||
in_frame_handler: false,
|
||||
debug_mode: false,
|
||||
audio_used: false,
|
||||
noise_used: false,
|
||||
triangle_used: false,
|
||||
ppu_update_used: false,
|
||||
source_locs: Vec::new(),
|
||||
next_source_loc: 0,
|
||||
|
|
@ -270,8 +287,10 @@ impl<'a> IrCodeGen<'a> {
|
|||
#[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()));
|
||||
self.sfx_info.insert(
|
||||
s.name.clone(),
|
||||
(s.period_lo, s.period_hi, s.label(), s.channel),
|
||||
);
|
||||
}
|
||||
for m in music {
|
||||
self.music_info
|
||||
|
|
@ -1173,11 +1192,27 @@ impl<'a> IrCodeGen<'a> {
|
|||
/// 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.
|
||||
/// initial period to the destination channel's trigger
|
||||
/// registers, sets the per-channel active counter, and loads
|
||||
/// the envelope pointer. The per-frame envelope walk happens
|
||||
/// in `runtime::gen_audio_tick`.
|
||||
///
|
||||
/// The exact register set depends on the sfx's [`Channel`]:
|
||||
/// - **Pulse 1**: `$4002/$4003` trigger, `ZP_SFX_PTR_*` envelope
|
||||
/// pointer, `$4000` runtime volume writes. This is the
|
||||
/// original path and is byte-identical to the pre-channels
|
||||
/// codegen.
|
||||
/// - **Triangle**: `$400A/$400B` trigger, `$4015 |= $04` to
|
||||
/// enable the channel's length counter, main-RAM envelope
|
||||
/// pointer at [`AUDIO_TRIANGLE_PTR_*`], runtime writes to
|
||||
/// `$4008` (linear counter reload).
|
||||
/// - **Noise**: `$400E/$400F` trigger, `$4015 |= $08` to
|
||||
/// enable, main-RAM pointer at [`AUDIO_NOISE_PTR_*`],
|
||||
/// runtime writes to `$400C` (noise volume).
|
||||
///
|
||||
/// Programs that never reference triangle/noise sfx only emit
|
||||
/// the pulse-1 path here, so their generated code — and their
|
||||
/// ROM bytes — are unchanged from before the channel feature.
|
||||
///
|
||||
/// If `name` is not a declared sfx or a recognized builtin, we
|
||||
/// emit a silent `play` (period 0, zero-length envelope) rather
|
||||
|
|
@ -1185,7 +1220,7 @@ impl<'a> IrCodeGen<'a> {
|
|||
/// diagnostic for the unknown name.
|
||||
fn gen_play_sfx(&mut self, name: &str) {
|
||||
self.emit_audio_marker();
|
||||
let Some((period_lo, period_hi, label)) = self.sfx_info.get(name).cloned() else {
|
||||
let Some((period_lo, period_hi, label, channel)) = 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
|
||||
|
|
@ -1193,6 +1228,18 @@ impl<'a> IrCodeGen<'a> {
|
|||
// any other pulse-1 state.
|
||||
return;
|
||||
};
|
||||
match channel {
|
||||
Channel::Pulse1 | Channel::Pulse2 => self.emit_play_pulse(period_lo, period_hi, &label),
|
||||
Channel::Triangle => self.emit_play_triangle(period_lo, period_hi, &label),
|
||||
Channel::Noise => self.emit_play_noise(period_lo, period_hi, &label),
|
||||
}
|
||||
}
|
||||
|
||||
/// Original pulse-1 `play` sequence — unchanged from before the
|
||||
/// channel feature. Kept as its own helper so the channel
|
||||
/// dispatch above reads cleanly and the byte layout is trivial
|
||||
/// to eyeball against the old code.
|
||||
fn emit_play_pulse(&mut self, period_lo: u8, period_hi: u8, label: &str) {
|
||||
// $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).
|
||||
|
|
@ -1207,9 +1254,9 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::Absolute(0x4003));
|
||||
// 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(LDA, AM::SymbolLo(label.to_string()));
|
||||
self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_LO));
|
||||
self.emit(LDA, AM::SymbolHi(label));
|
||||
self.emit(LDA, AM::SymbolHi(label.to_string()));
|
||||
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;
|
||||
|
|
@ -1218,6 +1265,67 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::ZeroPage(ZP_SFX_COUNTER));
|
||||
}
|
||||
|
||||
/// Triangle channel trigger sequence. Writes period bytes to
|
||||
/// `$400A/$400B`, enables the triangle channel in `$4015`, and
|
||||
/// seeds the main-RAM envelope pointer + active counter so the
|
||||
/// tick's triangle block starts walking the blob next frame.
|
||||
fn emit_play_triangle(&mut self, period_lo: u8, period_hi: u8, label: &str) {
|
||||
self.emit_triangle_marker();
|
||||
// $4008: linear counter control + reload. Set $FF (control
|
||||
// bit + max reload) so the counter starts from a known
|
||||
// non-muted state; the tick rewrites this every frame.
|
||||
self.emit(LDA, AM::Immediate(0xFF));
|
||||
self.emit(STA, AM::Absolute(0x4008));
|
||||
// $400A / $400B: period lo / length + period hi.
|
||||
self.emit(LDA, AM::Immediate(period_lo));
|
||||
self.emit(STA, AM::Absolute(0x400A));
|
||||
self.emit(LDA, AM::Immediate(period_hi));
|
||||
self.emit(STA, AM::Absolute(0x400B));
|
||||
// Enable pulse-1 + pulse-2 + triangle in the APU status
|
||||
// register. We don't bother reading $4015 first — overwriting
|
||||
// with $07 keeps all currently-used channels enabled.
|
||||
self.emit(LDA, AM::Immediate(0x07));
|
||||
self.emit(STA, AM::Absolute(0x4015));
|
||||
// Main-RAM envelope pointer.
|
||||
self.emit(LDA, AM::SymbolLo(label.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_TRIANGLE_PTR_LO));
|
||||
self.emit(LDA, AM::SymbolHi(label.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_TRIANGLE_PTR_HI));
|
||||
// Counter nonzero = channel active.
|
||||
self.emit(LDA, AM::Immediate(0xFF));
|
||||
self.emit(STA, AM::Absolute(AUDIO_TRIANGLE_COUNTER));
|
||||
}
|
||||
|
||||
/// Noise channel trigger sequence. Writes mode+period index to
|
||||
/// `$400E`, length-counter load to `$400F`, enables the noise
|
||||
/// channel in `$4015`, and seeds the main-RAM envelope pointer.
|
||||
fn emit_play_noise(&mut self, period_lo: u8, period_hi: u8, label: &str) {
|
||||
self.emit_noise_marker();
|
||||
// $400C: volume register. Start at constant-volume 0 (muted)
|
||||
// so the very first envelope byte (written by the tick one
|
||||
// NMI later) audibly triggers the note without a stale
|
||||
// value from a previous sfx leaking through.
|
||||
self.emit(LDA, AM::Immediate(0x30));
|
||||
self.emit(STA, AM::Absolute(0x400C));
|
||||
// $400E: mode (bit 7) + period-table index (low 4 bits).
|
||||
self.emit(LDA, AM::Immediate(period_lo));
|
||||
self.emit(STA, AM::Absolute(0x400E));
|
||||
// $400F: length counter load.
|
||||
self.emit(LDA, AM::Immediate(period_hi));
|
||||
self.emit(STA, AM::Absolute(0x400F));
|
||||
// Enable pulse-1 + pulse-2 + noise channels.
|
||||
self.emit(LDA, AM::Immediate(0x0B));
|
||||
self.emit(STA, AM::Absolute(0x4015));
|
||||
// Main-RAM envelope pointer.
|
||||
self.emit(LDA, AM::SymbolLo(label.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_NOISE_PTR_LO));
|
||||
self.emit(LDA, AM::SymbolHi(label.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_NOISE_PTR_HI));
|
||||
// Counter nonzero = channel active.
|
||||
self.emit(LDA, AM::Immediate(0xFF));
|
||||
self.emit(STA, AM::Absolute(AUDIO_NOISE_COUNTER));
|
||||
}
|
||||
|
||||
/// Emit the `start_music Name` sequence.
|
||||
///
|
||||
/// Stores the track's header byte into `ZP_MUSIC_STATE` (with
|
||||
|
|
@ -1273,6 +1381,25 @@ impl<'a> IrCodeGen<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Emit the `__noise_used` marker label at most once per program.
|
||||
/// The linker scans for this label to decide whether to append
|
||||
/// the noise tick block to the audio tick.
|
||||
fn emit_noise_marker(&mut self) {
|
||||
if !self.noise_used {
|
||||
self.emit_label("__noise_used");
|
||||
self.noise_used = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the `__triangle_used` marker label at most once per
|
||||
/// program. Drives the triangle tick block in the audio driver.
|
||||
fn emit_triangle_marker(&mut self) {
|
||||
if !self.triangle_used {
|
||||
self.emit_label("__triangle_used");
|
||||
self.triangle_used = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the `set_palette Name` sequence.
|
||||
///
|
||||
/// Writes the palette's ROM label pointer into the runtime
|
||||
|
|
@ -2428,6 +2555,86 @@ mod more_tests {
|
|||
insts.iter().any(|i| i.opcode == opcode && i.mode == *mode)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_play_noise_sfx_writes_400e_and_emits_noise_marker() {
|
||||
// A noise sfx `play` should:
|
||||
// 1. Write trigger bytes to $400E / $400F (noise
|
||||
// period + length).
|
||||
// 2. Enable the noise channel in the APU status
|
||||
// register at $4015.
|
||||
// 3. Emit the `__noise_used` marker so the linker
|
||||
// appends the noise block to the audio tick.
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Zap {
|
||||
channel: noise
|
||||
pitch: 5
|
||||
volume: [15, 8, 2]
|
||||
}
|
||||
on frame { play Zap }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x400E)),
|
||||
"noise play should write $400E (period)"
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x400F)),
|
||||
"noise play should write $400F (length counter)"
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x4015)),
|
||||
"noise play should write APU status ($4015)"
|
||||
);
|
||||
let has_marker = insts
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__noise_used"));
|
||||
assert!(has_marker, "noise play should emit __noise_used marker");
|
||||
// And the pulse1 sfx path must not leak through — no
|
||||
// $4002 write from this program.
|
||||
assert!(
|
||||
!has_inst(&insts, STA, &AM::Absolute(0x4002)),
|
||||
"noise play should not touch pulse-1 trigger registers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_play_triangle_sfx_writes_400a_and_emits_triangle_marker() {
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Bass {
|
||||
channel: triangle
|
||||
pitch: 60
|
||||
volume: [1, 1, 1]
|
||||
}
|
||||
on frame { play Bass }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x400A)),
|
||||
"triangle play should write $400A (period)"
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x400B)),
|
||||
"triangle play should write $400B (length counter)"
|
||||
);
|
||||
assert!(
|
||||
has_inst(&insts, STA, &AM::Absolute(0x4008)),
|
||||
"triangle play should write $4008 (linear counter)"
|
||||
);
|
||||
let has_marker = insts
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__triangle_used"));
|
||||
assert!(
|
||||
has_marker,
|
||||
"triangle play should emit __triangle_used marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_play_sfx_triggers_pulse1_and_loads_envelope_pointer() {
|
||||
// `play coin` must:
|
||||
|
|
|
|||
|
|
@ -418,8 +418,10 @@ impl Linker {
|
|||
// `__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");
|
||||
let has_noise = has_label(user_code, "__noise_used");
|
||||
let has_triangle = has_label(user_code, "__triangle_used");
|
||||
if has_audio {
|
||||
all_instructions.extend(runtime::gen_audio_tick());
|
||||
all_instructions.extend(runtime::gen_audio_tick(has_noise, has_triangle));
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use super::*;
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
use crate::parser::ast::{Mapper, Mirroring};
|
||||
use crate::parser::ast::{Channel, Mapper, Mirroring};
|
||||
use crate::rom;
|
||||
|
||||
#[test]
|
||||
|
|
@ -187,6 +187,7 @@ fn link_with_audio_data_places_sfx_blobs_in_prg() {
|
|||
period_lo: 0x50,
|
||||
period_hi: 0x08,
|
||||
envelope: vec![0xBF, 0xB8, 0xB4, 0xB0, 0x00],
|
||||
channel: Channel::Pulse1,
|
||||
}];
|
||||
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
|
|
@ -238,6 +239,7 @@ fn link_with_audio_resolves_sfx_pointer_references() {
|
|||
period_lo: 0x50,
|
||||
period_hi: 0x08,
|
||||
envelope: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00],
|
||||
channel: Channel::Pulse1,
|
||||
}];
|
||||
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
|
||||
// The user code starts at RESET ($C000) after init+palette_load.
|
||||
|
|
@ -280,6 +282,7 @@ fn link_without_audio_marker_does_not_emit_period_table() {
|
|||
period_lo: 0,
|
||||
period_hi: 0,
|
||||
envelope: vec![0xAA, 0xBB, 0x00],
|
||||
channel: Channel::Pulse1,
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -101,24 +101,51 @@ 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.
|
||||
/// APU channel an sfx targets. Pulse1 is the historical default and
|
||||
/// the only one populated from older programs that omit `channel:`.
|
||||
/// Triangle and Noise were added as part of the "richer audio"
|
||||
/// work — Triangle has no volume envelope (the channel is fixed
|
||||
/// output), Noise uses a 16-entry period table rather than the
|
||||
/// pulse channel's 60-entry one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Pulse1,
|
||||
Pulse2,
|
||||
Triangle,
|
||||
Noise,
|
||||
}
|
||||
|
||||
/// `sfx Name { ... }` — a sound effect played on pulse 1 (by default).
|
||||
/// SFX are frame-accurate envelopes: `pitch[i]` and `volume[i]`
|
||||
/// describe the 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 the channel volume to 0 one frame after the last entry.
|
||||
///
|
||||
/// The `channel:` property (new) lets a declaration target the
|
||||
/// triangle or noise channels instead of the default pulse 1. For
|
||||
/// triangle, `volume` is meaningless (fixed-level channel) and the
|
||||
/// per-frame "volume" byte is instead treated as a hold flag (nonzero
|
||||
/// = sustain, zero = release/stop). For noise, `pitch` values are
|
||||
/// interpreted as 0-15 indices into the APU's internal 16-entry
|
||||
/// noise period table rather than raw 11-bit pulse periods.
|
||||
#[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.
|
||||
/// pulse waveform; 2 (50%) sounds like a square wave. Not
|
||||
/// meaningful for triangle or noise channels.
|
||||
pub duty: u8,
|
||||
/// One period byte per frame, written to $4002. The $4003 high
|
||||
/// nibble is always zero (keeps notes in the audible range).
|
||||
/// One period byte per frame, written to $4002 (pulse 1) or
|
||||
/// $400E (noise, low 4 bits only) on trigger.
|
||||
pub pitch: Vec<u8>,
|
||||
/// One volume byte per frame (0-15), combined with the duty bits
|
||||
/// and written to $4000.
|
||||
/// and written to $4000 (pulse 1) / $400C (noise) / $4008
|
||||
/// (triangle; any nonzero value means "hold", zero means release).
|
||||
pub volume: Vec<u8>,
|
||||
/// APU channel this sfx drives. Defaults to [`Channel::Pulse1`]
|
||||
/// when the declaration omits the `channel:` property.
|
||||
pub channel: Channel,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1522,11 +1522,34 @@ impl Parser {
|
|||
let mut pitch_src: Option<PitchSrc> = None;
|
||||
let mut volume: Option<Vec<u8>> = None;
|
||||
let mut volume_key: &'static str = "volume";
|
||||
let mut channel: Channel = Channel::Pulse1;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, key_span) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"channel" => {
|
||||
// `channel: pulse1 | pulse2 | triangle | noise`.
|
||||
// Identifiers (not keywords) so the lexer passes
|
||||
// them through as `Ident`.
|
||||
let (ch_name, ch_span) = self.expect_ident()?;
|
||||
channel = match ch_name.as_str() {
|
||||
"pulse1" | "pulse" => Channel::Pulse1,
|
||||
"pulse2" => Channel::Pulse2,
|
||||
"triangle" => Channel::Triangle,
|
||||
"noise" => Channel::Noise,
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"unknown sfx channel '{ch_name}' \
|
||||
(expected pulse1, pulse2, triangle, or noise)"
|
||||
),
|
||||
ch_span,
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
"duty" => {
|
||||
duty = self.parse_u8_literal("duty")?;
|
||||
if duty > 3 {
|
||||
|
|
@ -1640,6 +1663,7 @@ impl Parser {
|
|||
duty,
|
||||
pitch,
|
||||
volume,
|
||||
channel,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().end),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -789,6 +789,80 @@ fn parse_sfx_decl_rejects_duty_over_3() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sfx_decl_defaults_channel_to_pulse1() {
|
||||
// Existing declarations (which never set `channel:`) should
|
||||
// default to `Channel::Pulse1` so their codegen path is
|
||||
// unchanged.
|
||||
let src = r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Pickup {
|
||||
duty: 2
|
||||
pitch: [0x50, 0x48]
|
||||
volume: [15, 8]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.sfx[0].channel, crate::parser::ast::Channel::Pulse1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sfx_decl_with_noise_channel() {
|
||||
let src = r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Zap {
|
||||
channel: noise
|
||||
pitch: 5
|
||||
volume: [15, 10, 5]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.sfx.len(), 1);
|
||||
assert_eq!(prog.sfx[0].channel, crate::parser::ast::Channel::Noise);
|
||||
assert_eq!(prog.sfx[0].pitch, vec![5, 5, 5]);
|
||||
assert_eq!(prog.sfx[0].volume, vec![15, 10, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sfx_decl_with_triangle_channel() {
|
||||
let src = r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Bass {
|
||||
channel: triangle
|
||||
pitch: 60
|
||||
volume: [1, 1, 1, 1, 1]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.sfx[0].channel, crate::parser::ast::Channel::Triangle);
|
||||
assert_eq!(prog.sfx[0].pitch, vec![60; 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sfx_decl_rejects_unknown_channel() {
|
||||
let src = r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Bad {
|
||||
channel: bogus
|
||||
pitch: 5
|
||||
volume: [8]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let (_, diags) = parse(src);
|
||||
assert!(
|
||||
diags.iter().any(crate::errors::Diagnostic::is_error),
|
||||
"unknown channel name should error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_music_decl_minimal() {
|
||||
let src = r#"
|
||||
|
|
|
|||
|
|
@ -96,6 +96,31 @@ pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17;
|
|||
/// analyzer-allocated variables (which grow from $0300 upward).
|
||||
pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF;
|
||||
|
||||
// ── Extra channel state ──
|
||||
//
|
||||
// The pulse-1 sfx and pulse-2 music channels live in zero page
|
||||
// ($00-$0F) where every byte is precious. Adding new channel
|
||||
// state there would either push user variables back by 6 bytes
|
||||
// (breaking every existing example's ZP layout) or collide with
|
||||
// runtime scratch slots. Instead, we park triangle and noise
|
||||
// state at the very top of main RAM, just below the debug frame
|
||||
// overrun counter, where analyzer-allocated globals rarely reach
|
||||
// (they grow from $0300 upward). The few extra cycles per
|
||||
// absolute access are negligible for a once-per-NMI tick.
|
||||
//
|
||||
// The state is only *referenced* by the audio tick when the
|
||||
// corresponding `has_noise` / `has_triangle` flag is set — so
|
||||
// programs that don't declare any noise/triangle sfx touch
|
||||
// these addresses zero times, and the ROM bytes generated for
|
||||
// an existing audio example are byte-identical to what today's
|
||||
// compiler produces.
|
||||
pub const AUDIO_NOISE_PTR_LO: u16 = 0x07F0;
|
||||
pub const AUDIO_NOISE_PTR_HI: u16 = 0x07F1;
|
||||
pub const AUDIO_NOISE_COUNTER: u16 = 0x07F2;
|
||||
pub const AUDIO_TRIANGLE_PTR_LO: u16 = 0x07F3;
|
||||
pub const AUDIO_TRIANGLE_PTR_HI: u16 = 0x07F4;
|
||||
pub const AUDIO_TRIANGLE_COUNTER: u16 = 0x07F5;
|
||||
|
||||
/// Generate the NES hardware initialization sequence.
|
||||
/// This runs at RESET and sets up the hardware before user code.
|
||||
pub fn gen_init() -> Vec<Instruction> {
|
||||
|
|
@ -721,7 +746,18 @@ pub fn gen_multiply() -> Vec<Instruction> {
|
|||
///
|
||||
/// 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<Instruction> {
|
||||
///
|
||||
/// When `has_noise` / `has_triangle` are set, the driver gains an
|
||||
/// extra per-channel slot: noise routes envelope bytes to `$400C`
|
||||
/// and drives `$400E` / `$400F` on trigger; triangle writes linear-
|
||||
/// counter reload values to `$4008`. These blocks are appended to
|
||||
/// the tick after the music path so that programs which do not
|
||||
/// declare any noise or triangle sfx produce byte-identical ROM
|
||||
/// output — the old pulse-only path emits exactly the same
|
||||
/// instruction stream as before. The linker decides whether to
|
||||
/// enable each by scanning for the `__noise_used` and
|
||||
/// `__triangle_used` marker labels emitted by the IR codegen.
|
||||
pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
|
||||
|
|
@ -948,11 +984,168 @@ pub fn gen_audio_tick() -> Vec<Instruction> {
|
|||
NOP,
|
||||
AM::Label("__audio_music_done".into()),
|
||||
));
|
||||
|
||||
// ── Noise channel tick (optional, gated on `has_noise`) ──
|
||||
//
|
||||
// Structurally identical to the pulse-1 sfx tick above: walk a
|
||||
// per-frame envelope blob via an indirect-indexed load and
|
||||
// write each byte to the APU noise volume register at $400C.
|
||||
// The pointer lives in main RAM at [AUDIO_NOISE_PTR_LO,
|
||||
// AUDIO_NOISE_PTR_HI]; the tick stashes it in ZP scratch $02/$03
|
||||
// for the duration of the block because the 6502 has no
|
||||
// (abs),Y addressing.
|
||||
if has_noise {
|
||||
out.extend(gen_noise_tick());
|
||||
}
|
||||
|
||||
// ── Triangle channel tick (optional, gated on `has_triangle`) ──
|
||||
//
|
||||
// Same shape as the noise tick but writes to $4008 (the linear
|
||||
// counter) instead of a volume register. Triangle has no volume,
|
||||
// so the envelope blob just encodes "keep the linear counter
|
||||
// loaded" (nonzero hold) or "silence" (the $80 sentinel).
|
||||
if has_triangle {
|
||||
out.extend(gen_triangle_tick());
|
||||
}
|
||||
|
||||
out.push(Instruction::implied(RTS));
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the noise-channel sfx tick. Appended to
|
||||
/// [`gen_audio_tick`] only when the program declares at least one
|
||||
/// noise sfx (`__noise_used` marker). Reads envelope bytes from the
|
||||
/// main-RAM pointer at [`AUDIO_NOISE_PTR_LO`] / [`AUDIO_NOISE_PTR_HI`]
|
||||
/// and writes them to the APU noise volume register at `$400C`.
|
||||
/// A zero envelope byte is the mute sentinel — on it the tick
|
||||
/// silences the channel and clears [`AUDIO_NOISE_COUNTER`].
|
||||
fn gen_noise_tick() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_noise_tick".into()),
|
||||
));
|
||||
// If counter is zero, no noise sfx is playing; skip the block.
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_COUNTER)));
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_noise_done".into()),
|
||||
));
|
||||
// Load the main-RAM pointer into ZP scratch $02/$03 so we can
|
||||
// do an indirect-indexed read (6502 has no (abs),Y mode).
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_PTR_LO)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_NOISE_PTR_HI)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
|
||||
// Read envelope byte through the scratch pointer.
|
||||
out.push(Instruction::new(LDY, AM::Immediate(0)));
|
||||
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
|
||||
// Zero sentinel? branch to the write path if nonzero.
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_noise_write".into()),
|
||||
));
|
||||
// Sentinel: mute pulse-noise ($4000-compatible encoding:
|
||||
// length-halt + constant-volume + volume 0) and clear the
|
||||
// counter so this block bails on subsequent NMIs.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x400C)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(AUDIO_NOISE_COUNTER)));
|
||||
out.push(Instruction::new(
|
||||
JMP,
|
||||
AM::Label("__audio_noise_done".into()),
|
||||
));
|
||||
// Write envelope byte and advance the 16-bit pointer by 1.
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_noise_write".into()),
|
||||
));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x400C)));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_NOISE_PTR_LO)));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_noise_ptr_ok".into()),
|
||||
));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_NOISE_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_noise_ptr_ok".into()),
|
||||
));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_noise_done".into()),
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the triangle-channel sfx tick. Same shape as
|
||||
/// [`gen_noise_tick`] but writes to `$4008` (the linear counter
|
||||
/// reload) instead of a volume register. Triangle has no volume —
|
||||
/// the envelope bytes are "keep holding" tokens that the runtime
|
||||
/// keeps writing every frame so the linear counter never underruns
|
||||
/// and the channel never auto-silences. A `0x80` byte is the mute
|
||||
/// sentinel (linear control bit set, reload = 0 → silence next
|
||||
/// frame).
|
||||
fn gen_triangle_tick() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_triangle_tick".into()),
|
||||
));
|
||||
// If counter is zero, no triangle sfx is playing.
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_COUNTER)));
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_triangle_done".into()),
|
||||
));
|
||||
// Stash pointer to ZP scratch for indirect-indexed load.
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_PTR_LO)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_TRIANGLE_PTR_HI)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
|
||||
out.push(Instruction::new(LDY, AM::Immediate(0)));
|
||||
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
|
||||
// Write the envelope byte to $4008. For triangle, `$80` is the
|
||||
// mute sentinel (linear counter reload = 0 with control bit
|
||||
// set). We detect it by CMP + BEQ so the counter can be cleared.
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x4008)));
|
||||
out.push(Instruction::new(CMP, AM::Immediate(0x80)));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_triangle_advance".into()),
|
||||
));
|
||||
// Sentinel: clear counter so we bail next frame. The $80 write
|
||||
// above already mutes the triangle channel.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(AUDIO_TRIANGLE_COUNTER)));
|
||||
out.push(Instruction::new(
|
||||
JMP,
|
||||
AM::Label("__audio_triangle_done".into()),
|
||||
));
|
||||
// Advance the pointer by 1 for the next frame.
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_triangle_advance".into()),
|
||||
));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_TRIANGLE_PTR_LO)));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_triangle_ptr_ok".into()),
|
||||
));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_TRIANGLE_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_triangle_ptr_ok".into()),
|
||||
));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_triangle_done".into()),
|
||||
));
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ fn multiply_routine_assembles() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_defines_required_labels() {
|
||||
let tick = gen_audio_tick();
|
||||
let tick = gen_audio_tick(false, false);
|
||||
// The IR codegen JSRs into `__audio_tick`; that's the entry.
|
||||
let has_entry = tick
|
||||
.iter()
|
||||
|
|
@ -215,7 +215,7 @@ fn audio_tick_defines_required_labels() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_ends_with_rts() {
|
||||
let tick = gen_audio_tick();
|
||||
let tick = gen_audio_tick(false, false);
|
||||
assert_eq!(
|
||||
tick.last().unwrap().opcode,
|
||||
RTS,
|
||||
|
|
@ -228,7 +228,7 @@ 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 tick = gen_audio_tick(false, false);
|
||||
let has_load = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
|
||||
|
|
@ -241,20 +241,101 @@ fn audio_tick_reads_sfx_envelope_via_indirect_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 tick = gen_audio_tick(false, false);
|
||||
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_noise_block_writes_400c_when_enabled() {
|
||||
// With `has_noise = true` the tick gains a block that loads an
|
||||
// envelope byte and writes it to the APU noise volume register
|
||||
// at $400C. Verify both the marker label and the STA $400C are
|
||||
// present.
|
||||
let tick = gen_audio_tick(true, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
|
||||
assert!(has_label, "noise tick should define __audio_noise_tick");
|
||||
let has_400c = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x400C));
|
||||
assert!(has_400c, "noise tick should write to $400C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_noise_block_absent_when_disabled() {
|
||||
// The pulse-only path must not emit any noise label, so
|
||||
// programs that never declare a noise sfx get byte-identical
|
||||
// code to the pre-feature version.
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
|
||||
assert!(
|
||||
!has_label,
|
||||
"noise tick label must not appear when flag is off"
|
||||
);
|
||||
let has_400c = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x400C));
|
||||
assert!(
|
||||
!has_400c,
|
||||
"noise $400C write must not appear when flag is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_triangle_block_writes_4008_when_enabled() {
|
||||
let tick = gen_audio_tick(false, true);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
|
||||
assert!(
|
||||
has_label,
|
||||
"triangle tick should define __audio_triangle_tick"
|
||||
);
|
||||
let has_4008 = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4008));
|
||||
assert!(has_4008, "triangle tick should write to $4008");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_triangle_block_absent_when_disabled() {
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
|
||||
assert!(!has_label);
|
||||
let has_4008 = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4008));
|
||||
assert!(!has_4008);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_both_channels_assemble() {
|
||||
// With both new channels enabled, the tick + period table must
|
||||
// still fit its internal branches (±127 bytes) and assemble
|
||||
// successfully.
|
||||
let mut combined = gen_audio_tick(true, true);
|
||||
combined.extend(gen_period_table());
|
||||
let result = asm::assemble(&combined, 0xC000);
|
||||
assert!(!result.bytes.is_empty());
|
||||
assert!(result.labels.contains_key("__audio_noise_tick"));
|
||||
assert!(result.labels.contains_key("__audio_triangle_tick"));
|
||||
}
|
||||
|
||||
#[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 tick = gen_audio_tick(false, false);
|
||||
let has_mute = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
|
||||
|
|
@ -275,7 +356,7 @@ fn audio_tick_assembles_without_error() {
|
|||
// 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();
|
||||
let mut combined = gen_audio_tick(false, false);
|
||||
combined.extend(gen_period_table());
|
||||
let result = asm::assemble(&combined, 0xC000);
|
||||
assert!(
|
||||
|
|
|
|||
1
tests/emulator/goldens/noise_triangle_sfx.audio.hash
Normal file
1
tests/emulator/goldens/noise_triangle_sfx.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/noise_triangle_sfx.png
Normal file
BIN
tests/emulator/goldens/noise_triangle_sfx.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 843 B |
|
|
@ -813,6 +813,54 @@ fn program_with_user_declared_sfx_and_music() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_with_noise_sfx_writes_400c_at_play_site() {
|
||||
// A program that declares a noise sfx and plays it should
|
||||
// end up with a trigger sequence that touches $400E (noise
|
||||
// period) and $400C (volume pre-mute). The exact register
|
||||
// sequence is:
|
||||
// LDA #$30; STA $400C; LDA #idx; STA $400E; LDA #len; STA $400F;
|
||||
// LDA #$0B; STA $4015
|
||||
// plus an envelope pointer load. We check the two channel-
|
||||
// specific stores ($400E + $400C) explicitly so a regression
|
||||
// that routes to pulse 1 by mistake will fail loud.
|
||||
let source = r#"
|
||||
game "Noise Test" { mapper: NROM }
|
||||
sfx Crash {
|
||||
channel: noise
|
||||
pitch: 4
|
||||
volume: [15, 12, 8, 4]
|
||||
}
|
||||
on frame { play Crash }
|
||||
start Main
|
||||
"#;
|
||||
let rom_data = compile(source);
|
||||
let prg = &rom_data[16..16 + 16384];
|
||||
// Search for any `STA $400C` instruction byte sequence
|
||||
// (opcode 0x8D, lo=0x0C, hi=0x40). 6502 absolute store is
|
||||
// always 3 bytes.
|
||||
let sta_envelope_reg: [u8; 3] = [0x8D, 0x0C, 0x40];
|
||||
assert!(
|
||||
prg.windows(sta_envelope_reg.len())
|
||||
.any(|w| w == sta_envelope_reg),
|
||||
"noise play sequence should include STA $400C"
|
||||
);
|
||||
let sta_period_reg: [u8; 3] = [0x8D, 0x0E, 0x40];
|
||||
assert!(
|
||||
prg.windows(sta_period_reg.len())
|
||||
.any(|w| w == sta_period_reg),
|
||||
"noise play sequence should include STA $400E"
|
||||
);
|
||||
// The noise envelope bytes (derived from the user `volume` list
|
||||
// masked with 0x30 | vol) must be in ROM too.
|
||||
let env = |v: u8| 0x30u8 | v;
|
||||
let crash_env: [u8; 5] = [env(15), env(12), env(8), env(4), 0x00];
|
||||
assert!(
|
||||
prg.windows(crash_env.len()).any(|w| w == crash_env),
|
||||
"noise envelope blob must live in PRG"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_without_audio_has_no_audio_driver_in_prg() {
|
||||
// Programs that never touch audio should pay zero ROM cost:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue