1
0
Fork 0
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:
Claude 2026-04-14 10:42:53 +00:00
parent 8610aecdac
commit 201664ea04
No known key found for this signature in database
18 changed files with 1116 additions and 52 deletions

View file

@ -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

View file

@ -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

View 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

Binary file not shown.

View file

@ -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);

View file

@ -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(

View file

@ -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());

View file

@ -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:

View file

@ -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

View file

@ -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,
}],
&[],
);

View file

@ -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,
}

View file

@ -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),
})
}

View file

@ -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#"

View file

@ -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 (C1B5) for 60 entries, 2 bytes each.

View file

@ -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!(

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

View file

@ -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: