Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
use super::*;
|
|
|
|
|
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
|
|
|
|
use crate::parser::ast::Mirroring;
|
|
|
|
|
use crate::rom;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_produces_valid_ines() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![
|
|
|
|
|
Instruction::new(NOP, AM::Label("__main_loop".into())),
|
|
|
|
|
Instruction::implied(NOP),
|
|
|
|
|
Instruction::new(JMP, AM::Label("__main_loop".into())),
|
|
|
|
|
];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).unwrap();
|
|
|
|
|
assert_eq!(info.prg_banks, 1);
|
|
|
|
|
assert_eq!(info.chr_banks, 1);
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_has_correct_vector_table() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
|
|
|
|
|
// Vector table is at the last 6 bytes of PRG ROM
|
|
|
|
|
// PRG starts at offset 16 in the .nes file
|
|
|
|
|
let prg_end = 16 + 16384;
|
|
|
|
|
let vector_start = prg_end - 6;
|
|
|
|
|
|
|
|
|
|
// NMI vector (2 bytes, little-endian)
|
|
|
|
|
let nmi = u16::from_le_bytes([rom_data[vector_start], rom_data[vector_start + 1]]);
|
|
|
|
|
// RESET vector
|
|
|
|
|
let reset = u16::from_le_bytes([rom_data[vector_start + 2], rom_data[vector_start + 3]]);
|
|
|
|
|
// IRQ vector
|
|
|
|
|
let irq = u16::from_le_bytes([rom_data[vector_start + 4], rom_data[vector_start + 5]]);
|
|
|
|
|
|
|
|
|
|
// All vectors should be in the $C000-$FFFF range
|
|
|
|
|
assert!(nmi >= 0xC000, "NMI vector {nmi:#06X} should be >= $C000");
|
|
|
|
|
assert!(
|
|
|
|
|
reset >= 0xC000,
|
|
|
|
|
"RESET vector {reset:#06X} should be >= $C000"
|
|
|
|
|
);
|
|
|
|
|
assert!(irq >= 0xC000, "IRQ vector {irq:#06X} should be >= $C000");
|
|
|
|
|
|
|
|
|
|
// RESET should point to the start of code ($C000)
|
|
|
|
|
assert_eq!(reset, 0xC000, "RESET should point to $C000");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_includes_chr_data() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
|
|
|
|
|
// CHR starts after PRG
|
|
|
|
|
let chr_start = 16 + 16384;
|
|
|
|
|
// First 16 bytes should be the smiley sprite
|
|
|
|
|
assert_ne!(
|
|
|
|
|
&rom_data[chr_start..chr_start + 16],
|
|
|
|
|
&[0u8; 16],
|
|
|
|
|
"CHR data should contain sprite tile"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_rom_size_correct() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
|
|
|
|
|
// 16 header + 16384 PRG + 8192 CHR
|
|
|
|
|
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 10:01:44 +00:00
|
|
|
#[test]
|
|
|
|
|
fn link_with_sprites_places_chr_data() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
|
|
|
|
|
let sprite_bytes: Vec<u8> = (0x20..0x30).collect(); // 16 bytes, one tile
|
|
|
|
|
let sprites = vec![SpriteData {
|
|
|
|
|
name: "Player".into(),
|
|
|
|
|
tile_index: 1,
|
|
|
|
|
chr_bytes: sprite_bytes.clone(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let rom_data = linker.link_with_assets(&user_code, &sprites);
|
|
|
|
|
|
|
|
|
|
// CHR starts right after the 16-byte iNES header and 16 KB PRG bank.
|
|
|
|
|
let chr_start = 16 + 16384;
|
|
|
|
|
|
|
|
|
|
// Tile 0 should still contain the built-in smiley (first 16 bytes, not
|
|
|
|
|
// all zero).
|
|
|
|
|
let tile0 = &rom_data[chr_start..chr_start + 16];
|
|
|
|
|
assert_ne!(
|
|
|
|
|
tile0, &[0u8; 16],
|
|
|
|
|
"default smiley should occupy tile index 0",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Tile 1 (CHR offset 16) should contain the sprite's CHR bytes exactly.
|
|
|
|
|
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
|
|
|
|
|
assert_eq!(tile1, sprite_bytes.as_slice());
|
|
|
|
|
|
|
|
|
|
// Tile 2 and beyond should be untouched (all zeros).
|
|
|
|
|
let tile2 = &rom_data[chr_start + 32..chr_start + 48];
|
|
|
|
|
assert_eq!(tile2, &[0u8; 16]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_with_sprites_spanning_multiple_tiles() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
|
|
|
|
|
// 32 bytes = 2 tiles. The linker should place them consecutively
|
|
|
|
|
// starting at the requested tile index.
|
|
|
|
|
let sprite_bytes: Vec<u8> = (0..32).collect();
|
|
|
|
|
let sprites = vec![SpriteData {
|
|
|
|
|
name: "Big".into(),
|
|
|
|
|
tile_index: 4,
|
|
|
|
|
chr_bytes: sprite_bytes.clone(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let rom_data = linker.link_with_assets(&user_code, &sprites);
|
|
|
|
|
let chr_start = 16 + 16384;
|
|
|
|
|
|
|
|
|
|
// Tile 4 starts at CHR offset 64.
|
|
|
|
|
let placed = &rom_data[chr_start + 64..chr_start + 64 + 32];
|
|
|
|
|
assert_eq!(placed, sprite_bytes.as_slice());
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
#[test]
|
|
|
|
|
fn palette_load_writes_to_ppu() {
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let palette_insts = linker.gen_palette_load();
|
|
|
|
|
|
|
|
|
|
// Should write to PPU address register ($2006) twice
|
|
|
|
|
let ppu_addr_writes: Vec<_> = palette_insts
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2006))
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
ppu_addr_writes.len(),
|
|
|
|
|
2,
|
|
|
|
|
"should set PPU address (high and low bytes)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Should write 32 palette bytes to $2007
|
|
|
|
|
let ppu_data_writes: Vec<_> = palette_insts
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2007))
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
ppu_data_writes.len(),
|
|
|
|
|
32,
|
|
|
|
|
"should write all 32 palette bytes"
|
|
|
|
|
);
|
|
|
|
|
}
|