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());
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
#[test]
|
|
|
|
|
fn link_splices_audio_tick_when_user_marker_present() {
|
|
|
|
|
// When user code contains the `__audio_used` marker label (the
|
|
|
|
|
// IR codegen emits this whenever it sees a `play`/`start_music`/
|
|
|
|
|
// `stop_music` op), the linker must splice a `JSR __audio_tick`
|
|
|
|
|
// into the NMI handler prologue AND link in the audio driver
|
|
|
|
|
// body so the JSR target exists.
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![
|
|
|
|
|
// Pretend user code with the marker the codegen would emit.
|
|
|
|
|
Instruction::new(NOP, AM::Label("__audio_used".into())),
|
|
|
|
|
Instruction::implied(NOP),
|
|
|
|
|
];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
|
|
|
|
|
// The ROM should be valid even with the splice — the driver
|
|
|
|
|
// body has to fit in bank 0 without overflowing.
|
|
|
|
|
let info = rom::validate_ines(&rom_data).unwrap();
|
|
|
|
|
assert_eq!(info.prg_banks, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn link_omits_audio_tick_when_no_marker() {
|
|
|
|
|
// User code without the marker should not pay any ROM cost
|
|
|
|
|
// for the audio driver. We can't easily inspect bytes, but we
|
|
|
|
|
// can at least verify the ROM builds and has a normal shape.
|
|
|
|
|
let linker = Linker::new(Mirroring::Horizontal);
|
|
|
|
|
let user_code = vec![Instruction::implied(NOP)];
|
|
|
|
|
let rom_data = linker.link(&user_code);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).unwrap();
|
|
|
|
|
assert_eq!(info.prg_banks, 1);
|
|
|
|
|
}
|
|
|
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|