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;
|
|
|
|
|
use crate::asm::{AddressingMode as AM, Opcode::*};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_disables_irq() {
|
|
|
|
|
let init = gen_init();
|
|
|
|
|
assert_eq!(init[0].opcode, SEI);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_sets_stack_pointer() {
|
|
|
|
|
let init = gen_init();
|
|
|
|
|
// LDX #$FF, TXS
|
|
|
|
|
let has_ldx = init
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == LDX && i.mode == AM::Immediate(0xFF));
|
|
|
|
|
let has_txs = init.iter().any(|i| i.opcode == TXS);
|
|
|
|
|
assert!(has_ldx, "should load $FF into X");
|
|
|
|
|
assert!(has_txs, "should transfer X to stack pointer");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_disables_ppu() {
|
|
|
|
|
let init = gen_init();
|
|
|
|
|
// Should write 0 to $2000 and $2001
|
|
|
|
|
let writes_ppu_ctrl = init
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2000));
|
|
|
|
|
let writes_ppu_mask = init
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2001));
|
|
|
|
|
assert!(writes_ppu_ctrl, "should disable PPU control");
|
|
|
|
|
assert!(writes_ppu_mask, "should disable PPU mask");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_enables_nmi_at_end() {
|
|
|
|
|
let init = gen_init();
|
|
|
|
|
// Last STA $2000 should enable NMI (bit 7 set = 0x80)
|
|
|
|
|
let nmi_writes: Vec<_> = init
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x2000))
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
nmi_writes.len() >= 2,
|
|
|
|
|
"should write to PPU_CTRL at least twice"
|
|
|
|
|
);
|
|
|
|
|
// The last write should be preceded by LDA #$80
|
|
|
|
|
let last_write_idx = nmi_writes.last().unwrap().0;
|
|
|
|
|
assert!(last_write_idx > 0);
|
|
|
|
|
assert_eq!(init[last_write_idx - 1].opcode, LDA);
|
|
|
|
|
assert_eq!(init[last_write_idx - 1].mode, AM::Immediate(0x80));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_assembles_without_error() {
|
|
|
|
|
let init = gen_init();
|
|
|
|
|
let result = asm::assemble(&init, 0x8000);
|
|
|
|
|
// Should produce non-empty output
|
|
|
|
|
assert!(!result.bytes.is_empty(), "init should produce bytes");
|
|
|
|
|
// Should be under 200 bytes (the plan estimates ~80)
|
|
|
|
|
assert!(
|
|
|
|
|
result.bytes.len() < 200,
|
|
|
|
|
"init is {} bytes, expected < 200",
|
|
|
|
|
result.bytes.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn nmi_saves_and_restores_registers() {
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
// First three instructions should push A, X, Y
|
|
|
|
|
assert_eq!(nmi[0].opcode, PHA);
|
|
|
|
|
assert_eq!(nmi[1].opcode, TXA);
|
|
|
|
|
assert_eq!(nmi[2].opcode, PHA);
|
|
|
|
|
assert_eq!(nmi[3].opcode, TYA);
|
|
|
|
|
assert_eq!(nmi[4].opcode, PHA);
|
|
|
|
|
|
|
|
|
|
// Last instructions should restore and RTI
|
|
|
|
|
let len = nmi.len();
|
|
|
|
|
assert_eq!(nmi[len - 1].opcode, RTI);
|
|
|
|
|
assert_eq!(nmi[len - 2].opcode, PLA);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn nmi_triggers_oam_dma() {
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
let has_dma = nmi
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
|
|
|
|
|
assert!(has_dma, "NMI should trigger OAM DMA");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn nmi_reads_controller() {
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
// Should write strobe to $4016
|
|
|
|
|
let has_strobe = nmi
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016));
|
|
|
|
|
assert!(has_strobe, "NMI should strobe controller");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn nmi_sets_frame_flag() {
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
let has_flag = nmi
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
|
|
|
|
|
assert!(has_flag, "NMI should set frame-ready flag");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn nmi_assembles_without_error() {
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
let result = asm::assemble(&nmi, 0xF000);
|
|
|
|
|
assert!(!result.bytes.is_empty());
|
|
|
|
|
assert!(
|
|
|
|
|
result.bytes.len() < 150,
|
|
|
|
|
"NMI handler is {} bytes, expected < 150",
|
|
|
|
|
result.bytes.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn irq_handler_is_just_rti() {
|
|
|
|
|
let irq = gen_irq();
|
|
|
|
|
assert_eq!(irq.len(), 1);
|
|
|
|
|
assert_eq!(irq[0].opcode, RTI);
|
|
|
|
|
}
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn multiply_routine_assembles() {
|
|
|
|
|
let mul = gen_multiply();
|
|
|
|
|
// Should have a reasonable number of instructions
|
|
|
|
|
assert!(
|
|
|
|
|
mul.len() > 5,
|
|
|
|
|
"multiply routine too short: {} instructions",
|
|
|
|
|
mul.len()
|
|
|
|
|
);
|
|
|
|
|
let result = asm::assemble(&mul, 0x8000);
|
|
|
|
|
assert!(
|
|
|
|
|
!result.bytes.is_empty(),
|
|
|
|
|
"multiply routine should produce bytes"
|
|
|
|
|
);
|
|
|
|
|
// Should be under 100 bytes (compact 6502 routine)
|
|
|
|
|
assert!(
|
|
|
|
|
result.bytes.len() < 100,
|
|
|
|
|
"multiply routine is {} bytes, expected < 100",
|
|
|
|
|
result.bytes.len()
|
|
|
|
|
);
|
|
|
|
|
// Should contain the __multiply label
|
|
|
|
|
assert!(
|
|
|
|
|
result.labels.contains_key("__multiply"),
|
|
|
|
|
"should define __multiply label"
|
|
|
|
|
);
|
|
|
|
|
// Should end with RTS
|
|
|
|
|
assert_eq!(
|
|
|
|
|
mul.last().unwrap().opcode,
|
|
|
|
|
RTS,
|
|
|
|
|
"multiply routine should end with RTS"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn divide_routine_assembles() {
|
|
|
|
|
let div = gen_divide();
|
|
|
|
|
// Should have a reasonable number of instructions
|
|
|
|
|
assert!(
|
|
|
|
|
div.len() > 5,
|
|
|
|
|
"divide routine too short: {} instructions",
|
|
|
|
|
div.len()
|
|
|
|
|
);
|
|
|
|
|
let result = asm::assemble(&div, 0x8000);
|
|
|
|
|
assert!(
|
|
|
|
|
!result.bytes.is_empty(),
|
|
|
|
|
"divide routine should produce bytes"
|
|
|
|
|
);
|
|
|
|
|
// Should be under 100 bytes (compact 6502 routine)
|
|
|
|
|
assert!(
|
|
|
|
|
result.bytes.len() < 100,
|
|
|
|
|
"divide routine is {} bytes, expected < 100",
|
|
|
|
|
result.bytes.len()
|
|
|
|
|
);
|
|
|
|
|
// Should contain the __divide label
|
|
|
|
|
assert!(
|
|
|
|
|
result.labels.contains_key("__divide"),
|
|
|
|
|
"should define __divide label"
|
|
|
|
|
);
|
|
|
|
|
// Should end with RTS
|
|
|
|
|
assert_eq!(
|
|
|
|
|
div.last().unwrap().opcode,
|
|
|
|
|
RTS,
|
|
|
|
|
"divide routine should end with RTS"
|
|
|
|
|
);
|
|
|
|
|
}
|