1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +00:00

audio: complete the subsystem — asset pipeline, user decls, tracker-style driver

The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.

This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:

## Asset pipeline (new `src/assets/audio.rs`)

- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
  pulse-1 envelopes. Pitch/volume arrays must match in length; each
  entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
  flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
  1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
  `start_music` references and append builtin fallbacks for any
  name that isn't user-declared — so `play coin` still works
  without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
  and tracks (theme, battle, victory, gameover) synthesize through
  the same compile path as user decls — one data model, one driver.

## Runtime engine (`src/runtime/mod.rs`)

- `gen_audio_tick()` walks both channels every NMI: reads one
  envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
  advances ptr, mutes on zero sentinel. Music decrements the note
  counter, advances to the next `(pitch, dur)` pair on zero, looks
  up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
  (A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
  load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
  user sfx/music data can be spliced into PRG with regular labels
  that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
  (duty/volume/loop/active), `$0C-$0F` sfx and music pointers.

## IR codegen (`src/codegen/ir_codegen.rs`)

- `with_audio(sfx, music)` registers compile-time trigger constants
  per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
  envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
  `__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
  with the active bit OR'd in, seeds both ptr and loop base from
  `__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.

## Linker (`src/linker/mod.rs`)

- New `link_with_all_assets(user_code, sprites, sfx, music)` path
  that splices driver body, period table, and each sfx/music data
  blob into PRG — all guarded on the `__audio_used` marker so
  silent programs pay zero ROM cost.

## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)

- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
  pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
  letting the linker splice ROM data tables into a code section
  and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
  correctly in the same assembly pass.

## Analyzer

- `play` / `start_music` now validate the name against user decls
  and builtin tables. Unknown names emit E0505 with a helpful list
  of builtins — previously a typo would silently compile to no-op.

## Parser

- New `sfx_decl` / `music_decl` grammar with property-style
  configuration. Strict validation: duty 0-3, volume 0-15, pitch
  arrays must match volume length, music notes must come in pairs,
  pitch 0-60, duration ≥ 1.

## Tests

+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
  shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
  declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
  acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
  $4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
  length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
  pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
  to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
  user-declared `sfx` + `music` program that verifies bytes land
  in PRG ROM at the right addresses

## Docs

- New Audio section in `docs/language-guide.md` with syntax
  reference, builtin tables, and an explanation of how the
  driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
  pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
  "status: full subsystem" with a narrower list of follow-up work
  (triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
  `sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
  builtin fallback via `play coin`.

Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.

https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
This commit is contained in:
Claude 2026-04-13 01:10:21 +00:00
parent c5c8c38a54
commit d42540f45e
No known key found for this signature in database
22 changed files with 2865 additions and 243 deletions

View file

@ -25,14 +25,38 @@ pub const ZP_INPUT_P2: u8 = 0x08;
/// the oldest slot gets overwritten — the classic NES flicker
/// fallback.
pub const ZP_OAM_CURSOR: u8 = 0x09;
/// Pulse-1 SFX countdown frames. `play SfxName` sets this to the
/// SFX duration and writes the tone registers; the NMI audio tick
/// decrements it every frame, silencing pulse 1 when it reaches 0.
/// Pulse-1 SFX envelope pointer (2 bytes, lo/hi) — points at the
/// *current* frame's $4000 envelope byte inside the sfx blob. The
/// audio tick reads through this byte, writes to $4000, advances
/// the pointer, and keeps going until it reads a zero sentinel.
pub const ZP_SFX_PTR_LO: u8 = 0x0C;
pub const ZP_SFX_PTR_HI: u8 = 0x0D;
/// Pulse-2 music note-stream pointer (2 bytes, lo/hi) — points at
/// the *current* (pitch, duration) note pair inside the music blob.
pub const ZP_MUSIC_PTR_LO: u8 = 0x0E;
pub const ZP_MUSIC_PTR_HI: u8 = 0x0F;
/// Music base pointer (2 bytes) — start of the currently-loaded
/// track. Used by the loop-back branch when the driver hits the
/// end-of-track sentinel and the header loop flag is set.
pub const ZP_MUSIC_BASE_LO: u8 = 0x05;
pub const ZP_MUSIC_BASE_HI: u8 = 0x06;
/// Music state byte. Bit layout:
/// bit 0: 1 = track is looping, 0 = one-shot
/// bit 1: 1 = music is active (non-zero means "playing")
/// bits 2-5: latched pulse-2 envelope volume 0-15
/// bits 6-7: latched pulse-2 duty
/// Set on `start_music`, cleared (to 0) on `stop_music`. The driver
/// writes a fresh $4004 envelope byte every time it advances to a
/// new note using these bits so held notes don't decay.
pub const ZP_MUSIC_STATE: u8 = 0x07;
/// Pulse-1 SFX countdown — `0` means no sfx is playing.
/// Nonzero means the audio tick should read one envelope byte from
/// `ZP_SFX_PTR` each NMI and write it to $4000. When the tick reads
/// a zero sentinel it mutes pulse 1 and clears this byte.
pub const ZP_SFX_COUNTER: u8 = 0x0A;
/// Pulse-2 music countdown frames. `start_music TrackName` sets
/// this to `$FF` (infinite sustain) and writes a pulse-2 tone;
/// `stop_music` zeros it and mutes pulse 2. `$FF` skips the NMI
/// decrement so music plays until explicitly stopped.
/// Pulse-2 music duration countdown — frames remaining on the
/// currently-held music note. When it reaches zero, the tick
/// advances to the next (pitch, duration) pair.
pub const ZP_MUSIC_COUNTER: u8 = 0x0B;
/// Generate the NES hardware initialization sequence.
@ -256,46 +280,338 @@ pub fn gen_multiply() -> Vec<Instruction> {
out
}
/// Generate the per-NMI audio tick that ages the SFX counter and
/// silences pulse 1 when the counter hits zero. Music on pulse 2
/// uses the sentinel `$FF` for infinite sustain and is never
/// decremented here — `stop_music` handles mute explicitly.
/// Generate the per-NMI audio tick. This is the heart of the audio
/// driver — it walks both the SFX envelope and the music note stream
/// every frame and writes the resulting APU register values.
///
/// The linker splices a `JSR __audio_tick` into the NMI handler
/// whenever user code contains any audio ops, so programs that
/// never call `play`/`start_music`/`stop_music` pay zero cost.
/// whenever user code contains any audio op (detected by the
/// `__audio_used` marker label), so programs that never call
/// `play`/`start_music`/`stop_music` pay zero ROM or cycle cost.
///
/// Contract:
/// - Input: `ZP_SFX_COUNTER` = remaining frames for pulse 1's tone
/// - Effect: decrements the counter; on 0→transition mutes $4000
/// - Clobbers: A (which the NMI handler restores via PLA)
/// ## SFX channel (pulse 1)
///
/// State:
/// - `ZP_SFX_COUNTER` — nonzero while an sfx is playing
/// - `ZP_SFX_PTR_LO/HI` — pointer into the current sfx blob,
/// advanced one byte per frame
///
/// Each frame: if the counter is nonzero, read one byte through the
/// pointer, write it to `$4000`, and advance the pointer. A zero
/// byte is the sentinel; on it the driver mutes pulse 1 and clears
/// the counter.
///
/// ## Music channel (pulse 2)
///
/// State:
/// - `ZP_MUSIC_COUNTER` — frames remaining on the current note
/// - `ZP_MUSIC_STATE` — bit 1 set = active; bits encode duty/volume/loop
/// - `ZP_MUSIC_PTR_LO/HI` — pointer to the next (pitch,dur) pair
/// - `ZP_MUSIC_BASE_LO/HI` — loop-back start of the current track
///
/// Each frame: if the state says "active" and the counter is nonzero,
/// decrement the counter and bail. When it hits zero, advance past
/// the current (pitch,dur) pair and read the next one. `0xFF,0xFF`
/// is the end-of-track sentinel; the driver either rewinds to the
/// base pointer (looping tracks) or mutes pulse 2 (one-shot tracks).
///
/// ## Clobbers
///
/// A, X, Y. The NMI handler calls this from inside its own
/// save/restore block so caller registers are safe.
pub fn gen_audio_tick() -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
// SFX counter check: if 0, nothing to do.
// ── SFX tick ──
// If counter is zero, no sfx is playing; skip.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_SFX_COUNTER)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_tick_done".into()),
AM::LabelRelative("__audio_sfx_done".into()),
));
out.push(Instruction::new(DEC, AM::ZeroPage(ZP_SFX_COUNTER)));
// If still non-zero, leave the tone alone.
// Read next envelope byte via (ZP_SFX_PTR),Y with Y=0.
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_SFX_PTR_LO)));
// If it's the zero sentinel, silence pulse 1 and clear state.
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_tick_done".into()),
AM::LabelRelative("__audio_sfx_write".into()),
));
// Counter just hit 0: silence pulse 1 (volume envelope = mute).
// Sentinel branch: write mute byte to $4000 and clear counter.
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_SFX_COUNTER)));
out.push(Instruction::new(JMP, AM::Label("__audio_sfx_done".into())));
// Non-sentinel branch: write envelope byte to $4000, advance ptr.
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_write".into())));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
// Advance the 16-bit pointer (lo, hi) by 1.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_sfx_ptr_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_SFX_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_sfx_ptr_ok".into()),
));
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into())));
out.push(Instruction::new(NOP, AM::Label("__audio_tick_done".into())));
// ── Music tick ──
// Bit 1 of ZP_MUSIC_STATE is "music is active". If clear, skip.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x02)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_done".into()),
));
// Active. Decrement the note counter; if nonzero after, bail.
out.push(Instruction::new(DEC, AM::ZeroPage(ZP_MUSIC_COUNTER)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_done".into()),
));
// Counter just hit zero — time to advance. Fall through to the
// "advance to next note" block below. The runtime calls this
// block from two places: end-of-note and start_music (which sets
// counter=0 then jumps here to trigger the first note).
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_advance".into()),
));
// Read the next pitch byte. LDA sets Z based on the value so
// we can dispatch on it cheaply:
// pitch == 0 → rest (fall through to __rest)
// pitch == 0xFF → sentinel (BNE past rest, then CMP + BEQ)
// otherwise → pitched (fall through to __pitched)
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO)));
// Zero? → rest branch (mute pulse 2, skip period lookup).
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_not_rest".into()),
));
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_load_dur".into()),
));
// Not zero — check sentinel, otherwise it's a real note.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_not_rest".into()),
));
out.push(Instruction::new(CMP, AM::Immediate(0xFF)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_eot".into()),
));
// Fall through to the pitched branch — A still holds pitch.
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_pitched".into()),
));
// Pitched branch: A already holds pitch (1..=60). Index the
// period table and write $4006 (period lo) and $4007 (period
// hi + length counter). Each table entry is 2 bytes.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_pitched".into()),
));
// Rewrite envelope byte ($4004) from music state so we don't
// depend on pulse-2 length counter. Extract duty (bits 6-7) and
// volume (bits 2-5) from state, shift into position, OR with $30
// (length-halt + constant volume), write $4004.
//
// Save pitch in X so we still have it for the period lookup.
out.push(Instruction::new(TAX, AM::Implied));
// Build envelope byte.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0xC0))); // keep duty bits
out.push(Instruction::new(STA, AM::ZeroPage(0x04))); // scratch
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x3C))); // keep volume<<2
out.push(Instruction::new(LSR, AM::Accumulator));
out.push(Instruction::new(LSR, AM::Accumulator));
out.push(Instruction::new(ORA, AM::ZeroPage(0x04)));
out.push(Instruction::new(ORA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
// Period lookup via a ZP pointer. X holds pitch (1..=60).
//
// 1. Set (ZP_SCRATCH = __period_table).
// 2. A = (pitch - 1) * 2 — byte offset in the 2-byte-per-entry
// table.
// 3. Y = A.
// 4. LDA (ZP_SCRATCH),Y → period_lo → STA $4006.
// 5. INY; LDA (ZP_SCRATCH),Y → period_hi → STA $4007.
//
// `$02`/`$03` are the multiply/divide scratch slots but the NMI
// audio tick never calls mul/div, so they're free to reuse here.
// A proper `Absolute,Y` addressing mode with a symbolic label
// would save the pointer setup, but our asm layer doesn't have
// that yet and the extra 8 cycles per frame are negligible.
out.push(Instruction::new(LDA, AM::SymbolLo("__period_table".into())));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
out.push(Instruction::new(LDA, AM::SymbolHi("__period_table".into())));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::new(TXA, AM::Implied));
out.push(Instruction::new(SEC, AM::Implied));
out.push(Instruction::new(SBC, AM::Immediate(1)));
out.push(Instruction::new(ASL, AM::Accumulator));
out.push(Instruction::new(TAY, AM::Implied));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
out.push(Instruction::new(STA, AM::Absolute(0x4006)));
out.push(Instruction::new(INY, AM::Implied));
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
// The period-table high byte already has the length-counter
// load bits baked in (see `gen_period_table`), so a raw store
// here retriggers the note. But retriggering every time the
// duration expires is fine — it's how trackers work.
out.push(Instruction::new(STA, AM::Absolute(0x4007)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_load_dur".into()),
));
// Advance pointer past the pitch byte we just consumed.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_dur_hi_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_dur_hi_ok".into()),
));
// Read duration through the advanced pointer and stash it in
// ZP_MUSIC_COUNTER.
out.push(Instruction::new(LDY, AM::Immediate(0)));
out.push(Instruction::new(LDA, AM::IndirectY(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)));
// Advance past the duration byte.
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_music_ptr2_ok".into()),
));
out.push(Instruction::new(INC, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_ptr2_ok".into()),
));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_done".into()),
));
// ── End-of-track branch ──
out.push(Instruction::new(NOP, AM::Label("__audio_music_eot".into())));
// Check loop flag (bit 0 of ZP_MUSIC_STATE). If set, rewind ptr
// to base and re-enter the advance path. Otherwise stop.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(AND, AM::Immediate(0x01)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_music_stop".into()),
));
// Looping: copy base pointer back into current pointer and
// re-enter the advance path.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_LO)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_LO)));
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUSIC_BASE_HI)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_PTR_HI)));
out.push(Instruction::new(
JMP,
AM::Label("__audio_music_advance".into()),
));
// Non-looping stop: mute pulse 2 and clear music state.
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_stop".into()),
));
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
out.push(Instruction::new(LDA, AM::Immediate(0)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_STATE)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUSIC_COUNTER)));
out.push(Instruction::new(
NOP,
AM::Label("__audio_music_done".into()),
));
out.push(Instruction::implied(RTS));
out
}
/// Generate the builtin period table that the music tick uses to
/// translate note indices into pulse-channel period values. The
/// table covers five octaves (C1B5) for 60 entries, 2 bytes each.
///
/// Entry 0 is `C1` (index 1 in user notes), entry 59 is `B5` (index
/// 60). Pitch 0 is the "rest" sentinel and is not present in the
/// table — the driver handles rests before indexing.
///
/// The high byte of each entry is `((period >> 8) & 0x07) | 0x08`.
/// Setting bit 3 pre-loads the length counter to index 1 (254 frames)
/// so any note held beyond the envelope will still play out naturally
/// when the track later falls into a rest — without this, pulse 2
/// would silence itself after ~4 frames on hardware.
#[must_use]
pub fn gen_period_table() -> Vec<Instruction> {
// NTSC CPU = 1.789773 MHz. Pulse channel frequency:
// f = CPU / (16 * (period + 1))
// Solving for period given a target frequency f:
// period = CPU / (16 * f) - 1
//
// We compute the 60 entries once at build time (here) using
// equal-tempered tuning anchored at A4 = 440 Hz.
const CPU: f64 = 1_789_773.0;
const A4_HZ: f64 = 440.0;
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__period_table".into())));
// Semitone offset from A4 for index `i` (0-based from C1).
// A4 is MIDI 69. C1 is MIDI 24. So semitones from A4 to C1 is
// -45 — our table starts at C1 so `offset(i) = i - 45`.
let mut bytes: Vec<u8> = Vec::with_capacity(120);
for i in 0i32..60 {
let semitone_offset = f64::from(i - 45);
let freq = A4_HZ * 2f64.powf(semitone_offset / 12.0);
let period_f = CPU / (16.0 * freq) - 1.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let period = period_f.round().clamp(0.0, 2047.0) as u16;
let lo = (period & 0xFF) as u8;
// High 3 bits of period + length counter load bits.
// 0x08 = length counter index 1 = 254 frames.
let hi = ((period >> 8) as u8 & 0x07) | 0x08;
bytes.push(lo);
bytes.push(hi);
}
out.push(Instruction::new(NOP, AM::Bytes(bytes)));
out
}
/// Generate a labelled data block emitting `bytes` verbatim into the
/// ROM at the address the assembler places this block. Used by the
/// linker to splice compiled sfx and music blobs into the code
/// section so that `LDA #<Name; STA ptr_lo` from the IR codegen can
/// resolve to the right in-ROM address.
#[must_use]
pub fn gen_data_block(label: &str, bytes: Vec<u8>) -> Vec<Instruction> {
vec![
Instruction::new(NOP, AM::Label(label.to_string())),
Instruction::new(NOP, AM::Bytes(bytes)),
]
}
/// Generate 8 / 8 -> 8 software divide routine (restoring division).
///
/// Input: A = dividend, zero-page $02 = divisor

View file

@ -164,6 +164,195 @@ fn multiply_routine_assembles() {
);
}
// ── Audio driver tests ──
#[test]
fn audio_tick_defines_required_labels() {
let tick = gen_audio_tick();
// The IR codegen JSRs into `__audio_tick`; that's the entry.
let has_entry = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_tick"));
assert!(has_entry, "audio tick must define __audio_tick entry label");
// The tick references `__period_table` via SymbolLo/SymbolHi —
// the period table itself is linked in separately.
let refs_period = tick.iter().any(|i| {
matches!(&i.mode, AM::SymbolLo(n) if n == "__period_table")
|| matches!(&i.mode, AM::SymbolHi(n) if n == "__period_table")
});
assert!(
refs_period,
"audio tick must reference the __period_table label"
);
}
#[test]
fn audio_tick_ends_with_rts() {
let tick = gen_audio_tick();
assert_eq!(
tick.last().unwrap().opcode,
RTS,
"audio tick must return to caller"
);
}
#[test]
fn audio_tick_reads_sfx_envelope_via_indirect_y() {
// The sfx branch walks the envelope via (ZP_SFX_PTR_LO),Y with
// Y=0 — each NMI reads one byte through the pointer and writes
// it to $4000. Verify the indirect-indexed load is present.
let tick = gen_audio_tick();
let has_load = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
assert!(
has_load,
"audio tick must read envelope via (ZP_SFX_PTR_LO),Y"
);
}
#[test]
fn audio_tick_writes_pulse1_envelope_register() {
// After reading the envelope byte the tick writes it to $4000.
let tick = gen_audio_tick();
let has_store = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000));
assert!(has_store, "audio tick must write pulse-1 envelope to $4000");
}
#[test]
fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() {
// When a non-looping track hits the (0xFF, 0xFF) sentinel, the
// tick writes $30 to $4004 and clears ZP_MUSIC_STATE. We verify
// the mute path exists by checking both writes exist somewhere
// in the tick body.
let tick = gen_audio_tick();
let has_mute = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
assert!(has_mute, "audio tick must mute pulse-2 on end-of-track");
let has_state_clear = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_MUSIC_STATE));
assert!(
has_state_clear,
"audio tick must clear ZP_MUSIC_STATE on stop"
);
}
#[test]
fn audio_tick_assembles_without_error() {
// Splice the period table into the same assembly pass so the
// tick's SymbolLo/SymbolHi references resolve. The tick also
// uses label-relative branches internally which need to fit
// within ±127 bytes — if the body grows past that the branches
// will panic at assemble time.
let mut combined = gen_audio_tick();
combined.extend(gen_period_table());
let result = asm::assemble(&combined, 0xC000);
assert!(
!result.bytes.is_empty(),
"audio tick + period table should assemble"
);
assert!(
result.labels.contains_key("__audio_tick"),
"audio tick entry label should be exported"
);
assert!(
result.labels.contains_key("__period_table"),
"period table label should be exported"
);
}
#[test]
fn period_table_has_60_entries_of_2_bytes() {
// The table covers C1..B5 inclusive = 60 semitones, 2 bytes
// each for period_lo and period_hi. Total = 120 data bytes
// plus the leading label pseudo-instruction.
let table = gen_period_table();
// Count the raw bytes in the single `Bytes` block.
let total: usize = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.len()),
_ => None,
})
.sum();
assert_eq!(total, 120, "period table should be 60 entries × 2 bytes");
}
#[test]
fn period_table_high_bytes_include_length_counter_bit() {
// Every period_hi byte must have bit 3 set ($08) so the length
// counter holds the note indefinitely. Without that bit, pulse
// 2 would silence after a few frames.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
for (i, chunk) in bytes.chunks(2).enumerate() {
let hi = chunk[1];
assert!(
hi & 0x08 != 0,
"period table entry {i} high byte ${hi:02X} missing length-counter bit"
);
}
}
#[test]
fn period_table_a4_matches_440hz() {
// Entry for A4 should produce ~253 period. Sanity check the
// rounding: CPU/(16*440)-1 ≈ 253.12.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
// A4 is semitone 69 in MIDI. C1 is MIDI 24 (entry 0 in the
// table). A4 = entry 69 - 24 = 45. Each entry is 2 bytes.
let lo = bytes[45 * 2];
let hi = bytes[45 * 2 + 1] & 0x07; // strip length-counter bit
let period = u16::from_le_bytes([lo, hi]);
// Expect period ≈ 253 (±1 for rounding).
assert!(
(252..=254).contains(&period),
"A4 period {period} should be ~253"
);
}
#[test]
fn gen_data_block_emits_label_and_bytes() {
let block = gen_data_block("__sfx_test", vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(block.len(), 2);
assert!(matches!(&block[0].mode, AM::Label(n) if n == "__sfx_test"));
match &block[1].mode {
AM::Bytes(v) => assert_eq!(v, &[0xDE, 0xAD, 0xBE, 0xEF]),
other => panic!("expected Bytes, got {other:?}"),
}
}
#[test]
fn data_block_assembles_verbatim() {
// A labelled data block must emit exactly the payload bytes
// (no opcode prefix) and register the label at the payload's
// address. Verifies the `NOP+Bytes` pseudo doesn't accidentally
// get wrapped with an instruction byte.
let block = gen_data_block("__test", vec![0x11, 0x22, 0x33]);
let result = asm::assemble(&block, 0x8000);
assert_eq!(result.bytes, vec![0x11, 0x22, 0x33]);
assert_eq!(result.labels.get("__test").copied(), Some(0x8000));
}
#[test]
fn divide_routine_assembles() {
let div = gen_divide();