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);
|
|
|
|
|
}
|
2026-04-11 23:32:12 +00:00
|
|
|
|
|
|
|
|
#[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
|
|
|
|
2026-04-12 10:49:36 +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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:52:06 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:18:05 +00:00
|
|
|
#[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]
|
2026-04-14 02:05:51 +00:00
|
|
|
fn analyze_struct_u16_field_allocates_two_bytes() {
|
|
|
|
|
// A struct with a u16 field should lay out fields with
|
|
|
|
|
// byte-accurate offsets: a u8 followed by a u16 followed by a u8
|
|
|
|
|
// puts `b` at offset 1 and `c` at offset 3.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "Test" { mapper: NROM }
|
|
|
|
|
struct Mixed { a: u8, b: u16, c: u8 }
|
|
|
|
|
var m: Mixed
|
|
|
|
|
on frame {
|
|
|
|
|
m.a = 1
|
|
|
|
|
m.b = 300
|
|
|
|
|
m.c = 7
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let a = result
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|x| x.name == "m.a")
|
|
|
|
|
.expect("m.a should be allocated");
|
|
|
|
|
let b = result
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|x| x.name == "m.b")
|
|
|
|
|
.expect("m.b should be allocated");
|
|
|
|
|
let c = result
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|x| x.name == "m.c")
|
|
|
|
|
.expect("m.c should be allocated");
|
|
|
|
|
// Offsets from base: a=0, b=1, c=3 (b is two bytes wide).
|
|
|
|
|
assert_eq!(b.address, a.address + 1);
|
|
|
|
|
assert_eq!(c.address, a.address + 3);
|
|
|
|
|
// u16 field is recorded with size 2 so codegen bookkeeping
|
|
|
|
|
// knows how much space the field occupies.
|
|
|
|
|
assert_eq!(a.size, 1);
|
|
|
|
|
assert_eq!(b.size, 2);
|
|
|
|
|
assert_eq!(c.size, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_struct_with_array_field_is_rejected() {
|
|
|
|
|
// Array fields are still rejected — the analyzer only accepts
|
|
|
|
|
// u8/i8/u16/bool scalar fields in v1 structs.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "Test" { mapper: NROM }
|
|
|
|
|
struct Bag { xs: u8[4] }
|
|
|
|
|
var b: Bag
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0201),
|
|
|
|
|
"array struct field should emit E0201: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_struct_with_nested_struct_field_is_rejected() {
|
|
|
|
|
// Nested struct fields are still rejected — only scalar primitives.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "Test" { mapper: NROM }
|
|
|
|
|
struct Inner { a: u8 }
|
|
|
|
|
struct Outer { inner: Inner }
|
|
|
|
|
var o: Outer
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0201),
|
|
|
|
|
"nested struct field should emit E0201: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-12 16:18:05 +00:00
|
|
|
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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:35:49 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:36:44 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:08:14 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:05:13 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:01:03 +00:00
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:57:34 +00:00
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:54:30 +00:00
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 18:01:07 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:52:06 +00:00
|
|
|
#[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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-12 10:01:44 +00:00
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:21:53 +00:00
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:01:44 +00:00
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
palette/background: first-class declarations with reset-time load and runtime swaps
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.
Pipeline:
- Lexer: re-adds `palette`, `background`, `set_palette`,
`load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
(name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
`Program`. `Statement::SetPalette` and `Statement::LoadBackground`
name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
{ tiles: [...], attributes: [...] }` blocks and their statement
forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
(<=32), nametable length (<=960), attribute length (<=64), and
duplicate decl names. `set_palette` / `load_background` targets
must reference a declared name (E0502 otherwise). When a program
declares palette or background, the analyzer bumps the user
zero-page allocator's starting address from `$10` to `$18` to
reserve `$11-$17` for the runtime update handshake — programs
that don't use the feature keep the old layout so their emulator
goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
expose `label()` / `tiles_label()` / `attrs_label()` for codegen
to reference.
- IR: new `IrOp::SetPalette(String)` and
`IrOp::LoadBackground(String)`; lowering forwards the names
verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
`gen_load_background` does the same for tile/attribute pointers
at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
marker so the linker splices in the NMI apply helper only when
the feature is actually used.
- Runtime: `gen_initial_palette_load` and
`gen_initial_background_load` write the first declared
palette/background at reset time (before rendering is enabled,
where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
new flag; when true it splices `gen_ppu_update_apply` at the top
of the NMI body, which checks the `$11` flags byte and copies
pending palette / nametable data to `$3F00` / `$2000` inside
vblank. All helpers use only ZP $02/$03 as scratch at reset time
and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
`BackgroundData`; splices each blob as a labelled data block in
PRG ROM, picks the first-declared as the reset-time load target,
enables background rendering automatically when a background is
declared, and threads `has_ppu_updates` into `gen_nmi`. Old
`link_banked` remains as a thin wrapper for callers without
palette/background data so existing tests don't shift.
Tests:
- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
without attributes, plus `set_palette` / `load_background`
statements.
- Analyzer: 9 new tests covering acceptance of declared
palettes/backgrounds, E0502 for unknown names, E0201 for
out-of-range NES colors and oversized blobs, E0501 for duplicate
names, and the zero-page-layout guard (palette/bg decls bump ZP
start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
PRG, `STA $12` / `STA $14` emitted by set_palette /
load_background codegen, and a regression guard that programs
without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
frame counter that toggles between `CoolBlues` / `WarmReds` and
`TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
hash checked in under `tests/emulator/goldens/` and verified via
`node run_examples.mjs` — rendered image shows the blue
`CoolBlues` palette with the nametable populated from
`TitleScreen`.
Docs:
- `README.md` adds the feature to the headline list and the example
table.
- `docs/language-guide.md` restores the palette/background sections
with the full 32-byte layout table and `set_palette` /
`load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
with the remaining gaps (PNG-sourced palette and nametable
assets, cross-vblank large background updates, memory-map
reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.
All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:11:33 +00:00
|
|
|
|
|
|
|
|
// ── Palette / background validation ──
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_accepts_declared_palette() {
|
|
|
|
|
analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Cool { colors: [0x0F, 0x01, 0x11, 0x21] }
|
|
|
|
|
on frame { set_palette Cool }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_unknown_palette() {
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
on frame { set_palette Ghost }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0502),
|
|
|
|
|
"expected E0502 for unknown palette, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_accepts_declared_background() {
|
|
|
|
|
analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
background Stage { tiles: [0, 1, 2] }
|
|
|
|
|
on frame { load_background Stage }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_unknown_background() {
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
on frame { load_background Ghost }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0502),
|
|
|
|
|
"expected E0502 for unknown background, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_palette_color_out_of_range() {
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Bad { colors: [0x0F, 0x40] }
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0201),
|
|
|
|
|
"expected E0201 for out-of-range NES color, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_palette_too_long() {
|
|
|
|
|
// 33 bytes > 32-byte PPU palette RAM limit.
|
|
|
|
|
let colors = (0..33)
|
|
|
|
|
.map(|_| "0x0F".to_string())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ");
|
|
|
|
|
let src = format!(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" {{ mapper: NROM }}
|
|
|
|
|
palette Big {{ colors: [{colors}] }}
|
|
|
|
|
on frame {{ wait_frame }}
|
|
|
|
|
start Main
|
|
|
|
|
"#
|
|
|
|
|
);
|
|
|
|
|
let errors = analyze_errors(&src);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0201),
|
|
|
|
|
"expected E0201 for >32-byte palette, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_background_tiles_too_long() {
|
|
|
|
|
// 961 bytes > 960-byte nametable.
|
|
|
|
|
let tiles = (0..961)
|
|
|
|
|
.map(|_| "0".to_string())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ");
|
|
|
|
|
let src = format!(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" {{ mapper: NROM }}
|
|
|
|
|
background Big {{ tiles: [{tiles}] }}
|
|
|
|
|
on frame {{ wait_frame }}
|
|
|
|
|
start Main
|
|
|
|
|
"#
|
|
|
|
|
);
|
|
|
|
|
let errors = analyze_errors(&src);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0201),
|
|
|
|
|
"expected E0201 for >960-byte nametable, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_rejects_duplicate_palette_name() {
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Dup { colors: [0x0F] }
|
|
|
|
|
palette Dup { colors: [0x10] }
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::E0501),
|
|
|
|
|
"expected E0501 for duplicate palette name, got {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_reserves_zero_page_when_palette_declared() {
|
|
|
|
|
// When a program declares any palette or background, the
|
|
|
|
|
// analyzer bumps the user zero-page start from $10 to $18 so
|
|
|
|
|
// the runtime can own $11-$17 for the vblank update handshake.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette P { colors: [0x0F] }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let x = result
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|a| a.name == "x")
|
|
|
|
|
.expect("x should be allocated");
|
|
|
|
|
assert!(
|
|
|
|
|
x.address >= 0x18,
|
|
|
|
|
"user var `x` should land at $18+ when palette is declared (got ${:02X})",
|
|
|
|
|
x.address
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_does_not_reserve_zero_page_without_palette_or_bg() {
|
|
|
|
|
// Programs that don't declare palette/background keep the old
|
|
|
|
|
// user-ZP start at $10 so existing examples (and their
|
|
|
|
|
// goldens) don't shift.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let x = result
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|a| a.name == "x")
|
|
|
|
|
.expect("x should be allocated");
|
|
|
|
|
assert_eq!(x.address, 0x10);
|
|
|
|
|
}
|
2026-04-14 01:35:08 +00:00
|
|
|
|
|
|
|
|
// ── W0102 extended: `while true` + continue-only loops ──────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_while_true_without_exit_warns() {
|
|
|
|
|
// `while true { x = x + 1 }` — no break/return/wait_frame,
|
|
|
|
|
// so the same W0102 that fires on bare `loop { ... }` must
|
|
|
|
|
// also fire here.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
while true { x = x + 1 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::W0102),
|
|
|
|
|
"`while true` with no exit should produce W0102, got: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_while_true_with_wait_frame_ok() {
|
|
|
|
|
// `while true { wait_frame }` yields control to the NMI each
|
|
|
|
|
// iteration, so the NES actually makes progress — no warning.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
on frame {
|
|
|
|
|
while true { wait_frame }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0102),
|
|
|
|
|
"`while true` + wait_frame should NOT warn, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_while_true_with_break_ok() {
|
|
|
|
|
// A reachable `break` satisfies W0102 just like it does for
|
|
|
|
|
// bare `loop`.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
while true {
|
|
|
|
|
x = x + 1
|
|
|
|
|
if x == 10 { break }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0102),
|
|
|
|
|
"`while true` + break should NOT warn, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_loop_with_only_continue_still_warns() {
|
|
|
|
|
// `continue` is *not* an exit — the loop still spins forever.
|
|
|
|
|
// W0102 must still fire here.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
loop {
|
|
|
|
|
if x == 0 { continue }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::W0102),
|
|
|
|
|
"`loop` whose only exit is `continue` should still produce W0102, got: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── W0105: palette universal-byte consistency ───────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_palette_consistent_universals_ok() {
|
|
|
|
|
// Flat-form palette where every sub-palette's first byte is
|
|
|
|
|
// the same universal colour ($0F = black). No W0105.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Consistent {
|
|
|
|
|
colors: [
|
|
|
|
|
0x0F, 0x11, 0x12, 0x13,
|
|
|
|
|
0x0F, 0x21, 0x22, 0x23,
|
|
|
|
|
0x0F, 0x31, 0x32, 0x33,
|
|
|
|
|
0x0F, 0x01, 0x02, 0x03,
|
|
|
|
|
0x0F, 0x05, 0x06, 0x07,
|
|
|
|
|
0x0F, 0x15, 0x16, 0x17,
|
|
|
|
|
0x0F, 0x25, 0x26, 0x27,
|
|
|
|
|
0x0F, 0x35, 0x36, 0x37
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0105),
|
|
|
|
|
"consistent palette should NOT warn, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_palette_inconsistent_universals_warns() {
|
|
|
|
|
// Flat-form palette whose sub-palette first bytes disagree
|
|
|
|
|
// (index 0 = $0F, index 16 = $30, etc.) — the $3F10 mirror
|
|
|
|
|
// will overwrite the background universal colour at runtime.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Broken {
|
|
|
|
|
colors: [
|
|
|
|
|
0x0F, 0x11, 0x12, 0x13,
|
|
|
|
|
0x0F, 0x21, 0x22, 0x23,
|
|
|
|
|
0x0F, 0x31, 0x32, 0x33,
|
|
|
|
|
0x0F, 0x01, 0x02, 0x03,
|
|
|
|
|
0x30, 0x05, 0x06, 0x07,
|
|
|
|
|
0x0F, 0x15, 0x16, 0x17,
|
|
|
|
|
0x0F, 0x25, 0x26, 0x27,
|
|
|
|
|
0x0F, 0x35, 0x36, 0x37
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::W0105),
|
|
|
|
|
"inconsistent palette universals should produce W0105, got: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_grouped_palette_is_always_consistent() {
|
|
|
|
|
// The grouped form uses the `universal:` field to drive
|
|
|
|
|
// every sub-palette's first byte, so it can never trip
|
|
|
|
|
// W0105 — even when the sub-palette bodies differ wildly.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
palette Grouped {
|
|
|
|
|
universal: 0x0F
|
|
|
|
|
bg0: [0x11, 0x12, 0x13]
|
|
|
|
|
bg1: [0x21, 0x22, 0x23]
|
|
|
|
|
bg2: [0x31, 0x32, 0x33]
|
|
|
|
|
bg3: [0x01, 0x02, 0x03]
|
|
|
|
|
sp0: [0x05, 0x06, 0x07]
|
|
|
|
|
sp1: [0x15, 0x16, 0x17]
|
|
|
|
|
sp2: [0x25, 0x26, 0x27]
|
|
|
|
|
sp3: [0x35, 0x36, 0x37]
|
|
|
|
|
}
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0105),
|
|
|
|
|
"grouped palette should never trip W0105, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── W0106: implicit drop of a function return value ─────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_discarded_non_void_return_warns() {
|
|
|
|
|
// `double(x)` returns u8 but the caller drops the result.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
fun double(n: u8) -> u8 { return n + n }
|
|
|
|
|
on frame { double(x) }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::W0106),
|
|
|
|
|
"discarded non-void return should produce W0106, got: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_discarded_void_call_ok() {
|
|
|
|
|
// Void function at statement position is the happy path —
|
|
|
|
|
// no discarded value, no warning.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
fun bump() { x = x + 1 }
|
|
|
|
|
on frame { bump() }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0106),
|
|
|
|
|
"void call should NOT produce W0106, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_non_void_return_used_as_rhs_ok() {
|
|
|
|
|
// Same signature as the discarded case, but the return value
|
|
|
|
|
// is consumed by an assignment — no warning.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
var y: u8 = 0
|
|
|
|
|
fun double(n: u8) -> u8 { return n + n }
|
|
|
|
|
on frame { y = double(x) }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0106),
|
|
|
|
|
"assigned return value should NOT produce W0106, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── W0107: `fast` variable slot under-use ───────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_fast_var_underused_warns() {
|
|
|
|
|
// `counter` is declared `fast` but only one read (in the
|
|
|
|
|
// `if` condition), so its access count is 1 — below the
|
|
|
|
|
// threshold of 3. W0107 should fire.
|
|
|
|
|
let errors = analyze_errors(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
fast var counter: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if counter == 0 { wait_frame }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
errors.contains(&ErrorCode::W0107),
|
|
|
|
|
"under-used `fast` var should produce W0107, got: {errors:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_fast_var_heavy_use_ok() {
|
|
|
|
|
// Three-plus accesses (one init + one read + one write-back)
|
|
|
|
|
// is enough to justify the slot — no W0107.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
fast var counter: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
counter = counter + 1
|
|
|
|
|
if counter == 0 { wait_frame }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0107),
|
|
|
|
|
"hot `fast` var should NOT warn, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_non_fast_var_never_warns() {
|
|
|
|
|
// Only `fast` declarations are checked — a plain `var` with
|
|
|
|
|
// the same (light) access pattern must not fire W0107.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
var counter: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if counter == 0 { wait_frame }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0107),
|
|
|
|
|
"plain `var` should never trip W0107, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn analyze_fast_var_underscore_exempt() {
|
|
|
|
|
// Leading-underscore names are exempt from W0107, mirroring
|
|
|
|
|
// the W0103 convention for deliberately-unused variables.
|
|
|
|
|
let result = analyze_ok(
|
|
|
|
|
r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
fast var _reserved: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if _reserved == 0 { wait_frame }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == ErrorCode::W0107),
|
|
|
|
|
"underscore-prefixed `fast` var should be exempt, got: {:?}",
|
|
|
|
|
result.diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|