mirror of
https://github.com/imjasonh/nescript
synced 2026-07-10 01:37:45 +00:00
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
This commit is contained in:
parent
1fca6864ac
commit
39ca246151
32 changed files with 6306 additions and 0 deletions
145
src/runtime/mod.rs
Normal file
145
src/runtime/mod.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
|
||||
/// PPU register addresses
|
||||
const PPU_CTRL: u16 = 0x2000;
|
||||
const PPU_MASK: u16 = 0x2001;
|
||||
const PPU_STATUS: u16 = 0x2002;
|
||||
const OAM_ADDR: u16 = 0x2003;
|
||||
const OAM_DMA: u16 = 0x4014;
|
||||
const APU_STATUS: u16 = 0x4015;
|
||||
const JOY1: u16 = 0x4016;
|
||||
const APU_FRAME: u16 = 0x4017;
|
||||
|
||||
/// Zero-page locations used by the runtime.
|
||||
pub const ZP_FRAME_FLAG: u8 = 0x00;
|
||||
pub const ZP_INPUT_P1: u8 = 0x01;
|
||||
|
||||
/// Generate the NES hardware initialization sequence.
|
||||
/// This runs at RESET and sets up the hardware before user code.
|
||||
pub fn gen_init() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Disable IRQs and set decimal mode off
|
||||
out.push(Instruction::implied(SEI));
|
||||
out.push(Instruction::implied(CLD));
|
||||
|
||||
// Disable APU frame counter IRQ
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0x40)));
|
||||
out.push(Instruction::new(STX, AM::Absolute(APU_FRAME)));
|
||||
|
||||
// Set up stack at $01FF
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0xFF)));
|
||||
out.push(Instruction::implied(TXS));
|
||||
|
||||
// Disable PPU rendering
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
|
||||
|
||||
// Disable DMC IRQs
|
||||
out.push(Instruction::new(STA, AM::Absolute(APU_STATUS)));
|
||||
|
||||
// Wait for first vblank
|
||||
// vblankwait1:
|
||||
out.push(Instruction::new(NOP, AM::Label("__vblankwait1".into())));
|
||||
out.push(Instruction::new(BIT, AM::Absolute(PPU_STATUS)));
|
||||
out.push(Instruction::new(
|
||||
BPL,
|
||||
AM::LabelRelative("__vblankwait1".into()),
|
||||
));
|
||||
|
||||
// Clear RAM ($0000-$07FF)
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(NOP, AM::Label("__clrmem".into())));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0000)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0100)));
|
||||
// OAM shadow: fill with $FE (hide sprites off-screen)
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0xFE)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0200)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0300)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0400)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0500)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0600)));
|
||||
out.push(Instruction::new(STA, AM::AbsoluteX(0x0700)));
|
||||
out.push(Instruction::implied(INX));
|
||||
out.push(Instruction::new(BNE, AM::LabelRelative("__clrmem".into())));
|
||||
|
||||
// Wait for second vblank
|
||||
out.push(Instruction::new(NOP, AM::Label("__vblankwait2".into())));
|
||||
out.push(Instruction::new(BIT, AM::Absolute(PPU_STATUS)));
|
||||
out.push(Instruction::new(
|
||||
BPL,
|
||||
AM::LabelRelative("__vblankwait2".into()),
|
||||
));
|
||||
|
||||
// Enable PPU (sprites from pattern table 0, enable NMI)
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x80))); // enable NMI
|
||||
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x10))); // show sprites
|
||||
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the NMI handler.
|
||||
/// Called every vblank by the NES hardware.
|
||||
pub fn gen_nmi() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Save registers
|
||||
out.push(Instruction::implied(PHA));
|
||||
out.push(Instruction::implied(TXA));
|
||||
out.push(Instruction::implied(PHA));
|
||||
out.push(Instruction::implied(TYA));
|
||||
out.push(Instruction::implied(PHA));
|
||||
|
||||
// OAM DMA — transfer sprite data from $0200
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x02)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(OAM_DMA)));
|
||||
|
||||
// Read controller 1
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(JOY1)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(JOY1)));
|
||||
|
||||
// Read 8 button bits into ZP_INPUT_P1
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0x08)));
|
||||
out.push(Instruction::new(NOP, AM::Label("__read_input".into())));
|
||||
out.push(Instruction::new(LDA, AM::Absolute(JOY1)));
|
||||
out.push(Instruction::new(LSR, AM::Accumulator));
|
||||
out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P1)));
|
||||
out.push(Instruction::implied(DEX));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__read_input".into()),
|
||||
));
|
||||
|
||||
// Set frame-ready flag
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_FRAME_FLAG)));
|
||||
|
||||
// Restore registers
|
||||
out.push(Instruction::implied(PLA));
|
||||
out.push(Instruction::implied(TAY));
|
||||
out.push(Instruction::implied(PLA));
|
||||
out.push(Instruction::implied(TAX));
|
||||
out.push(Instruction::implied(PLA));
|
||||
|
||||
// Return from interrupt
|
||||
out.push(Instruction::implied(RTI));
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the IRQ handler (just RTI for now).
|
||||
pub fn gen_irq() -> Vec<Instruction> {
|
||||
vec![Instruction::implied(RTI)]
|
||||
}
|
||||
132
src/runtime/tests.rs
Normal file
132
src/runtime/tests.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue