1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-20 21:10:04 +00:00

audio: per-frame pitch envelopes for pulse SFX

Pulse-channel sfx with a multi-byte `pitch:` array used to silently
ignore everything past the first byte — the runtime audio tick
latched the period at trigger time and never updated it. Programs
that wanted a frequency sweep had no way to express it.

The compiler now compiles a per-frame pitch envelope blob alongside
the existing volume envelope when `decl.pitch` has more than one
distinct value. The blob is padded (or truncated) to the volume
envelope's length and ends in a zero sentinel so the runtime
walker stops both pointers on the same NMI. Sfx with a single
scalar pitch (or an array where every byte is the same) keep their
historical "no pitch blob, latch once" path and emit byte-identical
ROM bytes.

The runtime gains two new pieces, both gated on a new
`__sfx_pitch_used` codegen marker so programs without varying-pitch
sfx pay zero bytes:

1. `gen_audio_tick` emits a per-frame pitch update block inside
   the SFX tick: read a byte through `(AUDIO_SFX_PITCH_PTR),Y`,
   write it to `$4002` (pulse-1 period low), advance the pointer.
   The block bails on a zero high-byte pointer so a single
   program can mix scalar-pitch and varying-pitch sfx without
   one clobbering the other.

2. `emit_play_pulse` seeds `AUDIO_SFX_PITCH_PTR_LO/HI` with the
   pitch-blob label for varying-pitch sfx and zeros it for
   scalar-pitch sfx. The per-call branch is skipped entirely
   when the program has no varying-pitch sfx anywhere.

The new `examples/sfx_pitch_envelope.ne` exercises the path with
a 16-frame siren sweep. Triangle and noise per-frame pitch are
deferred — they share the same data shape but the runtime ticks
for those channels still write only their volume registers, see
docs/future-work.md for the gap.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
This commit is contained in:
Claude 2026-04-15 02:54:56 +00:00
parent db3a4adc57
commit 9878b7d87d
No known key found for this signature in database
12 changed files with 449 additions and 31 deletions

View file

@ -90,6 +90,7 @@ start Main
| [`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 |
| [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone |
| [`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

@ -31,6 +31,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `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. |
| `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_<name>` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. |
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
| `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`. |

View file

@ -0,0 +1,42 @@
// Per-frame pitch envelope on a pulse SFX. The user authors the
// `pitch:` array as one byte per frame (matching the per-frame
// `volume:` array) and the runtime audio tick walks both pointers
// in lockstep, writing pitch to `$4002` and volume to `$4000` on
// every NMI. The result is a frequency-sweeping "siren" tone — the
// classic latch-once pulse SFX driver couldn't model this at all.
//
// Per-frame pitch is opt-in: if the `pitch:` array has a single
// value (or repeats one byte) the compiler emits the byte-identical
// pre-pitch-envelope sequence and no extra blob, so existing
// programs that just want a static pitch keep working unchanged.
// See `runtime/gen_audio_tick` for the gated extension.
//
// Build: cargo run -- build examples/sfx_pitch_envelope.ne
game "SFX Pitch Envelope" {
mapper: NROM
}
// 16-frame pitch sweep from $40 down to $20 paired with a slow
// volume ramp. Both arrays are the same length so the runtime's
// lockstep walker handles the simplest possible case.
sfx Siren {
duty: 2
pitch: [0x40, 0x3D, 0x3A, 0x37, 0x34, 0x31, 0x2E, 0x2B, 0x28, 0x26, 0x24, 0x22, 0x21, 0x20, 0x20, 0x20]
volume: [15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
}
var tick: u8 = 0
on frame {
// Re-trigger the sfx every 60 frames so the siren restarts
// whenever the previous pass mutes itself, giving the
// emulator harness a stable pattern to capture.
tick += 1
if tick == 60 {
tick = 0
play Siren
}
}
start Main

Binary file not shown.

View file

@ -79,6 +79,20 @@ pub struct SfxData {
/// (`0x00` for pulse/noise, `0x80` for triangle). Linked into
/// PRG ROM as a labelled data block.
pub envelope: Vec<u8>,
/// Per-frame pitch bytes. Empty when the sfx has a single
/// scalar pitch (the existing latch-once behaviour) or when
/// the channel doesn't currently support per-frame pitch
/// updates (today only Pulse 1 does — triangle and noise
/// share the same data shape but the runtime path hasn't been
/// extended yet, see `docs/future-work.md`). When non-empty
/// the audio tick writes one byte per NMI to the channel's
/// period-lo register, in lockstep with the volume envelope.
/// Length doesn't have to match `envelope` — the runtime
/// re-reads the same byte each frame, so a shorter pitch
/// stream simply latches its last value. The pitch stream
/// has its own zero-byte sentinel matching the volume
/// envelope's length so the lockstep walk terminates cleanly.
pub pitch_envelope: Vec<u8>,
/// APU channel this sfx drives.
pub channel: Channel,
}
@ -104,6 +118,27 @@ impl SfxData {
pub fn label(&self) -> String {
format!("__sfx_{}", sanitize_label(&self.name))
}
/// ROM label for the optional per-frame pitch envelope blob.
/// Only meaningful when [`SfxData::pitch_envelope`] is
/// non-empty; the codegen / linker uses it to splice the blob
/// into PRG and to set up the pitch-walk pointer at trigger
/// time. The label format mirrors the volume envelope label
/// to keep `--symbols` output uniform.
#[must_use]
pub fn pitch_label(&self) -> String {
format!("__sfx_pitch_{}", sanitize_label(&self.name))
}
/// True iff this sfx carries a per-frame pitch envelope. The
/// runtime audio tick has a slightly different (and slightly
/// larger) code path for sfx with pitch envelopes, so the
/// codegen gates emission of that path on whether *any* sfx
/// in the program has one.
#[must_use]
pub fn has_pitch_envelope(&self) -> bool {
!self.pitch_envelope.is_empty()
}
}
impl MusicData {
@ -334,15 +369,54 @@ fn compile_pulse_sfx(decl: &SfxDecl) -> SfxData {
// 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);
// Per-frame pitch envelope — populated when the user provides
// more than one distinct pitch byte. A single scalar (or a
// multi-element array where every byte is the same) keeps
// the historical "latch period at trigger and never touch
// it again" behaviour and emits no pitch blob, so existing
// sfx ROMs are byte-identical. The pitch envelope is padded
// (or truncated) to match the volume envelope length so the
// runtime can walk both pointers in lockstep without a
// separate length check; the trailing zero sentinel is added
// last so the pitch blob's last byte aligns with the volume
// sentinel and the runtime stops both walks at the same NMI.
let pitch_envelope = build_pulse_pitch_envelope(&decl.pitch, decl.volume.len());
SfxData {
name: decl.name.clone(),
period_lo,
period_hi,
envelope,
pitch_envelope,
channel: decl.channel,
}
}
/// Build the pulse-channel pitch envelope from a user-declared
/// `pitch:` array. Returns an empty vector when the array
/// describes a single static pitch (length ≤ 1, or all bytes the
/// same), in which case the runtime keeps its existing latch-once
/// behaviour and no pitch blob is emitted at all. Otherwise the
/// returned vector has exactly `volume_frames + 1` bytes — the
/// extra byte is a zero sentinel that lines up with the volume
/// envelope's mute byte so the runtime tick sees both end markers
/// on the same NMI. Pitches shorter than `volume_frames` repeat
/// their last value; longer pitches truncate.
fn build_pulse_pitch_envelope(pitch: &[u8], volume_frames: usize) -> Vec<u8> {
if pitch.len() <= 1 {
return Vec::new();
}
if pitch.iter().all(|&p| p == pitch[0]) {
return Vec::new();
}
let mut out = Vec::with_capacity(volume_frames + 1);
let last = *pitch.last().unwrap_or(&0);
for i in 0..volume_frames {
out.push(pitch.get(i).copied().unwrap_or(last));
}
out.push(0x00);
out
}
/// 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
@ -380,6 +454,10 @@ fn compile_triangle_sfx(decl: &SfxDecl) -> SfxData {
period_lo,
period_hi,
envelope,
// Triangle per-frame pitch isn't wired up yet (the runtime
// tick's triangle block writes only $4008, not $400A); see
// docs/future-work.md for the gap.
pitch_envelope: Vec::new(),
channel: decl.channel,
}
}
@ -416,6 +494,10 @@ fn compile_noise_sfx(decl: &SfxDecl) -> SfxData {
period_lo,
period_hi,
envelope,
// Noise per-frame pitch (period-index sweeping) isn't
// wired up yet — the existing runtime tick only updates
// `$400C` per frame. See docs/future-work.md.
pitch_envelope: Vec::new(),
channel: decl.channel,
}
}
@ -1089,6 +1171,85 @@ mod tests {
assert_eq!(note_name_to_index("CoolName"), None);
}
#[test]
fn build_pulse_pitch_envelope_scalar_returns_empty() {
// A single pitch byte (the historical scalar `pitch:` form)
// keeps the latch-once driver path. The runtime never
// reads the pitch envelope blob, and we don't want to
// emit one — the empty vec signals that to the linker.
assert!(build_pulse_pitch_envelope(&[0x40], 8).is_empty());
// Same for "the user wrote an array but every element is
// the same byte" — semantically identical to scalar pitch.
assert!(build_pulse_pitch_envelope(&[0x40, 0x40, 0x40], 8).is_empty());
// And the degenerate empty case.
assert!(build_pulse_pitch_envelope(&[], 8).is_empty());
}
#[test]
fn build_pulse_pitch_envelope_varying_pads_to_volume_length() {
// The runtime walks pitch and volume in lockstep, so the
// pitch blob is sized to match the volume envelope's
// length plus a trailing zero sentinel.
let env = build_pulse_pitch_envelope(&[0x40, 0x30, 0x20], 5);
assert_eq!(
env,
vec![0x40, 0x30, 0x20, 0x20, 0x20, 0x00],
"pitches shorter than volume frames should latch their last value"
);
}
#[test]
fn build_pulse_pitch_envelope_truncates_when_longer_than_volume() {
// A pitch array longer than the volume envelope is
// truncated — the runtime stops walking when the volume
// envelope hits its zero sentinel anyway.
let env = build_pulse_pitch_envelope(&[0x10, 0x20, 0x30, 0x40, 0x50, 0x60], 3);
assert_eq!(env, vec![0x10, 0x20, 0x30, 0x00]);
}
#[test]
fn compile_pulse_sfx_with_varying_pitch_populates_envelope() {
// End-to-end: a pulse sfx with a varying `pitch:` array
// should produce a non-empty `pitch_envelope` whose length
// matches the volume envelope (excluding the sentinel),
// and whose label is the canonical `__sfx_pitch_<name>`.
let decl = SfxDecl {
name: "Siren".to_string(),
duty: 2,
pitch: vec![0x40, 0x30, 0x20],
volume: vec![15, 10, 5],
channel: Channel::Pulse1,
span: crate::lexer::Span::dummy(),
};
let sfx = compile_pulse_sfx(&decl);
assert!(sfx.has_pitch_envelope());
// Three volume frames + sentinel.
assert_eq!(sfx.envelope.len(), 4);
assert_eq!(sfx.pitch_envelope.len(), 4);
assert_eq!(sfx.pitch_envelope[0], 0x40);
assert_eq!(sfx.pitch_envelope[3], 0x00); // sentinel
assert_eq!(sfx.pitch_label(), "__sfx_pitch_Siren");
}
#[test]
fn compile_pulse_sfx_with_scalar_pitch_omits_envelope() {
// The historical scalar form should still produce no
// pitch envelope — gating the runtime extension on
// emptiness keeps existing scalar-pitch ROMs byte-
// identical.
let decl = SfxDecl {
name: "Coin".to_string(),
duty: 2,
pitch: vec![0x50],
volume: vec![15, 10, 5],
channel: Channel::Pulse1,
span: crate::lexer::Span::dummy(),
};
let sfx = compile_pulse_sfx(&decl);
assert!(!sfx.has_pitch_envelope());
assert!(sfx.pitch_envelope.is_empty());
}
#[test]
fn walk_for_play_finds_nested_references() {
// `play` inside an `if` inside a `while` should still be

View file

@ -30,12 +30,12 @@ use crate::assets::{MusicData, SfxData};
use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId};
use crate::parser::ast::Channel;
use crate::runtime::{
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,
AUDIO_NOISE_COUNTER, AUDIO_NOISE_PTR_HI, AUDIO_NOISE_PTR_LO, AUDIO_SFX_PITCH_PTR_HI,
AUDIO_SFX_PITCH_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.
@ -82,15 +82,26 @@ 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, channel)`.
/// Populated by `with_audio` from the resolved [`SfxData`] list.
/// Per-sfx codegen metadata captured by [`Self::with_audio`].
/// Tuple layout:
/// - trigger period low / high (channel-specific — see below);
/// - the volume-envelope blob's PRG label;
/// - the APU channel the sfx drives;
/// - an optional per-frame pitch envelope blob label
/// (`Some(label)` when the sfx has a varying-pitch envelope,
/// `None` for the scalar-pitch fast path that doesn't touch
/// the runtime pitch tick).
///
/// `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)>,
/// at the `AUDIO_*_PTR_*` addresses);
/// - whether to seed [`AUDIO_SFX_PITCH_PTR_LO`] with the
/// pitch-envelope label or with zero (the runtime "no pitch
/// update" sentinel).
sfx_info: HashMap<String, (u8, u8, String, Channel, Option<String>)>,
/// 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`.
@ -118,6 +129,14 @@ pub struct IrCodeGen<'a> {
/// Same as `noise_used`, but for triangle sfx. Drives the
/// `__triangle_used` marker label.
triangle_used: bool,
/// True when at least one sfx in the program declares a
/// varying-pitch envelope. Drives the `__sfx_pitch_used`
/// marker label, which the linker reads to decide whether to
/// splice the per-frame pitch update path into the audio
/// tick. Programs without varying-pitch sfx leave this
/// `false` and emit byte-identical ROM bytes for the audio
/// subsystem.
sfx_pitch_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
@ -252,6 +271,7 @@ impl<'a> IrCodeGen<'a> {
use_counts: HashMap::new(),
sprite_tiles: HashMap::new(),
sfx_info: HashMap::new(),
sfx_pitch_used: false,
music_info: HashMap::new(),
state_indices: HashMap::new(),
function_names,
@ -351,9 +371,15 @@ impl<'a> IrCodeGen<'a> {
#[must_use]
pub fn with_audio(mut self, sfx: &[SfxData], music: &[MusicData]) -> Self {
for s in sfx {
let pitch_label = if s.has_pitch_envelope() {
self.sfx_pitch_used = true;
Some(s.pitch_label())
} else {
None
};
self.sfx_info.insert(
s.name.clone(),
(s.period_lo, s.period_hi, s.label(), s.channel),
(s.period_lo, s.period_hi, s.label(), s.channel, pitch_label),
);
}
for m in music {
@ -549,6 +575,18 @@ impl<'a> IrCodeGen<'a> {
self.emit_label("__debug_mode");
}
// `__sfx_pitch_used` follows the same marker-label pattern
// as `__audio_used`. It tells the linker that at least one
// sfx in the program declared a per-frame `pitch:` array
// and so the audio tick needs the extra pitch-update
// block. Programs without a varying-pitch sfx never set
// `sfx_pitch_used` and the linker emits the smaller pre-
// pitch-envelope tick path, so existing example ROMs are
// byte-identical.
if self.sfx_pitch_used {
self.emit_label("__sfx_pitch_used");
}
// 1. Variable initializers
//
// Scalars write a single byte from `init_value`. Array
@ -1383,7 +1421,9 @@ 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, channel)) = self.sfx_info.get(name).cloned() else {
let Some((period_lo, period_hi, label, channel, pitch_label)) =
self.sfx_info.get(name).cloned()
else {
// Unknown name. The analyzer warns on this; emit a no-op
// sequence so the rest of the code still assembles. The
// unknown branch is easy to spot in `--asm-dump`: it
@ -1392,17 +1432,31 @@ impl<'a> IrCodeGen<'a> {
return;
};
match channel {
Channel::Pulse1 | Channel::Pulse2 => self.emit_play_pulse(period_lo, period_hi, &label),
Channel::Pulse1 | Channel::Pulse2 => {
self.emit_play_pulse(period_lo, period_hi, &label, pitch_label.as_deref());
}
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) {
/// Pulse `play` sequence. The byte layout is unchanged from
/// the pre-pitch-envelope codegen unless either (a) the sfx
/// being played has its own pitch envelope or (b) the program
/// has at least one varying-pitch sfx anywhere — in which
/// case the sequence also seeds (or zeros) the runtime's
/// per-frame pitch pointer at
/// [`AUDIO_SFX_PITCH_PTR_LO`] / `_HI`. The runtime tick
/// treats a zero high byte as "no pitch envelope on the
/// currently-playing sfx" so a single program can mix scalar
/// and varying-pitch sfx without one clobbering the other.
fn emit_play_pulse(
&mut self,
period_lo: u8,
period_hi: u8,
label: &str,
pitch_label: Option<&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).
@ -1421,6 +1475,29 @@ impl<'a> IrCodeGen<'a> {
self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_LO));
self.emit(LDA, AM::SymbolHi(label.to_string()));
self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_HI));
// Optional pitch envelope pointer setup. We only emit any
// store at all when the *program* has at least one
// varying-pitch sfx (`self.sfx_pitch_used`) — that's the
// gate that keeps existing scalar-pitch programs
// byte-identical. Within such a program every play
// sequence still runs through this branch so a scalar
// sfx that follows a varying-pitch one zeros the pointer
// and the runtime tick safely skips the pitch update.
if self.sfx_pitch_used {
if let Some(pl) = pitch_label {
self.emit(LDA, AM::SymbolLo(pl.to_string()));
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
self.emit(LDA, AM::SymbolHi(pl.to_string()));
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI));
} else {
// Scalar-pitch sfx in a mixed program: clear the
// pointer so the tick's high-byte sentinel check
// bails out before reading the (now-stale) blob.
self.emit(LDA, AM::Immediate(0));
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI));
}
}
// Mark sfx as active. The audio tick checks this and bails
// on zero. We use `$FF` (any nonzero value works) as a flag;
// the tick zeros it when it hits the envelope sentinel.

View file

@ -489,8 +489,13 @@ impl Linker {
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");
let has_sfx_pitch = has_label(user_code, "__sfx_pitch_used");
if has_audio {
all_instructions.extend(runtime::gen_audio_tick(has_noise, has_triangle));
all_instructions.extend(runtime::gen_audio_tick(
has_noise,
has_triangle,
has_sfx_pitch,
));
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
@ -500,6 +505,18 @@ impl Linker {
&blob.label(),
blob.envelope.clone(),
));
// Optional pitch envelope blob. Only emitted for
// sfx the compiler decided actually need per-frame
// pitch updates — the pitch_envelope is empty for
// single-pitch sfx and the `gen_data_block` call
// is skipped, keeping ROM bytes identical to the
// pre-pitch-envelope behaviour.
if blob.has_pitch_envelope() {
all_instructions.extend(runtime::gen_data_block(
&blob.pitch_label(),
blob.pitch_envelope.clone(),
));
}
}
// Same for music: label + note stream.
for blob in music {

View file

@ -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],
pitch_envelope: Vec::new(),
channel: Channel::Pulse1,
}];
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
@ -239,6 +240,7 @@ fn link_with_audio_resolves_sfx_pointer_references() {
period_lo: 0x50,
period_hi: 0x08,
envelope: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00],
pitch_envelope: Vec::new(),
channel: Channel::Pulse1,
}];
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
@ -282,6 +284,7 @@ fn link_without_audio_marker_does_not_emit_period_table() {
period_lo: 0,
period_hi: 0,
envelope: vec![0xAA, 0xBB, 0x00],
pitch_envelope: Vec::new(),
channel: Channel::Pulse1,
}],
&[],

View file

@ -131,6 +131,16 @@ 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;
/// Pulse-1 sfx per-frame pitch envelope pointer. Only populated
/// (and only read by the audio tick) in programs that declare at
/// least one sfx with a varying-pitch `pitch:` array; programs
/// that stick to scalar `pitch:` keep their byte-for-byte
/// pre-pitch-envelope ROM output. The tick treats a zero
/// high-byte as "no pitch update for the currently-playing sfx",
/// which lets a single program mix sfx with and without pitch
/// envelopes.
pub const AUDIO_SFX_PITCH_PTR_LO: u16 = 0x07F6;
pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7;
/// Generate the NES hardware initialization sequence.
/// This runs at RESET and sets up the hardware before user code.
@ -778,7 +788,11 @@ pub fn gen_multiply() -> Vec<Instruction> {
/// 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> {
pub fn gen_audio_tick(
has_noise: bool,
has_triangle: bool,
has_sfx_pitch: bool,
) -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
@ -818,6 +832,61 @@ pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
NOP,
AM::Label("__audio_sfx_ptr_ok".into()),
));
// Optional per-frame pitch update. Only emitted in programs
// that declare at least one varying-pitch sfx, gated on the
// `__sfx_pitch_used` codegen marker. The block reads a byte
// through (AUDIO_SFX_PITCH_PTR),Y, writes it to `$4002`
// (pulse-1 period low), then advances the pointer in
// lockstep with the volume envelope above. A zero high byte
// in the pointer is treated as "no pitch envelope on the
// currently-playing sfx" so a program can mix scalar-pitch
// and varying-pitch sfx without the latter clobbering the
// former when it isn't playing.
//
// The pointer lives in main RAM (no zero-page slot pressure)
// and is copied into ZP scratch $02/$03 for the indirect
// read because the 6502 has no `(abs),Y` mode — the same
// technique used by `gen_noise_tick` / `gen_triangle_tick`.
if has_sfx_pitch {
// Bail out if the high byte is zero — sentinel for "the
// currently-playing sfx has no pitch envelope".
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_sfx_pitch_done".into()),
));
// Stash main-RAM pointer in ZP scratch $02/$03.
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_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)));
// Zero pitch byte? Treat it as the matching mute sentinel
// (volume envelope's zero will already mute on the next
// tick) and skip the period write so we don't yank pulse-1
// to a 0 period for one frame before muting.
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_sfx_pitch_advance".into()),
));
out.push(Instruction::new(STA, AM::Absolute(0x4002)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_pitch_advance".into()),
));
// Advance the main-RAM pointer 1 byte.
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_sfx_pitch_done".into()),
));
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_pitch_done".into()),
));
}
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into())));
// ── Music tick ──

View file

@ -195,7 +195,7 @@ fn multiply_routine_assembles() {
#[test]
fn audio_tick_defines_required_labels() {
let tick = gen_audio_tick(false, false);
let tick = gen_audio_tick(false, 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(false, false);
let tick = gen_audio_tick(false, 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(false, false);
let tick = gen_audio_tick(false, false, false);
let has_load = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
@ -241,7 +241,7 @@ 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(false, false);
let tick = gen_audio_tick(false, false, false);
let has_store = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000));
@ -254,7 +254,7 @@ fn audio_tick_noise_block_writes_400c_when_enabled() {
// 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 tick = gen_audio_tick(true, false, false);
let has_label = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
@ -270,7 +270,7 @@ 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 tick = gen_audio_tick(false, false, false);
let has_label = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
@ -289,7 +289,7 @@ fn audio_tick_noise_block_absent_when_disabled() {
#[test]
fn audio_tick_triangle_block_writes_4008_when_enabled() {
let tick = gen_audio_tick(false, true);
let tick = gen_audio_tick(false, true, false);
let has_label = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
@ -305,7 +305,7 @@ fn audio_tick_triangle_block_writes_4008_when_enabled() {
#[test]
fn audio_tick_triangle_block_absent_when_disabled() {
let tick = gen_audio_tick(false, false);
let tick = gen_audio_tick(false, false, false);
let has_label = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
@ -316,12 +316,58 @@ fn audio_tick_triangle_block_absent_when_disabled() {
assert!(!has_4008);
}
#[test]
fn audio_tick_sfx_pitch_block_writes_4002_when_enabled() {
// With `has_sfx_pitch = true` the tick gains a per-frame pitch
// update path that writes to `$4002` (pulse-1 period low) on
// every NMI. The path also needs to read the pitch envelope
// pointer from main RAM, so we check both the absolute-RAM
// load and the period write.
let tick = gen_audio_tick(false, false, true);
let writes_4002 = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4002));
assert!(
writes_4002,
"sfx pitch tick path should write to $4002 (pulse-1 period low)"
);
let reads_pitch_ptr = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
assert!(
reads_pitch_ptr,
"sfx pitch tick path should LDA from AUDIO_SFX_PITCH_PTR_LO"
);
}
#[test]
fn audio_tick_sfx_pitch_block_absent_when_disabled() {
// Programs without varying-pitch sfx must not pay any bytes
// for the pitch update path, otherwise existing audio_demo /
// friendly_assets goldens would byte-shift.
let tick = gen_audio_tick(false, false, false);
let writes_4002 = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4002));
assert!(
!writes_4002,
"sfx pitch tick path must not appear when flag is off"
);
let reads_pitch_ptr = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
assert!(
!reads_pitch_ptr,
"AUDIO_SFX_PITCH_PTR_LO must not be touched when flag is off"
);
}
#[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);
let mut combined = gen_audio_tick(true, true, false);
combined.extend(gen_period_table());
let result = asm::assemble(&combined, 0xC000);
assert!(!result.bytes.is_empty());
@ -335,7 +381,7 @@ fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() {
// 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(false, false);
let tick = gen_audio_tick(false, false, false);
let has_mute = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
@ -356,7 +402,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(false, false);
let mut combined = gen_audio_tick(false, false, false);
combined.extend(gen_period_table());
let result = asm::assemble(&combined, 0xC000);
assert!(

View file

@ -0,0 +1 @@
61bd9e17 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B