1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 09:42:37 +00:00
nescript/src/analyzer/tests.rs

971 lines
23 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::errors::ErrorCode;
use crate::parser;
fn analyze_ok(input: &str) -> AnalysisResult {
let (prog, diags) = parser::parse(input);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let result = analyze(&prog);
assert!(
result.diagnostics.iter().all(|d| !d.is_error()),
"analysis errors: {:?}",
result.diagnostics
);
result
}
fn analyze_errors(input: &str) -> Vec<ErrorCode> {
let (prog, parse_diags) = parser::parse(input);
if prog.is_none() {
return parse_diags.into_iter().map(|d| d.code).collect();
}
let result = analyze(&prog.unwrap());
result.diagnostics.into_iter().map(|d| d.code).collect()
}
#[test]
fn analyze_minimal_program() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
var px: u8 = 128
on frame { px = 1 }
start Main
"#,
);
assert!(result.symbols.contains_key("px"));
assert_eq!(result.var_allocations.len(), 1);
}
#[test]
fn analyze_allocates_zero_page() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
var y: u8 = 0
on frame { x = 1 }
start Main
"#,
);
// u8 vars should be allocated in zero page starting at $10
assert_eq!(result.var_allocations[0].address, 0x10);
assert_eq!(result.var_allocations[1].address, 0x11);
}
#[test]
fn analyze_duplicate_var() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
var x: u8 = 1
on frame { x = 1 }
start Main
"#,
);
assert!(errors.contains(&ErrorCode::E0501));
}
#[test]
fn analyze_undefined_transition() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { transition Nonexistent }
}
start Main
"#,
);
assert!(errors.contains(&ErrorCode::E0404));
}
#[test]
fn analyze_valid_transition() {
let _result = analyze_ok(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { transition Other }
}
state Other {
on frame { wait_frame }
}
start Main
"#,
);
}
#[test]
fn analyze_start_state_exists() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { wait_frame }
}
start Nonexistent
"#,
);
assert!(errors.contains(&ErrorCode::E0404));
}
#[test]
fn analyze_const_symbol() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
const SPEED: u8 = 2
var px: u8 = 0
on frame { px = SPEED }
start Main
"#,
);
let sym = result.symbols.get("SPEED").unwrap();
assert!(sym.is_const);
}
#[test]
fn analyze_function_registered() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
fun add(a: u8, b: u8) -> u8 { return a }
on frame { wait_frame }
start Main
"#,
);
assert!(result.symbols.contains_key("add"));
}
#[test]
fn analyze_recursion_detected() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun a() { a() }
on frame { wait_frame }
start Main
"#,
);
assert!(errors.contains(&ErrorCode::E0402));
}
#[test]
fn analyze_mutual_recursion() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun a() { b() }
fun b() { a() }
on frame { wait_frame }
start Main
"#,
);
assert!(errors.contains(&ErrorCode::E0402));
}
#[test]
fn analyze_call_depth_ok() {
// 3 levels of nesting — well within the default limit of 8
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
fun c() { wait_frame }
fun b() { c() }
fun a() { b() }
on frame { a() }
start Main
"#,
);
// The frame handler's depth should be <= 8
for &depth in result.max_depths.values() {
assert!(depth <= 8, "depth {depth} should be within limit");
}
}
#[test]
fn analyze_call_depth_exceeded() {
// Build a call chain deeper than 8: f1 -> f2 -> ... -> f10
let result = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun f10() { wait_frame }
fun f9() { f10() }
fun f8() { f9() }
fun f7() { f8() }
fun f6() { f7() }
fun f5() { f6() }
fun f4() { f5() }
fun f3() { f4() }
fun f2() { f3() }
fun f1() { f2() }
on frame { f1() }
start Main
"#,
);
assert!(
result.contains(&ErrorCode::E0401),
"expected E0401 for exceeded call depth, got: {result:?}"
);
}
#[test]
fn analyze_undefined_function() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
on frame { no_such_fn() }
start Main
"#,
);
assert!(errors.contains(&ErrorCode::E0503));
}
Implement codegen for state dispatch, functions, arrays, math, scroll State machine dispatch: - Assign each state a numeric index, store in ZP $03 - Main loop dispatch table: CMP + BNE + JMP trampoline pattern (avoids branch range limits for large programs) - on_enter/on_exit handlers generated as JSR targets - Transition statement writes state index + JSR enter/exit handlers Function calls: - Function bodies emitted as labeled subroutines with RTS - Statement::Call generates parameter passing via ZP + JSR - Statement::Return generates RTS (with value in A if present) - Parameter slots at ZP $04-$07 Break/continue: - Loop stack tracks continue/break label pairs - Break generates JMP to break_label - Continue generates JMP to continue_label - While and Loop push/pop the stack Array indexing: - LValue::ArrayIndex generates TAX + STA absolute,X - Expr::ArrayIndex generates TAX + LDA absolute,X / ZP,X - Compound array assignments (+=, -=, &=, |=, ^=) load-modify-store Scroll: - scroll(x, y) writes to PPU $2005 twice (X then Y) Math: - Multiply generates JSR __multiply (shift-and-add routine) - Divide generates JSR __divide (restoring division) - Modulo loads remainder from $03 after divide - ShiftLeft generates ASL A, ShiftRight generates LSR A - Math routines wired into linker Error validations: - E0203 for assignment to const variables - Break/continue outside loop detection (in_loop tracking) 233 tests (8 new codegen + 2 analyzer + 2 integration), all passing. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 02:04:49 +00:00
#[test]
fn analyze_call_arity_mismatch() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun add(a: u8, b: u8) -> u8 { return a }
on frame { add(1) }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"calling with wrong argument count should produce E0203, got: {errors:?}"
);
}
#[test]
fn analyze_call_arity_ok() {
analyze_ok(
r#"
game "Test" { mapper: NROM }
fun add(a: u8, b: u8) -> u8 { return a }
on frame { add(1, 2) }
start Main
"#,
);
}
#[test]
fn analyze_call_arity_in_expr_context() {
// Calls used as expressions should also be checked.
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun two(a: u8, b: u8) -> u8 { return a }
var x: u8 = 0
on frame { x = two(1) }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"call arity error in expression context should still trigger E0203: {errors:?}"
);
}
#[test]
fn analyze_return_type_ok() {
analyze_ok(
r#"
game "Test" { mapper: NROM }
fun get_five() -> u8 { return 5 }
on frame { wait_frame }
start Main
"#,
);
}
#[test]
fn analyze_return_wrong_type() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun is_ok() -> bool { return 5 }
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0201),
"returning wrong type should produce E0201, got: {errors:?}"
);
}
#[test]
fn analyze_struct_variable_allocates_fields() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
struct Vec2 { x: u8, y: u8 }
var pos: Vec2
on frame {
pos.x = 10
pos.y = pos.x
}
start Main
"#,
);
// The analyzer should synthesize pos.x and pos.y as separate
// variables with consecutive addresses.
let px = result
.var_allocations
.iter()
.find(|a| a.name == "pos.x")
.expect("pos.x should be allocated");
let py = result
.var_allocations
.iter()
.find(|a| a.name == "pos.y")
.expect("pos.y should be allocated");
assert_eq!(py.address, px.address + 1);
}
#[test]
fn analyze_struct_unknown_field_errors() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
struct Vec2 { x: u8, y: u8 }
var pos: Vec2
on frame { pos.z = 5 }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0201),
"unknown field should emit E0201: {errors:?}"
);
}
#[test]
fn analyze_unknown_struct_type_errors() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
var pos: NoSuchStruct
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0201),
"unknown struct type should emit E0201: {errors:?}"
);
}
#[test]
fn analyze_assign_to_undefined_var_errors() {
// Assigning to an undeclared variable must produce E0502
// rather than silently creating the variable.
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
on frame { nope = 5 }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0502),
"assignment to undefined var should produce E0502, got: {errors:?}"
);
}
#[test]
fn analyze_enum_variants_as_constants() {
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
enum Color { Red, Green, Blue }
var c: u8 = Red
on frame {
if c == Blue { c = Green }
}
start Main
"#,
);
// Variants should be registered as constant symbols.
assert!(result.symbols.get("Red").is_some_and(|s| s.is_const));
assert!(result.symbols.get("Green").is_some_and(|s| s.is_const));
assert!(result.symbols.get("Blue").is_some_and(|s| s.is_const));
}
#[test]
fn analyze_duplicate_enum_variant_errors() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
enum A { Foo, Bar }
enum B { Baz, Bar }
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0501),
"duplicate variant should emit E0501, got: {errors:?}"
);
}
#[test]
fn analyze_dead_code_after_break() {
let src = r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame {
loop {
break
x += 1
}
}
start Main
"#;
let errors = analyze_errors(src);
assert!(
errors.contains(&ErrorCode::W0104),
"code after break should trigger W0104, got: {errors:?}"
);
}
#[test]
fn analyze_dead_code_after_transition() {
let src = r#"
game "Test" { mapper: NROM }
state A {
on frame {
transition B
wait_frame
}
}
state B { on frame { wait_frame } }
start A
"#;
let errors = analyze_errors(src);
assert!(
errors.contains(&ErrorCode::W0104),
"code after transition should trigger W0104, got: {errors:?}"
);
}
#[test]
fn analyze_dead_code_after_return_in_fn() {
let src = r#"
game "Test" { mapper: NROM }
fun foo() -> u8 {
return 5
return 6
}
on frame { wait_frame }
start Main
"#;
let errors = analyze_errors(src);
assert!(
errors.contains(&ErrorCode::W0104),
"code after return should trigger W0104, got: {errors:?}"
);
}
#[test]
fn analyze_ram_overflow_emits_e0301() {
// Two arrays totalling >2 KB cannot fit in NES RAM, triggering
// E0301 at allocation time.
let src = r#"
game "Test" { mapper: NROM }
var huge: u8[2000]
var also_huge: u8[2000]
on frame { wait_frame }
start Main
"#;
let errors = analyze_errors(src);
assert!(
errors.contains(&ErrorCode::E0301),
"RAM overflow should produce E0301, got: {errors:?}"
);
}
#[test]
fn analyze_expensive_multiply_warns() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
var a: u8 = 3
var b: u8 = 5
var c: u8 = 0
on frame { c = a * b }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0101),
"variable*variable multiply should emit W0101, got: {errors:?}"
);
}
#[test]
fn analyze_multiply_by_constant_ok() {
// Multiply by a literal is cheap (strength reduced to shifts).
analyze_ok(
r#"
game "Test" { mapper: NROM }
var a: u8 = 3
var c: u8 = 0
on frame { c = a * 4 }
start Main
"#,
);
}
#[test]
fn analyze_on_scanline_requires_mmc3() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { wait_frame }
on scanline(120) { scroll(0, 0) }
}
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"on scanline without MMC3 should produce E0203, got: {errors:?}"
);
}
#[test]
fn analyze_on_scanline_mmc3_ok() {
analyze_ok(
r#"
game "Test" { mapper: MMC3 }
state Main {
on frame { wait_frame }
on scanline(120) { scroll(0, 0) }
}
start Main
"#,
);
}
#[test]
fn analyze_loop_without_exit_warns() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame {
loop { x += 1 }
}
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0102),
"infinite loop with no exit should produce W0102, got: {errors:?}"
);
}
#[test]
fn analyze_loop_with_wait_frame_ok() {
analyze_ok(
r#"
game "Test" { mapper: NROM }
on frame {
loop { wait_frame }
}
start Main
"#,
);
}
#[test]
fn analyze_loop_with_break_ok() {
analyze_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame {
loop {
x += 1
if x == 10 { break }
}
}
start Main
"#,
);
}
#[test]
fn analyze_bare_return_from_typed_fn_errors() {
// A `return` with no value inside a function that has a declared
// return type should produce E0203.
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun get_five() -> u8 {
return
}
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"bare return from typed fn should produce E0203, got: {errors:?}"
);
}
#[test]
fn analyze_return_value_from_void_fn() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
fun do_nothing() { return 5 }
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"returning value from void function should produce E0203, got: {errors:?}"
);
}
Implement codegen for state dispatch, functions, arrays, math, scroll State machine dispatch: - Assign each state a numeric index, store in ZP $03 - Main loop dispatch table: CMP + BNE + JMP trampoline pattern (avoids branch range limits for large programs) - on_enter/on_exit handlers generated as JSR targets - Transition statement writes state index + JSR enter/exit handlers Function calls: - Function bodies emitted as labeled subroutines with RTS - Statement::Call generates parameter passing via ZP + JSR - Statement::Return generates RTS (with value in A if present) - Parameter slots at ZP $04-$07 Break/continue: - Loop stack tracks continue/break label pairs - Break generates JMP to break_label - Continue generates JMP to continue_label - While and Loop push/pop the stack Array indexing: - LValue::ArrayIndex generates TAX + STA absolute,X - Expr::ArrayIndex generates TAX + LDA absolute,X / ZP,X - Compound array assignments (+=, -=, &=, |=, ^=) load-modify-store Scroll: - scroll(x, y) writes to PPU $2005 twice (X then Y) Math: - Multiply generates JSR __multiply (shift-and-add routine) - Divide generates JSR __divide (restoring division) - Modulo loads remainder from $03 after divide - ShiftLeft generates ASL A, ShiftRight generates LSR A - Math routines wired into linker Error validations: - E0203 for assignment to const variables - Break/continue outside loop detection (in_loop tracking) 233 tests (8 new codegen + 2 analyzer + 2 integration), all passing. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 02:04:49 +00:00
#[test]
fn analyze_const_assignment_error() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
const SPEED: u8 = 2
on frame { SPEED = 5 }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"assigning to const should produce E0203, got: {errors:?}"
);
}
#[test]
fn analyze_break_outside_loop() {
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
on frame { break }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0203),
"break outside loop should produce E0203, got: {errors:?}"
);
}
#[test]
fn analyze_unused_variable_warning() {
// `ghost` is declared but never read (only the initializer runs).
// It should trigger a W0103 warning.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
var ghost: u8 = 0
on frame { wait_frame }
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result.diagnostics.iter().any(|d| d.code == ErrorCode::W0103
&& d.level == crate::errors::Level::Warning
&& d.message.contains("ghost")),
"expected W0103 for unused var 'ghost', got: {:?}",
result.diagnostics
);
// And no hard errors.
assert!(
result.diagnostics.iter().all(|d| !d.is_error()),
"unexpected hard errors: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_unused_state_local_warning() {
// State-local `bonus` is declared but never read — W0103 should fire.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
state Main {
var bonus: u8 = 0
on frame { wait_frame }
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0103 && d.message.contains("bonus")),
"expected W0103 for unused state-local 'bonus', got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_unused_variable_no_warning_when_read() {
// `counter` is both written and read (in the `if` condition),
// so W0103 should NOT fire for it.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
var counter: u8 = 0
on frame {
counter = counter + 1
if counter > 60 { wait_frame }
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0103 && d.message.contains("counter")),
"did not expect W0103 for read variable 'counter', got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_unused_variable_underscore_prefix_silences() {
// A leading underscore silences the W0103 warning, matching Rust's
// convention for intentionally-unused names.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
var _reserved: u8 = 0
on frame { wait_frame }
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0103),
"did not expect W0103 for underscore-prefixed var, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_unreachable_state_warning() {
// `Orphan` is never reached from `Main` — W0104 should fire.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { wait_frame }
}
state Orphan {
on frame { wait_frame }
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0104 && d.message.contains("Orphan")),
"expected W0104 for unreachable state 'Orphan', got: {:?}",
result.diagnostics
);
// And no hard errors.
assert!(
result.diagnostics.iter().all(|d| !d.is_error()),
"unexpected hard errors: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_reachable_state_no_warning() {
// Both states are reachable: Main transitions to Other, and Other
// transitions back to Main. Neither should trigger W0104.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
state Main {
on frame { transition Other }
}
state Other {
on frame { transition Main }
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0104),
"did not expect any W0104 warnings, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_undefined_variable_emits_e0502() {
// `ghosy` does not exist; analyzer should emit E0502 and — thanks to
// the suggestion helper — hint at `ghost` which is the close match.
let (prog, diags) = parser::parse(
r#"
game "Test" { mapper: NROM }
var ghost: u8 = 0
var score: u8 = 0
on frame {
score = ghosy + 1
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
let diag = result
.diagnostics
.iter()
.find(|d| d.code == ErrorCode::E0502)
.expect("expected E0502 for undefined variable 'ghosy'");
assert!(
diag.message.contains("ghosy"),
"E0502 message should mention 'ghosy', got: {}",
diag.message
);
assert_eq!(
diag.help.as_deref(),
Some("did you mean 'ghost'?"),
"expected suggestion for 'ghost', got: {:?}",
diag.help
);
}
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 name validation ──
#[test]
fn analyze_accepts_builtin_sfx() {
// `play coin` is always valid because `coin` is a builtin
// even without a user `sfx Coin { ... }` declaration.
analyze_ok(
r#"
game "T" { mapper: NROM }
on frame { play coin }
start Main
"#,
);
}
#[test]
fn analyze_accepts_user_declared_sfx() {
analyze_ok(
r#"
game "T" { mapper: NROM }
sfx Chime {
pitch: [0x20, 0x22, 0x24, 0x26]
volume: [15, 12, 8, 4]
}
on frame { play Chime }
start Main
"#,
);
}
#[test]
fn analyze_rejects_unknown_sfx_name() {
// `play Nonexistent` with no matching user decl or builtin
// should emit E0505.
let codes = analyze_errors(
r#"
game "T" { mapper: NROM }
on frame { play Nonexistent }
start Main
"#,
);
assert!(
codes.contains(&ErrorCode::E0505),
"expected E0505 for unknown sfx, got {codes:?}"
);
}
#[test]
fn analyze_accepts_builtin_music() {
analyze_ok(
r#"
game "T" { mapper: NROM }
on frame { start_music theme }
start Main
"#,
);
}
#[test]
fn analyze_accepts_user_declared_music() {
analyze_ok(
r#"
game "T" { mapper: NROM }
music Boss {
notes: [37, 8, 41, 8, 44, 8, 49, 8]
}
on frame { start_music Boss }
start Main
"#,
);
}
#[test]
fn analyze_rejects_unknown_music_name() {
let codes = analyze_errors(
r#"
game "T" { mapper: NROM }
on frame { start_music Nonexistent }
start Main
"#,
);
assert!(
codes.contains(&ErrorCode::E0505),
"expected E0505 for unknown music, got {codes:?}"
);
}
#[test]
fn analyze_stop_music_needs_no_name_and_is_always_valid() {
// `stop_music` takes no argument, so there's nothing to
// validate — it should always analyze cleanly.
analyze_ok(
r#"
game "T" { mapper: NROM }
on frame { stop_music }
start Main
"#,
);
}