1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/tests/integration_test.rs
Claude 9a539ea068
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling
Five language features and optimizations from the planned-work backlog:

- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
  APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
  the NMI handler gains a `JSR __audio_tick` splice (via the linker's
  `__audio_used` marker lookup) that ages an SFX countdown counter and
  mutes pulse 1 when the tone expires. Programs that never trigger
  audio pay zero ROM cost.

- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
  `Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
  tracks variable types via the analyzer's symbol table and routes
  expressions through the 8-bit or 16-bit path based on operand width.
  Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
  high byte; compares dispatch high-byte-first with a short-circuit
  low-byte fallback. Fixes a silent miscompile where `big += 1` on a
  u16 var only incremented the low byte.

- **Multi-scanline handlers per state**: `gen_scanline_irq` now
  dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
  MMC3 counter with the delta to the next scanline in the same state.
  `gen_scanline_reload` resets the step counter at the top of each
  NMI so a state with multiple handlers fires them in ascending line
  order. Previously only the first handler per state ever fired.

- **IR temp slot recycling**: `build_use_counts` pre-scans each
  function to count per-temp uses; `retire_op_sources` decrements
  the counts after each op and pushes dead slots back onto
  `free_slots` for later allocation. `bitwise_ops.ne` used to crash
  (debug) or miscompile (release) once it hit 128 concurrent temps;
  with recycling the same function now uses ~4 slots instead of 136.

- **INC/DEC peephole fold + improved dead-load elimination**:
  `fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
  a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
  saving 5 bytes and 5 cycles per increment. The fold is suppressed
  when the next instruction reads carry. `remove_dead_loads` now
  walks past INC/DEC/STX/STY (which don't touch A) to find the
  actual next A-use, catching more dead loads.

Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.

https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00

1016 lines
29 KiB
Rust

use std::path::Path;
use nescript::analyzer;
use nescript::assets;
use nescript::codegen::IrCodeGen;
use nescript::ir;
use nescript::linker::Linker;
use nescript::optimizer;
use nescript::rom;
/// Compile a `NEScript` source string into a .nes ROM. Runs the full
/// IR pipeline: parse → analyze → IR lower → optimize → IR codegen
/// → peephole → link. This is what the `nescript build` CLI does
/// (minus file IO and the dump flags), so these integration tests
/// exercise the same path end users hit.
fn compile(source: &str) -> Vec<u8> {
let (program, diags) = nescript::parser::parse(source);
assert!(
diags.is_empty(),
"unexpected parse errors: {diags:?}\nsource:\n{source}"
);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis.diagnostics.iter().all(|d| !d.is_error()),
"unexpected analysis errors: {:?}",
analysis.diagnostics
);
let mut ir_program = ir::lower(&program, &analysis);
optimizer::optimize(&mut ir_program);
let sprites = assets::resolve_sprites(&program, Path::new("."))
.expect("sprite resolution should succeed");
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program).with_sprites(&sprites);
let mut instructions = codegen.generate(&ir_program);
nescript::codegen::peephole::optimize(&mut instructions);
let linker = Linker::new(program.game.mirroring);
linker.link_with_assets(&instructions, &sprites)
}
// ── M1 Tests ──
#[test]
fn hello_sprite_compiles_to_valid_rom() {
let source = include_str!("integration/hello_sprite.ne");
let rom_data = compile(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.prg_banks, 1, "should be 1 PRG bank (16 KB)");
assert_eq!(info.chr_banks, 1, "should have CHR ROM");
assert_eq!(info.mapper, 0, "should be NROM (mapper 0)");
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
}
#[test]
fn hello_sprite_has_correct_vectors() {
let source = include_str!("integration/hello_sprite.ne");
let rom_data = compile(source);
let prg_end = 16 + 16384;
let nmi = u16::from_le_bytes([rom_data[prg_end - 6], rom_data[prg_end - 5]]);
let reset = u16::from_le_bytes([rom_data[prg_end - 4], rom_data[prg_end - 3]]);
let irq = u16::from_le_bytes([rom_data[prg_end - 2], rom_data[prg_end - 1]]);
assert!(nmi >= 0xC000, "NMI vector should be in ROM space");
assert_eq!(reset, 0xC000, "RESET should point to $C000");
assert!(irq >= 0xC000, "IRQ vector should be in ROM space");
assert!(nmi != reset, "NMI and RESET should be different");
}
#[test]
fn minimal_program_compiles() {
let source = r#"
game "Minimal" { mapper: NROM }
on frame { wait_frame }
start Main
"#;
let rom_data = compile(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
}
#[test]
fn program_with_state_machine() {
let source = r#"
game "States" { mapper: NROM }
state Title {
on frame {
if button.start { transition Game }
}
}
state Game {
var score: u8 = 0
on frame {
score += 1
}
}
start Title
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_constants() {
let source = r#"
game "Constants" { mapper: NROM }
const SPEED: u8 = 3
var px: u8 = 100
on frame {
if button.right { px += SPEED }
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
// ── M2 Tests ──
#[test]
fn program_with_functions() {
let source = r#"
game "Functions" { mapper: NROM }
var x: u8 = 0
fun add_ten(val: u8) -> u8 {
return val + 10
}
on frame {
x = add_ten(5)
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_on_scanline_mmc3() {
let source = r#"
game "Scanline" { mapper: MMC3 }
var sx: u8 = 0
state Main {
on frame { wait_frame }
on scanline(120) { scroll(sx, 0) }
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_on_scanline_per_state() {
// Two states, each with its own scanline handler at a different
// position. The IR codegen should emit per-state dispatch in
// both `__irq_user` and `__ir_mmc3_reload`.
let source = r#"
game "MultiSL" { mapper: MMC3 }
var s: u8 = 0
state A {
on frame { wait_frame }
on scanline(64) { scroll(0, 0) }
}
state B {
on frame { wait_frame }
on scanline(192) { scroll(0, 0) }
}
start A
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_function_local_variables() {
// Functions with locally-declared variables should allocate
// their own backing storage and not corrupt caller state when
// nested.
let source = r#"
game "Locals" { mapper: NROM }
var out: u8 = 0
fun double(x: u8) -> u8 {
var t: u8 = x
t = t + t
return t
}
fun double_sum(a: u8, b: u8) -> u8 {
var s1: u8 = double(a)
var s2: u8 = double(b)
return s1 + s2
}
on frame {
out = double_sum(10, 20)
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_for_loop() {
let source = r#"
game "ForLoop" { mapper: NROM }
var arr: u8[8] = [0, 0, 0, 0, 0, 0, 0, 0]
var total: u8 = 0
on frame {
total = 0
for i in 0..8 {
total += arr[i]
}
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_match_statement() {
// Note: the parser doesn't support `;` as a statement separator,
// so each arm body uses newlines between statements.
let source = r#"
game "Match" { mapper: NROM }
enum Mode { Idle, Run, Jump }
var mode: u8 = Idle
var x: u8 = 0
on frame {
match mode {
Idle => { if button.a { mode = Run } }
Run => {
x += 1
if button.b { mode = Jump }
}
Jump => {
x += 2
if button.a { mode = Idle }
}
_ => {}
}
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_struct_literals() {
let source = r#"
game "Lit" { mapper: NROM }
struct Vec2 { x: u8, y: u8 }
var pos: Vec2 = Vec2 { x: 10, y: 20 }
on frame {
pos = Vec2 { x: 100, y: 50 }
if button.right {
pos = Vec2 { x: pos.x + 1, y: pos.y }
}
draw Smiley at: (pos.x, pos.y)
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_structs() {
let source = r#"
game "Structs" { mapper: NROM }
struct Vec2 { x: u8, y: u8 }
struct Player { health: u8, lives: u8 }
var pos: Vec2
var hero: Player
on frame {
pos.x = 100
pos.y = 50
hero.health = 3
hero.lives = 5
if button.right { pos.x += 1 }
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_enums() {
let source = r#"
game "Enums" { mapper: NROM }
enum Direction { Up, Down, Left, Right }
enum Mode { Idle, Running, Jumping }
var dir: u8 = 0
var mode: u8 = 0
on frame {
if button.right { dir = Right }
if button.left { dir = Left }
if dir == Right { mode = Running }
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_poke_peek_intrinsics() {
let source = r#"
game "Hardware" { mapper: NROM }
var status: u8 = 0
on frame {
// Write to PPU address / data registers directly.
poke(0x2006, 0x3F)
poke(0x2006, 0x00)
poke(0x2007, 0x0F)
// Read PPU status.
status = peek(0x2002)
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_raw_asm_block() {
// `raw asm` bypasses `{var}` substitution so the body is passed
// to the inline parser unchanged.
let source = r#"
game "RawAsm" { mapper: NROM }
var x: u8 = 0
on frame {
raw asm {
LDA #$42
STA $00
}
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_inline_asm_variable_substitution() {
let source = r#"
game "AsmVar" { mapper: NROM }
var counter: u8 = 0
on frame {
asm {
LDA {counter}
CLC
ADC #$01
STA {counter}
}
wait_frame
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_inline_asm() {
let source = r#"
game "Asm" { mapper: NROM }
var x: u8 = 0
on frame {
asm {
LDA #$42
STA $10
INC $10
LSR A
CLC
ADC #$01
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_while_loop() {
let source = r#"
game "Loops" { mapper: NROM }
var x: u8 = 0
on frame {
while x < 10 {
x += 1
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_fast_slow_vars() {
let source = r#"
game "Placement" { mapper: NROM }
fast var hot: u8 = 0
slow var cold: u8 = 0
on frame {
hot += 1
cold += 1
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_multi_state_transitions() {
let source = r#"
game "Multi" { mapper: NROM }
state Menu {
on enter { wait_frame }
on frame {
if button.start { transition Level1 }
}
}
state Level1 {
var timer: u8 = 0
on frame {
timer += 1
if timer > 60 {
transition Level2
}
}
}
state Level2 {
on frame {
if button.select { transition Menu }
}
}
start Menu
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn coin_cavern_compiles() {
let source = include_str!("../examples/coin_cavern.ne");
let rom_data = compile(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
}
#[test]
fn ir_pipeline_produces_ir() {
let source = r#"
game "IR" { mapper: NROM }
const SPEED: u8 = 2
var x: u8 = 0
fun double(n: u8) -> u8 { return n + n }
on frame {
x += SPEED
if x > 100 { x = 0 }
}
start Main
"#;
let (program, diags) = nescript::parser::parse(source);
assert!(diags.is_empty());
let program = program.unwrap();
let analysis = analyzer::analyze(&program);
assert!(analysis.diagnostics.iter().all(|d| !d.is_error()));
let mut ir_program = ir::lower(&program, &analysis);
let before_ops = ir_program.op_count();
optimizer::optimize(&mut ir_program);
let after_ops = ir_program.op_count();
// Optimizer should reduce or maintain op count (not increase)
assert!(after_ops <= before_ops, "optimizer should not increase ops");
// Should have functions for the user function + frame handler
assert!(ir_program.functions.len() >= 2);
}
#[test]
fn error_test_missing_game() {
let source = "var x: u8 = 0\nstart Main";
let (_, diags) = nescript::parser::parse(source);
assert!(
diags.iter().any(nescript::errors::Diagnostic::is_error),
"should produce error"
);
}
#[test]
fn error_test_undefined_transition() {
let source = r#"
game "T" { mapper: NROM }
state Main {
on frame { transition Nonexistent }
}
start Main
"#;
let (program, parse_diags) = nescript::parser::parse(source);
assert!(parse_diags.is_empty());
let analysis = analyzer::analyze(&program.unwrap());
assert!(
analysis
.diagnostics
.iter()
.any(nescript::errors::Diagnostic::is_error),
"should detect undefined transition target"
);
}
#[test]
fn error_test_recursion_detected() {
let source = r#"
game "T" { mapper: NROM }
fun loop_forever() { loop_forever() }
on frame { wait_frame }
start Main
"#;
let (program, parse_diags) = nescript::parser::parse(source);
assert!(parse_diags.is_empty());
let analysis = analyzer::analyze(&program.unwrap());
assert!(
analysis
.diagnostics
.iter()
.any(|d| d.code == nescript::errors::ErrorCode::E0402),
"should detect recursion"
);
}
// ── M4 Tests ──
#[test]
fn program_with_scroll_and_cast() {
let source = r#"
game "M4 Test" { mapper: NROM }
var px: u8 = 0
var py: u8 = 0
var wide: u16 = 0
on frame {
if button.right { px += 1 }
wide = px as u16
scroll(px, py)
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_u16_arithmetic_and_compare() {
// Exercises the full u16 path: literal > 255 initializer,
// u16 += u8, u16 > u16 comparison. The old codegen truncated
// all u16 operations to their low byte, so `big = 1000`
// landed as 232 and `big += 1` never carried into the high
// byte. This test just asserts the ROM builds cleanly — the
// unit tests in `codegen/ir_codegen.rs` verify the actual
// instruction shape.
let source = r#"
game "U16 Arith" { mapper: NROM }
var big: u16 = 1000
var flag: u8 = 0
on frame {
big = big + 1
if big > 1050 {
flag = 1
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_audio_driver() {
// Exercises the minimal audio driver: play, start_music,
// stop_music all lowering into APU register writes plus the
// NMI audio tick splice. The linker must include the driver
// body and wire up the JSR from NMI.
let source = r#"
game "Audio" { mapper: NROM }
on frame {
if button.a { play coin }
if button.b { start_music theme }
if button.start { stop_music }
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
// ── M3 Tests ──
#[test]
fn program_with_sprites_and_palette() {
let source = r#"
game "M3 Assets" { mapper: NROM }
sprite Player {
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
}
palette MainPal {
colors: [0x0F, 0x00, 0x10, 0x20]
}
background TitleBg {
chr: @binary("title.bin")
}
var px: u8 = 128
var py: u8 = 120
state Title {
on enter {
load_background TitleBg
set_palette MainPal
}
on frame {
if button.right { px += 2 }
if button.left { px -= 2 }
draw Player at: (px, py)
}
}
start Title
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
// ── M5 Tests ──
/// Compile a source string using the mapper-aware linker.
fn compile_with_mapper(source: &str) -> Vec<u8> {
let (program, diags) = nescript::parser::parse(source);
assert!(
diags.is_empty(),
"unexpected parse errors: {diags:?}\nsource:\n{source}"
);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis.diagnostics.iter().all(|d| !d.is_error()),
"unexpected analysis errors: {:?}",
analysis.diagnostics
);
let mut ir_program = ir::lower(&program, &analysis);
nescript::optimizer::optimize(&mut ir_program);
let sprites = assets::resolve_sprites(&program, Path::new("."))
.expect("sprite resolution should succeed");
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program).with_sprites(&sprites);
let mut instructions = codegen.generate(&ir_program);
nescript::codegen::peephole::optimize(&mut instructions);
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
linker.link_with_assets(&instructions, &sprites)
}
#[test]
fn sprite_resolution_uses_tile_index() {
// The Player sprite has 16 unique bytes of CHR data. Because tile index 0
// is reserved for the built-in smiley, the compiler should place Player
// at tile index 1 and `draw Player` should store that tile index in OAM.
//
// We check this in two ways:
// 1. The CHR ROM contains Player's bytes at tile 1 (offset 16).
// 2. The PRG ROM contains the immediate-load sequence `A9 01 8D 01 02`
// (LDA #$01 ; STA $0201) — writing tile index 1 into OAM byte 1.
let source = r#"
game "SpriteTile" { mapper: NROM }
sprite Player {
chr: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F]
}
var px: u8 = 128
var py: u8 = 120
state Title {
on frame {
draw Player at: (px, py)
}
}
start Title
"#;
let rom_data = compile(source);
// CHR ROM begins right after PRG ROM (16 header + 16384 PRG).
let chr_start = 16 + 16384;
// Tile 1 lives at CHR offset 16 (16 bytes per tile).
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
assert_eq!(
tile1,
&[
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
0x1E, 0x1F
],
"Player sprite CHR bytes should be placed at tile index 1",
);
// The default smiley tile at index 0 should still be non-zero (untouched).
let tile0 = &rom_data[chr_start..chr_start + 16];
assert_ne!(
tile0, &[0u8; 16],
"tile 0 should still contain the default smiley",
);
// In PRG ROM, look for `LDA #$01 ; STA $0201,Y` which writes
// the Player's tile index (1) into the tile-index byte of the
// current OAM slot (the slot is computed at runtime via the
// OAM cursor in Y). The STA AbsoluteY opcode is $99.
let prg = &rom_data[16..16 + 16384];
let pattern = [0xA9u8, 0x01, 0x99, 0x01, 0x02];
assert!(
prg.windows(pattern.len()).any(|w| w == pattern),
"PRG ROM should contain LDA #$01 ; STA $0201,Y for draw Player",
);
}
#[test]
fn program_with_arrays_and_math() {
let source = r#"
game "ArrayMath" { mapper: NROM }
var arr: u8[4] = [10, 20, 30, 40]
var idx: u8 = 0
var result: u8 = 0
on frame {
result = arr[idx] * 2
idx += 1
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_mmc1() {
let source = r#"
game "MMC1 Game" { mapper: MMC1 }
var px: u8 = 128
on frame {
if button.right { px += 2 }
}
start Main
"#;
let rom_data = compile_with_mapper(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.mapper, 1, "should be MMC1 (mapper 1)");
}
// ── IR Codegen Tests ──
//
// These tests exercise specific end-to-end IR codegen behavior.
// They all use the top-level `compile()` helper now that it runs
// the full IR pipeline — there's no longer a separate legacy path
// to compare against.
#[test]
fn ir_codegen_minimal_rom() {
let source = r#"
game "IR Test" { mapper: NROM }
var x: u8 = 42
on frame { wait_frame }
start Main
"#;
let rom_data = compile(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
}
#[test]
fn ir_codegen_full_pipeline() {
let source = r#"
game "IR Full" { mapper: NROM }
var x: u8 = 0
var y: u8 = 0
on frame {
if button.right { x += 1 }
if button.left { x -= 1 }
if x > 100 { x = 0 }
draw Smiley at: (x, y)
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn ir_codegen_multi_state_dispatch() {
// Exercise the IR main-loop dispatch with multiple states and a
// transition.
let source = r#"
game "IR States" { mapper: NROM }
var timer: u8 = 0
state Title {
on frame {
if button.start { transition Play }
}
}
state Play {
on frame {
timer += 1
if timer > 60 { transition Title }
}
}
start Title
"#;
let rom_data = compile(source);
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
}
#[test]
fn ir_codegen_multi_oam() {
// Draw multiple sprites and verify OAM slots are allocated sequentially.
let source = r#"
game "IR MultiOAM" { mapper: NROM }
var a: u8 = 10
var b: u8 = 20
var c: u8 = 30
on frame {
draw One at: (a, a)
draw Two at: (b, b)
draw Three at: (c, c)
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn ir_codegen_array_literal_globals_emit_per_byte_init() {
// Regression test: `var xs: u8[4] = [10, 20, 30, 40]` used to
// compile to a zero-initialized array because `eval_const`
// returned `None` for `Expr::ArrayLiteral` and no startup
// stores were emitted. The fix captures the literal values
// in `IrGlobal::init_array` and has the IR codegen emit one
// `LDA #imm; STA base+i` per byte during startup.
use nescript::asm::{AddressingMode, Opcode};
use nescript::codegen::IrCodeGen;
let source = r#"
game "ArrLit" { mapper: NROM }
var xs: u8[4] = [10, 20, 30, 40]
on frame { wait_frame }
start Main
"#;
let (prog, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let mut ir_program = ir::lower(&prog, &analysis);
optimizer::optimize(&mut ir_program);
let xs_addr = analysis
.var_allocations
.iter()
.find(|a| a.name == "xs")
.expect("xs should be allocated")
.address;
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
let instructions = codegen.generate(&ir_program);
// For each element, look for `LDA #val` followed shortly by
// `STA absolute(xs_addr + i)`. We don't require them to be
// adjacent because the peephole passes can reshuffle, but a
// store of the correct value to the correct address must
// exist.
for (i, &expected) in [10u8, 20, 30, 40].iter().enumerate() {
let target = xs_addr + i as u16;
let has_store = instructions.windows(2).any(|w| {
matches!(w[0].mode, AddressingMode::Immediate(v) if v == expected)
&& w[0].opcode == Opcode::LDA
&& w[1].opcode == Opcode::STA
&& matches!(w[1].mode, AddressingMode::Absolute(a) if a == target)
});
assert!(
has_store,
"expected `LDA #{expected}; STA ${target:04X}` for xs[{i}] but did not find it"
);
}
}
#[test]
fn ir_codegen_locals_do_not_overlap_array_globals() {
// Regression test for the local-allocator off-by-array-size
// bug. `IrCodeGen::new` used to start handler-local vars at
// `max_global_base + 1`, which for an array global at
// `$0300-$0303` put the first local at `$0301` — inside the
// array. Any store through that local then corrupted the
// array mid-frame. The fix advances past the global's END,
// not its base.
//
// We verify by asking the IR codegen what addresses it
// assigned. Since `var_addrs` is private, we check indirectly
// via emitted instructions: any `STA $030N` for N > 3 that
// isn't part of the startup init must be writing to a local
// whose address is outside the array. If the bug regressed,
// we'd see `STA $0302` or similar in the frame handler's
// computation code.
use nescript::asm::{AddressingMode, Opcode};
use nescript::codegen::IrCodeGen;
let source = r#"
game "LocalVsArr" { mapper: NROM }
var xs: u8[4] = [11, 22, 33, 44]
on frame {
var tmp: u8 = 0
tmp = xs[0]
tmp += 1
wait_frame
}
start Main
"#;
let (prog, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let mut ir_program = ir::lower(&prog, &analysis);
optimizer::optimize(&mut ir_program);
let xs_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "xs")
.expect("xs should be allocated");
let xs_base = xs_alloc.address;
let xs_end = xs_base + xs_alloc.size; // one past last element
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
let instructions = codegen.generate(&ir_program);
// Collect the (ordered) list of `STA absolute` targets and
// immediate values preceding each store. The first four
// stores into `[xs_base, xs_end)` should be the `LDA #imm;
// STA addr` init pairs — those are fine. Any STA into the
// array AFTER the init sequence would indicate a local var
// was allocated inside the array.
let mut init_stores_seen = 0usize;
for w in instructions.windows(2) {
if w[1].opcode != Opcode::STA {
continue;
}
let AddressingMode::Absolute(addr) = w[1].mode else {
continue;
};
if addr < xs_base || addr >= xs_end {
continue;
}
if w[0].opcode == Opcode::LDA
&& matches!(w[0].mode, AddressingMode::Immediate(_))
&& init_stores_seen < 4
{
init_stores_seen += 1;
continue;
}
panic!(
"store into xs array (${addr:04X}) after init sequence — \
local probably overlapping with array global"
);
}
assert_eq!(
init_stores_seen, 4,
"expected 4 init stores for xs[0..4], found {init_stores_seen}"
);
}