1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 17:52:51 +00:00
nescript/src/runtime/tests.rs

388 lines
12 KiB
Rust
Raw Normal View History

Implement NEScript compiler Milestone 1 ("Hello Sprite") Complete implementation of the NEScript compiler pipeline for M1: - Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators - Parser: recursive descent with Pratt expression parsing (M1 subset) - Analyzer: symbol resolution, type checking, memory allocation - 6502 Assembler: full opcode encoding table (~150 valid combinations) - Code Generator: AST → 6502 instructions (direct, no IR for M1) - Runtime: NES hardware init, NMI handler, controller read, OAM DMA - Linker: NROM layout, vector table, palette loading, CHR data - ROM Builder: iNES header generation, PRG/CHR padding - CLI: `build` and `check` subcommands via clap 143 tests across all modules: - 22 lexer tests (literals, keywords, operators, error recovery) - 18 parser tests (expressions, statements, game structure, errors) - 7 analyzer tests (symbol resolution, memory allocation, transitions) - 30 assembler tests (every addressing mode, label resolution) - 7 codegen tests (var init, arithmetic, buttons, draw, comparisons) - 11 runtime tests (init sequence, NMI handler, controller read) - 10 ROM builder tests (iNES format, mirroring, banking, validation) - 5 linker tests (vector table, CHR data, palette loading) - 7 integration tests (end-to-end compilation, error detection) CI: GitHub Actions for check, fmt, clippy, test Pre-commit: script for local fmt + clippy + test validation https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
use super::*;
use crate::asm;
use crate::asm::{AddressingMode as AM, Opcode::*};
#[test]
fn init_disables_irq() {
let init = gen_init();
assert_eq!(init[0].opcode, SEI);
}
#[test]
fn init_sets_stack_pointer() {
let init = gen_init();
// LDX #$FF, TXS
let has_ldx = init
.iter()
.any(|i| i.opcode == LDX && i.mode == AM::Immediate(0xFF));
let has_txs = init.iter().any(|i| i.opcode == TXS);
assert!(has_ldx, "should load $FF into X");
assert!(has_txs, "should transfer X to stack pointer");
}
#[test]
fn init_disables_ppu() {
let init = gen_init();
// Should write 0 to $2000 and $2001
let writes_ppu_ctrl = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2000));
let writes_ppu_mask = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2001));
assert!(writes_ppu_ctrl, "should disable PPU control");
assert!(writes_ppu_mask, "should disable PPU mask");
}
#[test]
fn init_enables_nmi_at_end() {
let init = gen_init();
// Last STA $2000 should enable NMI (bit 7 set = 0x80)
let nmi_writes: Vec<_> = init
.iter()
.enumerate()
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x2000))
.collect();
assert!(
nmi_writes.len() >= 2,
"should write to PPU_CTRL at least twice"
);
// The last write should be preceded by LDA #$80
let last_write_idx = nmi_writes.last().unwrap().0;
assert!(last_write_idx > 0);
assert_eq!(init[last_write_idx - 1].opcode, LDA);
assert_eq!(init[last_write_idx - 1].mode, AM::Immediate(0x80));
}
#[test]
fn init_assembles_without_error() {
let init = gen_init();
let result = asm::assemble(&init, 0x8000);
// Should produce non-empty output
assert!(!result.bytes.is_empty(), "init should produce bytes");
// Should be under 200 bytes (the plan estimates ~80)
assert!(
result.bytes.len() < 200,
"init is {} bytes, expected < 200",
result.bytes.len()
);
}
#[test]
fn nmi_saves_and_restores_registers() {
let nmi = gen_nmi();
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
assert_eq!(nmi[2].opcode, PHA);
assert_eq!(nmi[3].opcode, TYA);
assert_eq!(nmi[4].opcode, PHA);
// Last instructions should restore and RTI
let len = nmi.len();
assert_eq!(nmi[len - 1].opcode, RTI);
assert_eq!(nmi[len - 2].opcode, PLA);
}
#[test]
fn nmi_triggers_oam_dma() {
let nmi = gen_nmi();
let has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
assert!(has_dma, "NMI should trigger OAM DMA");
}
#[test]
fn nmi_reads_controller() {
let nmi = gen_nmi();
// Should write strobe to $4016
let has_strobe = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016));
assert!(has_strobe, "NMI should strobe controller");
}
#[test]
fn nmi_sets_frame_flag() {
let nmi = gen_nmi();
let has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
assert!(has_flag, "NMI should set frame-ready flag");
}
#[test]
fn nmi_assembles_without_error() {
let nmi = gen_nmi();
let result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(
result.bytes.len() < 150,
"NMI handler is {} bytes, expected < 150",
result.bytes.len()
);
}
#[test]
fn irq_handler_is_just_rti() {
let irq = gen_irq();
assert_eq!(irq.len(), 1);
assert_eq!(irq[0].opcode, RTI);
}
#[test]
fn multiply_routine_assembles() {
let mul = gen_multiply();
// Should have a reasonable number of instructions
assert!(
mul.len() > 5,
"multiply routine too short: {} instructions",
mul.len()
);
let result = asm::assemble(&mul, 0x8000);
assert!(
!result.bytes.is_empty(),
"multiply routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"multiply routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __multiply label
assert!(
result.labels.contains_key("__multiply"),
"should define __multiply label"
);
// Should end with RTS
assert_eq!(
mul.last().unwrap().opcode,
RTS,
"multiply routine should end with RTS"
);
}
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
2026-04-13 01:10:21 +00:00
// ── 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();
// Should have a reasonable number of instructions
assert!(
div.len() > 5,
"divide routine too short: {} instructions",
div.len()
);
let result = asm::assemble(&div, 0x8000);
assert!(
!result.bytes.is_empty(),
"divide routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"divide routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __divide label
assert!(
result.labels.contains_key("__divide"),
"should define __divide label"
);
// Should end with RTS
assert_eq!(
div.last().unwrap().opcode,
RTS,
"divide routine should end with RTS"
);
}