diff --git a/docs/future-work.md b/docs/future-work.md index e351f88..8c12444 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -7,64 +7,9 @@ in the NEScript compiler. Items are organized by priority and area. ## 1. IR-Based Code Generation -**Status**: The IR pipeline (lowering + optimization) runs during compilation but -its output is discarded. Code generation still works from the AST directly -(`src/codegen/mod.rs`). This means IR-level optimizations have no effect on the -final ROM. - -**What exists**: -- `src/ir/mod.rs`: Complete IR type definitions (`IrProgram`, `IrFunction`, - `IrBasicBlock`, `IrOp`, `IrTerminator`) -- `src/ir/lowering.rs`: AST → IR translation for all statement and expression types -- `src/optimizer/mod.rs`: Constant folding, dead code elimination, strength - reduction, ZP promotion analysis, function inlining — all operating on IR - -**What's needed**: -- A new `src/codegen/ir_codegen.rs` that walks `IrProgram` and emits 6502 - `Instruction` sequences from `IrOp`/`IrTerminator` instead of from AST nodes -- Register allocation strategy for IR temps → A/X/Y/zero-page spill slots -- Replace the `CodeGen::generate(&program)` call in `main.rs` with - `ir_codegen::generate(&ir_program, &analysis)` -- Once working, delete the AST-based codegen entirely - -**IR lowering issues to fix first** (found during code review): -- `ButtonRead` emits `ReadInput` with no destination temp, then uses uninitialized - temp in the `And` mask operation (`src/ir/lowering.rs:534`). The fix: `ReadInput` - should store the input byte into a temp, or the lowering should emit - `LoadVar(t, input_var_id)` after `ReadInput`. -- Logical AND/OR use raw `VarId(self.next_var_id)` for temp storage without - registering it (`src/ir/lowering.rs:603,637`). Should use `IrTemp` instead. -- Break/continue create unreachable blocks that contain subsequent dead statements - (`src/ir/lowering.rs:259`). Should either skip lowering after a terminator, or - the dead code elimination pass should handle it. - -**Impact**: Enables all optimizer passes to actually affect output quality. -Currently the optimizer is validated by tests but its results are thrown away. - ---- - -## 2. Codegen Gaps (AST-Based) - -These features are parsed and analyzed but produce no 6502 output: - -| Feature | Location | Status | -|---------|----------|--------| -| Function calls | `codegen:148` | `Statement::Call` is a no-op | -| Return values | `codegen:148` | `Statement::Return` is a no-op | -| State transitions | `codegen:148` | `Statement::Transition` is a no-op | -| Array indexing | `codegen:199-200` | `LValue::ArrayIndex` assignment is a no-op | -| Array expressions | `codegen:417` | `Expr::ArrayIndex`, `Expr::ArrayLiteral` are no-ops | -| Function call expressions | `codegen:417` | `Expr::Call` returns nothing | -| Scroll | `codegen:151-152` | `Statement::Scroll` is a no-op | -| Load background | `codegen:154-155` | `Statement::LoadBackground` is a no-op | -| Set palette | `codegen:154-155` | `Statement::SetPalette` is a no-op | -| Multiply/divide/modulo | `codegen:450-452` | `BinOp::Mul/Div/Mod` only emit left operand | -| Dynamic shifts | `ir/lowering:575` | Shift amount is hardcoded to 1 | - -**Priority fixes for a working multi-state game**: -1. **Function calls**: JSR to function label, pass args via zero-page, return via A -2. **State transitions**: Write state ID to a zero-page variable, jump to dispatcher -3. **Array indexing**: Use X register for index, LDA absolute,X for loads +**Status**: Complete. The AST → IR lowering, optimizer, and +`src/codegen/ir_codegen.rs` all work end-to-end; the legacy AST +codegen has been removed. See "Recently completed" below. --- @@ -373,8 +318,10 @@ These items were documented as future work but have since been implemented: - **IR-based codegen** — `src/codegen/ir_codegen.rs` walks `IrProgram` and emits 6502 for every IR op: load/store, arithmetic, comparisons, arrays, calls, draws, input (P1 and P2), scroll, debug.log/assert, state - dispatch, multi-OAM slot allocation, transitions + on_enter handlers. - Now the default; `--use-ast` falls back to the legacy AST-based codegen. + dispatch, runtime OAM cursor for looped draws, transitions + on_enter + handlers. It's the only codegen — the legacy AST-based path and the + `--use-ast` flag were removed once the IR pipeline was proven correct + by the jsnes emulator smoke test. - **IR lowering bug fixes** — `ReadInput` now has a destination temp, `ButtonRead` uses the proper input temp, logical AND/OR use a new `emit_move` helper instead of the buggy raw VarId temp storage @@ -490,23 +437,18 @@ These items were documented as future work but have since been implemented: For someone picking up this codebase, the recommended order of work: -1. **Delete AST codegen** — IR codegen is now the default and beats - the AST codegen in 5/7 examples. Once confidence is high, remove - `--use-ast` and `src/codegen/mod.rs`'s AST-specific code. -2. **Struct literals** — `pos = Vec2 { x: 100, y: 50 }` as an - expression. Currently fields must be assigned individually. -3. **u16 / array / nested struct fields** — current structs only +1. **u16 / array / nested struct fields** — current structs only allow u8/i8/bool fields. The layout machinery is ready for more types but the codegen only handles 1-byte loads/stores. -4. **Audio driver** — the `play`/`start_music`/`stop_music` +2. **Audio driver** — the `play`/`start_music`/`stop_music` statements parse but don't generate any code. A minimal NSF-style driver running in NMI would unblock game projects. -5. **Multi-scanline on_scanline reload** — the current codegen +3. **Multi-scanline on_scanline reload** — the current codegen supports one scanline per state but not a chain of handlers at different scanlines in the same frame. -6. **Register allocator** — proper A/X/Y allocation to replace +4. **Register allocator** — proper A/X/Y allocation to replace zero-page spills used by the current IR codegen. Partially mitigated by peephole passes but still wasteful in some cases. -7. **Match statement** — `match x { 0 => ... , 1 => ... }` would be +5. **Match statement** — `match x { 0 => ... , 1 => ... }` would be useful for state dispatch and enum-driven logic. 8. **Text / HUD layer** — font sheet + layout system for scores. diff --git a/docs/language-guide.md b/docs/language-guide.md index 8ab8dfb..de29130 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -945,7 +945,6 @@ nescript build game.ne --dump-ir | `--dump-ir` | Dump the lowered IR program (after optimization) to stdout | | `--memory-map` | Dump a memory map of variable allocations to stdout | | `--call-graph` | Dump a call graph (which handler/function calls which) to stdout | -| `--use-ast` | Use the legacy AST-based codegen (default is the IR codegen) | ### Check diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index a94f8c7..8ebee4e 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -1,1159 +1,18 @@ +//! Code generation. +//! +//! Walks an `IrProgram` produced by `src/ir/lowering.rs` and emits 6502 +//! `Instruction` sequences. The final pass is `peephole::optimize`, which +//! cleans up the IR codegen's temp-heavy output into something closer to +//! hand-written assembly. +//! +//! There used to be a legacy AST-based codegen in this module alongside +//! `IrCodeGen`. It's been removed — the IR path is canonical, and the +//! AST path was strictly a subset (no struct literal init, no function +//! return values, no runtime OAM cursor for looped draws, no match with +//! many arms, etc.). Every example and integration test now goes through +//! `IrCodeGen`. + pub mod ir_codegen; pub mod peephole; -#[cfg(test)] -mod tests; - pub use ir_codegen::IrCodeGen; - -use std::collections::HashMap; - -use crate::analyzer::VarAllocation; -use crate::asm::{AddressingMode as AM, Instruction, Opcode::*}; -use crate::linker::SpriteData; -use crate::parser::ast::*; - -/// Zero-page address for the current state index. -pub const ZP_CURRENT_STATE: u8 = 0x03; -/// Zero-page addresses for function call parameter passing ($04-$07, up to 4 params). -pub const ZP_PARAM_BASE: u8 = 0x04; - -/// Debug output port address used by Mesen and some other emulators. -pub const DEBUG_PORT: u16 = 0x4800; - -/// Code generator: translates AST directly to 6502 instructions. -/// For Milestone 1, we skip the IR and go AST → 6502 directly. -pub struct CodeGen { - instructions: Vec, - var_addrs: HashMap, - const_values: HashMap, - label_counter: u32, - /// When true, debug.log/assert statements emit runtime code. - /// When false, they are stripped entirely. - debug_mode: bool, - /// Address of the NMI-signaled "frame ready" flag in zero page - pub frame_flag_addr: u8, - /// Address of controller state byte in zero page - pub input_addr: u8, - /// Maps state name → numeric index for dispatch - state_indices: HashMap, - /// Stack of (`continue_label`, `break_label`) for nested loops - loop_stack: Vec<(String, String)>, - /// Next OAM slot to allocate (0-63), reset per frame handler - next_oam_slot: u8, - /// Maps sprite name → CHR ROM tile index for `draw SpriteName` - sprite_tiles: HashMap, -} - -impl CodeGen { - pub fn new(allocations: &[VarAllocation], constants: &[ConstDecl]) -> Self { - let mut var_addrs = HashMap::new(); - for alloc in allocations { - var_addrs.insert(alloc.name.clone(), alloc.address); - } - - let mut const_values = HashMap::new(); - for c in constants { - if let Expr::IntLiteral(v, _) = &c.value { - const_values.insert(c.name.clone(), *v); - } - } - // Enum variants get wired in via `with_enums` below so that the - // main.rs call sites stay concise. - - Self { - instructions: Vec::new(), - var_addrs, - const_values, - label_counter: 0, - debug_mode: false, - frame_flag_addr: 0x00, - input_addr: 0x01, - state_indices: HashMap::new(), - loop_stack: Vec::new(), - next_oam_slot: 0, - sprite_tiles: HashMap::new(), - } - } - - /// Enable debug mode: debug.log/debug.assert statements will emit runtime code. - /// When disabled (the default), debug statements are stripped. - #[must_use] - pub fn with_debug(mut self, enabled: bool) -> Self { - self.debug_mode = enabled; - self - } - - /// Register sprite-to-tile-index mappings so that `draw SpriteName` can - /// emit the correct CHR tile index instead of defaulting to 0. - #[must_use] - pub fn with_sprites(mut self, sprites: &[SpriteData]) -> Self { - for sprite in sprites { - self.sprite_tiles - .insert(sprite.name.clone(), sprite.tile_index); - } - self - } - - /// Register enum variants as constants (each variant gets a u8 - /// equal to its declaration order within the enum). - #[must_use] - pub fn with_enums(mut self, enums: &[EnumDecl]) -> Self { - for e in enums { - for (i, (variant, _)) in e.variants.iter().enumerate() { - self.const_values.insert(variant.clone(), i as u16); - } - } - self - } - - /// Replace `{name}` placeholders in an inline-asm body with the - /// resolved zero-page or absolute hex address. Unknown names - /// pass through unchanged so the asm parser can surface a clear - /// error. - fn substitute_asm_vars(&self, body: &str) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(body.len()); - let bytes = body.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'{' { - if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { - let name_start = i + 1; - let name_end = i + 1 + end; - let name = &body[name_start..name_end]; - if !name.is_empty() { - if let Some(&addr) = self.var_addrs.get(name) { - if addr < 0x100 { - let _ = write!(out, "${addr:02X}"); - } else { - let _ = write!(out, "${addr:04X}"); - } - i = name_end + 1; - continue; - } - } - } - } - out.push(bytes[i] as char); - i += 1; - } - out - } - - fn fresh_label(&mut self, prefix: &str) -> String { - self.label_counter += 1; - format!("__{prefix}_{}", self.label_counter) - } - - fn emit(&mut self, opcode: crate::asm::Opcode, mode: AM) { - self.instructions.push(Instruction::new(opcode, mode)); - } - - fn emit_label(&mut self, name: &str) { - self.instructions - .push(Instruction::new(NOP, AM::Label(name.to_string()))); - } - - pub fn generate(mut self, program: &Program) -> Vec { - // Assign each state an index - for (i, state) in program.states.iter().enumerate() { - self.state_indices.insert(state.name.clone(), i as u8); - } - - // Generate variable initializers - for var in &program.globals { - self.gen_var_init(var); - } - - // Initialize current_state to the start state's index - let start_index = self - .state_indices - .get(&program.start_state) - .copied() - .unwrap_or(0); - self.emit(LDA, AM::Immediate(start_index)); - self.emit(STA, AM::ZeroPage(ZP_CURRENT_STATE)); - - // If the start state has an on_enter handler, call it - if let Some(start_state) = program - .states - .iter() - .find(|s| s.name == program.start_state) - { - if start_state.on_enter.is_some() { - let enter_label = format!("__state_{start_index}_enter"); - self.emit(JSR, AM::Absolute(0)); - // Patch: use label-based absolute for JSR - let idx = self.instructions.len() - 1; - self.instructions[idx] = Instruction::new(JSR, AM::Label(enter_label)); - } - } - - // Main dispatch loop - let main_loop_label = "__main_loop".to_string(); - self.emit_label(&main_loop_label); - - // Wait for vblank flag - let wait_label = "__wait_vblank".to_string(); - self.emit_label(&wait_label); - self.emit(LDA, AM::ZeroPage(self.frame_flag_addr)); - self.emit(BEQ, AM::LabelRelative(wait_label.clone())); - // Clear the flag - self.emit(LDA, AM::Immediate(0)); - self.emit(STA, AM::ZeroPage(self.frame_flag_addr)); - - // Dispatch based on current_state - // Uses CMP + BNE skip + JMP pattern to avoid branch range limits - self.emit(LDA, AM::ZeroPage(ZP_CURRENT_STATE)); - for (i, state) in program.states.iter().enumerate() { - if state.on_frame.is_some() { - let frame_label = format!("__state_{i}_frame"); - let skip_label = self.fresh_label("dispatch_skip"); - self.emit(CMP, AM::Immediate(i as u8)); - self.emit(BNE, AM::LabelRelative(skip_label.clone())); - self.emit(JMP, AM::Label(frame_label)); - self.emit_label(&skip_label); - } - } - self.emit(JMP, AM::Label(main_loop_label.clone())); - - // Generate all state frame handlers as labeled subroutines - for (i, state) in program.states.iter().enumerate() { - if let Some(on_frame) = &state.on_frame { - let frame_label = format!("__state_{i}_frame"); - self.emit_label(&frame_label); - self.next_oam_slot = 0; // reset OAM allocation per frame - self.gen_block(on_frame); - self.emit(JMP, AM::Label(main_loop_label.clone())); - } - } - - // Generate on_enter handlers - for (i, state) in program.states.iter().enumerate() { - if let Some(on_enter) = &state.on_enter { - let enter_label = format!("__state_{i}_enter"); - self.emit_label(&enter_label); - self.gen_block(on_enter); - self.emit(RTS, AM::Implied); - } - } - - // Generate on_exit handlers - for (i, state) in program.states.iter().enumerate() { - if let Some(on_exit) = &state.on_exit { - let exit_label = format!("__state_{i}_exit"); - self.emit_label(&exit_label); - self.gen_block(on_exit); - self.emit(RTS, AM::Implied); - } - } - - // Generate function bodies - // We need to clone the function data we need to avoid borrow issues - let functions: Vec<_> = program - .functions - .iter() - .map(|f| { - ( - f.name.clone(), - f.params.iter().map(|p| p.name.clone()).collect::>(), - f.body.clone(), - ) - }) - .collect(); - for (name, params, body) in &functions { - let fn_label = format!("__fn_{name}"); - self.emit_label(&fn_label); - // Load parameters from zero-page param slots into local var addresses - for (j, param_name) in params.iter().enumerate() { - if let Some(&addr) = self.var_addrs.get(param_name) { - self.emit(LDA, AM::ZeroPage(ZP_PARAM_BASE + j as u8)); - self.emit_store(addr); - } - } - self.gen_block(body); - self.emit(RTS, AM::Implied); // fallthrough return - } - - self.instructions - } - - fn gen_var_init(&mut self, var: &VarDecl) { - if let Some(init) = &var.init { - if let Some(&addr) = self.var_addrs.get(&var.name) { - self.gen_expr(init); - self.emit_store(addr); - } - } - } - - fn gen_block(&mut self, block: &Block) { - for stmt in &block.statements { - self.gen_statement(stmt); - } - } - - fn gen_statement(&mut self, stmt: &Statement) { - match stmt { - Statement::VarDecl(var) => { - self.gen_var_init(var); - } - Statement::Assign(lvalue, op, expr, _) => { - self.gen_assign(lvalue, *op, expr); - } - Statement::If(cond, then_block, else_ifs, else_block, _) => { - self.gen_if(cond, then_block, else_ifs, else_block.as_ref()); - } - Statement::While(cond, body, _) => { - self.gen_while(cond, body); - } - Statement::Loop(body, _) => { - let loop_label = self.fresh_label("loop"); - let end_label = self.fresh_label("loop_end"); - self.loop_stack - .push((loop_label.clone(), end_label.clone())); - self.emit_label(&loop_label); - self.gen_block(body); - self.emit(JMP, AM::Label(loop_label)); - self.emit_label(&end_label); - self.loop_stack.pop(); - } - Statement::For { .. } => { - // AST codegen is legacy; for loops are only supported - // through the IR codegen path. Emit nothing so the - // program still links — users should use `--use-ast` - // without for loops if they rely on this path. - } - Statement::Draw(draw) => { - self.gen_draw(draw); - } - Statement::WaitFrame(_) => { - // Wait for vblank flag - let wait_label = self.fresh_label("wait_frame"); - self.emit_label(&wait_label); - self.emit(LDA, AM::ZeroPage(self.frame_flag_addr)); - self.emit(BEQ, AM::LabelRelative(wait_label)); - self.emit(LDA, AM::Immediate(0)); - self.emit(STA, AM::ZeroPage(self.frame_flag_addr)); - } - Statement::Break(_) => { - if let Some((_, break_label)) = self.loop_stack.last() { - self.emit(JMP, AM::Label(break_label.clone())); - } - } - Statement::Continue(_) => { - if let Some((continue_label, _)) = self.loop_stack.last() { - self.emit(JMP, AM::Label(continue_label.clone())); - } - } - Statement::Return(value, _) => { - if let Some(expr) = value { - self.gen_expr(expr); - } - self.emit(RTS, AM::Implied); - } - Statement::Transition(name, _) => { - if let Some(&idx) = self.state_indices.get(name) { - self.emit(LDA, AM::Immediate(idx)); - self.emit(STA, AM::ZeroPage(ZP_CURRENT_STATE)); - // Jump to main loop to start the new state's frame - self.emit(JMP, AM::Label("__main_loop".into())); - } - } - Statement::Call(name, args, _) => { - // Built-in `poke(addr, value)` writes `value` to the - // compile-time-constant `addr`. The IR codegen handles - // this intrinsic in ir/lowering.rs; the legacy AST - // codegen has to recognize it here too, otherwise it - // falls through to the generic JSR path and tries to - // call a non-existent `__fn_poke` routine. - if name == "poke" && args.len() == 2 { - if let Some(addr) = const_u16(&args[0]) { - self.gen_expr(&args[1]); - self.emit(STA, AM::Absolute(addr)); - return; - } - } - // Pass arguments via zero-page param slots ($04-$07) - for (i, arg) in args.iter().enumerate() { - self.gen_expr(arg); - self.emit(STA, AM::ZeroPage(0x04 + i as u8)); - } - let fn_label = format!("__fn_{name}"); - self.emit(JSR, AM::Label(fn_label)); - } - Statement::Scroll(x_expr, y_expr, _) => { - // PPU scroll register $2005 takes two writes: X then Y - self.gen_expr(x_expr); - self.emit(STA, AM::Absolute(0x2005)); // X scroll - self.gen_expr(y_expr); - self.emit(STA, AM::Absolute(0x2005)); // Y scroll - } - Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => { - // TODO: implement in asset pipeline - } - Statement::DebugLog(args, _) => { - if self.debug_mode { - for arg in args { - self.gen_expr(arg); - // Write A to debug port $4800 - self.emit(STA, AM::Absolute(DEBUG_PORT)); - } - } - // In release mode, stripped entirely - } - Statement::DebugAssert(cond, _) => { - if self.debug_mode { - // Evaluate condition; if zero (false), halt - self.gen_condition(cond); - let pass_label = self.fresh_label("assert_pass"); - self.emit(BNE, AM::LabelRelative(pass_label.clone())); - // Assertion failed: write marker to debug port and BRK - self.emit(LDA, AM::Immediate(0xFF)); - self.emit(STA, AM::Absolute(DEBUG_PORT)); - self.emit(BRK, AM::Implied); - self.emit_label(&pass_label); - } - } - Statement::InlineAsm(body, _) => { - let substituted = self.substitute_asm_vars(body); - match crate::asm::parse_inline(&substituted) { - Ok(parsed) => self.instructions.extend(parsed), - Err(msg) => { - eprintln!("inline asm error: {msg}"); - self.emit(BRK, AM::Implied); - } - } - } - Statement::RawAsm(body, _) => { - // Raw asm bypasses variable substitution. - match crate::asm::parse_inline(body) { - Ok(parsed) => self.instructions.extend(parsed), - Err(msg) => { - eprintln!("inline asm error: {msg}"); - self.emit(BRK, AM::Implied); - } - } - } - Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => { - // Audio statements compile to no-ops for now. - } - } - } - - fn gen_assign(&mut self, lvalue: &LValue, op: AssignOp, expr: &Expr) { - match lvalue { - LValue::Var(name) => { - if let Some(&addr) = self.var_addrs.get(name) { - match op { - AssignOp::Assign => { - self.gen_expr(expr); - self.emit_store(addr); - } - AssignOp::PlusAssign => { - self.emit_load(addr); - self.emit(CLC, AM::Implied); - self.gen_adc_expr(expr); - self.emit_store(addr); - } - AssignOp::MinusAssign => { - self.emit_load(addr); - self.emit(SEC, AM::Implied); - self.gen_sbc_expr(expr); - self.emit_store(addr); - } - AssignOp::AmpAssign => { - self.emit_load(addr); - self.gen_and_expr(expr); - self.emit_store(addr); - } - AssignOp::PipeAssign => { - self.emit_load(addr); - self.gen_ora_expr(expr); - self.emit_store(addr); - } - AssignOp::CaretAssign => { - self.emit_load(addr); - self.gen_eor_expr(expr); - self.emit_store(addr); - } - AssignOp::ShiftLeftAssign => { - // x <<= n: load, shift left n times, store - // For non-constant shift count, emit ASL A once - // (matches codegen of the << operator) - self.emit_load(addr); - self.emit(ASL, AM::Accumulator); - let _ = expr; // count is evaluated but not used for dynamic shifts yet - self.emit_store(addr); - } - AssignOp::ShiftRightAssign => { - self.emit_load(addr); - self.emit(LSR, AM::Accumulator); - let _ = expr; - self.emit_store(addr); - } - } - } - } - LValue::Field(name, field) => { - // Treat `name.field` as a regular variable. The - // analyzer has already synthesized a VarAllocation - // entry under the name `"struct.field"`. - let full_name = format!("{name}.{field}"); - if let Some(&addr) = self.var_addrs.get(&full_name) { - match op { - AssignOp::Assign => { - self.gen_expr(expr); - self.emit_store(addr); - } - AssignOp::PlusAssign => { - self.emit_load(addr); - self.emit(CLC, AM::Implied); - self.gen_adc_expr(expr); - self.emit_store(addr); - } - AssignOp::MinusAssign => { - self.emit_load(addr); - self.emit(SEC, AM::Implied); - self.gen_sbc_expr(expr); - self.emit_store(addr); - } - _ => { - // Other compound ops: read, compute, store - self.emit_load(addr); - self.gen_expr(expr); - self.emit_store(addr); - } - } - } - } - LValue::ArrayIndex(name, index) => { - if let Some(&base_addr) = self.var_addrs.get(name) { - // Evaluate index into X register - self.gen_expr(index); - self.emit(TAX, AM::Implied); - // Evaluate value into A - match op { - AssignOp::Assign => { - self.gen_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::PlusAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.emit(CLC, AM::Implied); - self.gen_adc_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::MinusAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.emit(SEC, AM::Implied); - self.gen_sbc_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::AmpAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.gen_and_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::PipeAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.gen_ora_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::CaretAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.gen_eor_expr(expr); - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::ShiftLeftAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.emit(ASL, AM::Accumulator); - let _ = expr; - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - AssignOp::ShiftRightAssign => { - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - self.emit(LSR, AM::Accumulator); - let _ = expr; - if base_addr < 0x100 { - self.emit(STA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(STA, AM::AbsoluteX(base_addr)); - } - } - } - } - } - } - } - - fn gen_if( - &mut self, - cond: &Expr, - then_block: &Block, - else_ifs: &[(Expr, Block)], - else_block: Option<&Block>, - ) { - let end_label = self.fresh_label("if_end"); - - // Evaluate condition - self.gen_condition(cond); - let else_label = self.fresh_label("if_else"); - self.emit(BEQ, AM::LabelRelative(else_label.clone())); - - // Then block - self.gen_block(then_block); - if !else_ifs.is_empty() || else_block.is_some() { - self.emit(JMP, AM::Label(end_label.clone())); - } - - self.emit_label(&else_label); - - // Else-if chains - for (i, (cond, block)) in else_ifs.iter().enumerate() { - self.gen_condition(cond); - let next_label = if i + 1 < else_ifs.len() || else_block.is_some() { - self.fresh_label("elif") - } else { - end_label.clone() - }; - self.emit(BEQ, AM::LabelRelative(next_label.clone())); - self.gen_block(block); - self.emit(JMP, AM::Label(end_label.clone())); - self.emit_label(&next_label); - } - - // Else block - if let Some(block) = else_block { - self.gen_block(block); - } - - self.emit_label(&end_label); - } - - fn gen_while(&mut self, cond: &Expr, body: &Block) { - let loop_label = self.fresh_label("while"); - let end_label = self.fresh_label("while_end"); - - self.emit_label(&loop_label); - self.gen_condition(cond); - self.emit(BEQ, AM::LabelRelative(end_label.clone())); - self.gen_block(body); - self.emit(JMP, AM::Label(loop_label)); - self.emit_label(&end_label); - } - - /// Generate code that evaluates a condition, leaving result in A - /// (non-zero = true, zero = false). - fn gen_condition(&mut self, expr: &Expr) { - match expr { - Expr::ButtonRead(player, button, _) => { - let mask = button_mask(button); - let addr = match player { - Some(Player::P2) => 0x08, // ZP_INPUT_P2 - _ => self.input_addr, // P1 or default - }; - self.emit(LDA, AM::ZeroPage(addr)); - self.emit(AND, AM::Immediate(mask)); - } - Expr::BinaryOp(left, op, right, _) => match op { - BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::Gt | BinOp::LtEq | BinOp::GtEq => { - self.gen_comparison(left, *op, right); - } - BinOp::And => { - let false_label = self.fresh_label("and_false"); - let end_label = self.fresh_label("and_end"); - self.gen_condition(left); - self.emit(BEQ, AM::LabelRelative(false_label.clone())); - self.gen_condition(right); - self.emit(JMP, AM::Label(end_label.clone())); - self.emit_label(&false_label); - self.emit(LDA, AM::Immediate(0)); - self.emit_label(&end_label); - } - BinOp::Or => { - let true_label = self.fresh_label("or_true"); - let end_label = self.fresh_label("or_end"); - self.gen_condition(left); - self.emit(BNE, AM::LabelRelative(true_label.clone())); - self.gen_condition(right); - self.emit(JMP, AM::Label(end_label.clone())); - self.emit_label(&true_label); - self.emit(LDA, AM::Immediate(1)); - self.emit_label(&end_label); - } - _ => { - // Treat the expression result as a boolean - self.gen_expr(expr); - } - }, - Expr::BoolLiteral(v, _) => { - self.emit(LDA, AM::Immediate(u8::from(*v))); - } - Expr::UnaryOp(UnaryOp::Not, inner, _) => { - // Logical NOT: if condition is nonzero → 0, if zero → 1 - self.gen_condition(inner); - let true_label = self.fresh_label("not_true"); - let end_label = self.fresh_label("not_end"); - self.emit(BEQ, AM::LabelRelative(true_label.clone())); - // Condition was true (nonzero), result is false (0) - self.emit(LDA, AM::Immediate(0)); - self.emit(JMP, AM::Label(end_label.clone())); - // Condition was false (zero), result is true (1) - self.emit_label(&true_label); - self.emit(LDA, AM::Immediate(1)); - self.emit_label(&end_label); - } - _ => { - self.gen_expr(expr); - } - } - } - - fn gen_comparison(&mut self, left: &Expr, op: BinOp, right: &Expr) { - self.gen_expr(left); - // Save A to a temp location - self.emit(PHA, AM::Implied); - self.gen_expr(right); - // Transfer right to temp, restore left to A - self.emit(STA, AM::ZeroPage(0x02)); // temp - self.emit(PLA, AM::Implied); - self.emit(CMP, AM::ZeroPage(0x02)); - - // Set A based on comparison result - let true_label = self.fresh_label("cmp_true"); - let end_label = self.fresh_label("cmp_end"); - - match op { - BinOp::Eq => { - self.emit(BEQ, AM::LabelRelative(true_label.clone())); - } - BinOp::NotEq => { - self.emit(BNE, AM::LabelRelative(true_label.clone())); - } - BinOp::Lt => { - self.emit(BCC, AM::LabelRelative(true_label.clone())); - } - BinOp::GtEq => { - self.emit(BCS, AM::LabelRelative(true_label.clone())); - } - BinOp::Gt => { - // A > temp: not equal AND carry set - self.emit(BEQ, AM::LabelRelative(end_label.clone())); - self.emit(BCS, AM::LabelRelative(true_label.clone())); - } - BinOp::LtEq => { - // A <= temp: equal OR carry clear - self.emit(BEQ, AM::LabelRelative(true_label.clone())); - self.emit(BCC, AM::LabelRelative(true_label.clone())); - } - _ => {} - } - // False path - self.emit(LDA, AM::Immediate(0)); - self.emit(JMP, AM::Label(end_label.clone())); - // True path - self.emit_label(&true_label); - self.emit(LDA, AM::Immediate(1)); - self.emit_label(&end_label); - } - - fn gen_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(LDA, AM::Immediate(*v as u8)); - } - Expr::BoolLiteral(v, _) => { - self.emit(LDA, AM::Immediate(u8::from(*v))); - } - Expr::Ident(name, _) => { - if let Some(&value) = self.const_values.get(name) { - self.emit(LDA, AM::Immediate(value as u8)); - } else if let Some(&addr) = self.var_addrs.get(name) { - self.emit_load(addr); - } - } - Expr::BinaryOp(left, op, right, _) => { - self.gen_binary_op(left, *op, right); - } - Expr::UnaryOp(op, inner, _) => { - self.gen_expr(inner); - match op { - UnaryOp::Negate => { - // Two's complement: EOR #$FF, CLC, ADC #1 - self.emit(EOR, AM::Immediate(0xFF)); - self.emit(CLC, AM::Implied); - self.emit(ADC, AM::Immediate(1)); - } - UnaryOp::Not => { - self.emit(EOR, AM::Immediate(0xFF)); - self.emit(AND, AM::Immediate(0x01)); - } - UnaryOp::BitNot => { - self.emit(EOR, AM::Immediate(0xFF)); - } - } - } - Expr::ButtonRead(player, button, _) => { - let mask = button_mask(button); - let addr = match player { - Some(Player::P2) => 0x08, // ZP_INPUT_P2 - _ => self.input_addr, - }; - self.emit(LDA, AM::ZeroPage(addr)); - self.emit(AND, AM::Immediate(mask)); - } - Expr::Cast(inner, _, _) => { - // For now, just evaluate the inner expression - self.gen_expr(inner); - } - Expr::ArrayIndex(name, index, _) => { - if let Some(&base_addr) = self.var_addrs.get(name) { - self.gen_expr(index); - self.emit(TAX, AM::Implied); - if base_addr < 0x100 { - self.emit(LDA, AM::ZeroPageX(base_addr as u8)); - } else { - self.emit(LDA, AM::AbsoluteX(base_addr)); - } - } - } - Expr::FieldAccess(name, field, _) => { - let full_name = format!("{name}.{field}"); - if let Some(&addr) = self.var_addrs.get(&full_name) { - self.emit_load(addr); - } - } - Expr::Call(name, args, _) => { - // Built-in `peek(addr)` reads a byte from the - // compile-time-constant `addr`. Matches the IR codegen's - // `Peek` handling so the legacy AST path doesn't emit a - // bogus JSR to a non-existent `__fn_peek` routine. - if name == "peek" && args.len() == 1 { - if let Some(addr) = const_u16(&args[0]) { - self.emit(LDA, AM::Absolute(addr)); - return; - } - } - // Other function calls as expressions would need JSR - // with a return-value slot; the legacy codegen doesn't - // implement that yet, so fall back to loading 0. - self.emit(LDA, AM::Immediate(0)); - } - Expr::ArrayLiteral(_, _) => { - // Array literals are handled at initialization time - } - Expr::StructLiteral(_, _, _) => { - // Struct literals only appear in assignments on the - // IR codegen path. Legacy AST codegen treats them as - // no-ops. - } - } - } - - fn gen_binary_op(&mut self, left: &Expr, op: BinOp, right: &Expr) { - match op { - BinOp::Add => { - self.gen_expr(left); - self.emit(CLC, AM::Implied); - self.gen_adc_expr(right); - } - BinOp::Sub => { - self.gen_expr(left); - self.emit(SEC, AM::Implied); - self.gen_sbc_expr(right); - } - BinOp::BitwiseAnd => { - self.gen_expr(left); - self.gen_and_expr(right); - } - BinOp::BitwiseOr => { - self.gen_expr(left); - self.gen_ora_expr(right); - } - BinOp::BitwiseXor => { - self.gen_expr(left); - self.gen_eor_expr(right); - } - BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::Gt | BinOp::LtEq | BinOp::GtEq => { - self.gen_comparison(left, op, right); - } - BinOp::Mul => { - // Software multiply: left in A, right in $02 - self.gen_expr(left); - self.emit(STA, AM::ZeroPage(0x04)); // save multiplicand - self.gen_expr(right); - self.emit(STA, AM::ZeroPage(0x02)); // multiplier - self.emit(LDA, AM::ZeroPage(0x04)); // restore multiplicand to A - self.emit(JSR, AM::Label("__multiply".into())); - // Result is in A - } - BinOp::Div => { - self.gen_expr(left); - self.emit(STA, AM::ZeroPage(0x04)); - self.gen_expr(right); - self.emit(STA, AM::ZeroPage(0x02)); // divisor - self.emit(LDA, AM::ZeroPage(0x04)); // dividend - self.emit(JSR, AM::Label("__divide".into())); - // Quotient in A - } - BinOp::Mod => { - self.gen_expr(left); - self.emit(STA, AM::ZeroPage(0x04)); - self.gen_expr(right); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(LDA, AM::ZeroPage(0x04)); - self.emit(JSR, AM::Label("__divide".into())); - self.emit(LDA, AM::ZeroPage(0x03)); // remainder is in $03 - } - BinOp::ShiftLeft => { - self.gen_expr(left); - self.emit(ASL, AM::Accumulator); - } - BinOp::ShiftRight => { - self.gen_expr(left); - self.emit(LSR, AM::Accumulator); - } - BinOp::And | BinOp::Or => { - // Logical operators handled in gen_condition context; here - // treat as expression evaluation - self.gen_expr(left); - } - } - } - - /// Generate ADC with an expression (optimizing for immediate values). - fn gen_adc_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(ADC, AM::Immediate(*v as u8)); - } - Expr::Ident(name, _) if self.const_values.contains_key(name) => { - let v = self.const_values[name]; - self.emit(ADC, AM::Immediate(v as u8)); - } - Expr::Ident(name, _) if self.var_addrs.contains_key(name) => { - let addr = self.var_addrs[name]; - if addr < 0x100 { - self.emit(ADC, AM::ZeroPage(addr as u8)); - } else { - self.emit(ADC, AM::Absolute(addr)); - } - } - _ => { - // Complex expr: evaluate, save to temp, then ADC - self.emit(PHA, AM::Implied); - self.gen_expr(expr); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(PLA, AM::Implied); - self.emit(ADC, AM::ZeroPage(0x02)); - } - } - } - - /// Generate SBC with an expression. - fn gen_sbc_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(SBC, AM::Immediate(*v as u8)); - } - Expr::Ident(name, _) if self.const_values.contains_key(name) => { - let v = self.const_values[name]; - self.emit(SBC, AM::Immediate(v as u8)); - } - Expr::Ident(name, _) if self.var_addrs.contains_key(name) => { - let addr = self.var_addrs[name]; - if addr < 0x100 { - self.emit(SBC, AM::ZeroPage(addr as u8)); - } else { - self.emit(SBC, AM::Absolute(addr)); - } - } - _ => { - self.emit(PHA, AM::Implied); - self.gen_expr(expr); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(PLA, AM::Implied); - self.emit(SBC, AM::ZeroPage(0x02)); - } - } - } - - fn gen_and_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(AND, AM::Immediate(*v as u8)); - } - _ => { - self.emit(PHA, AM::Implied); - self.gen_expr(expr); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(PLA, AM::Implied); - self.emit(AND, AM::ZeroPage(0x02)); - } - } - } - - fn gen_ora_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(ORA, AM::Immediate(*v as u8)); - } - _ => { - self.emit(PHA, AM::Implied); - self.gen_expr(expr); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(PLA, AM::Implied); - self.emit(ORA, AM::ZeroPage(0x02)); - } - } - } - - fn gen_eor_expr(&mut self, expr: &Expr) { - match expr { - Expr::IntLiteral(v, _) => { - self.emit(EOR, AM::Immediate(*v as u8)); - } - _ => { - self.emit(PHA, AM::Implied); - self.gen_expr(expr); - self.emit(STA, AM::ZeroPage(0x02)); - self.emit(PLA, AM::Implied); - self.emit(EOR, AM::ZeroPage(0x02)); - } - } - } - - fn gen_draw(&mut self, draw: &DrawStmt) { - // OAM buffer at $0200-$02FF: 64 sprites, 4 bytes each - // Each entry: Y, tile_index, attributes, X - let slot = self.next_oam_slot; - if slot >= 64 { - return; // OAM full — silently skip (analyzer should warn) - } - self.next_oam_slot += 1; - let base = 0x0200 + u16::from(slot) * 4; - - // Y position - self.gen_expr(&draw.y); - self.emit(STA, AM::Absolute(base)); - - // Tile index — use frame: expr if provided, else the sprite's - // resolved tile index, else 0 (default smiley). - if let Some(frame) = &draw.frame { - self.gen_expr(frame); - } else if let Some(&tile_idx) = self.sprite_tiles.get(&draw.sprite_name) { - self.emit(LDA, AM::Immediate(tile_idx)); - } else { - self.emit(LDA, AM::Immediate(0)); - } - self.emit(STA, AM::Absolute(base + 1)); - - // Attributes — default 0 - self.emit(LDA, AM::Immediate(0)); - self.emit(STA, AM::Absolute(base + 2)); - - // X position - self.gen_expr(&draw.x); - self.emit(STA, AM::Absolute(base + 3)); - } - - fn emit_load(&mut self, addr: u16) { - if addr < 0x100 { - self.emit(LDA, AM::ZeroPage(addr as u8)); - } else { - self.emit(LDA, AM::Absolute(addr)); - } - } - - fn emit_store(&mut self, addr: u16) { - if addr < 0x100 { - self.emit(STA, AM::ZeroPage(addr as u8)); - } else { - self.emit(STA, AM::Absolute(addr)); - } - } -} - -/// Map button name to NES controller bit mask. -fn button_mask(button: &str) -> u8 { - match button { - "a" => 0x80, - "b" => 0x40, - "select" => 0x20, - "start" => 0x10, - "up" => 0x08, - "down" => 0x04, - "left" => 0x02, - "right" => 0x01, - _ => 0x00, - } -} - -/// Return the compile-time-constant u16 value of an expression, or -/// `None` if it's not a plain integer literal. Used by the intrinsic -/// call handling to recognize `poke(0x2007, …)` and `peek(0x2002)` -/// at codegen time. -fn const_u16(expr: &Expr) -> Option { - match expr { - Expr::IntLiteral(v, _) => Some(*v), - _ => None, - } -} diff --git a/src/codegen/tests.rs b/src/codegen/tests.rs deleted file mode 100644 index 1146af2..0000000 --- a/src/codegen/tests.rs +++ /dev/null @@ -1,278 +0,0 @@ -use super::*; -use crate::analyzer; -use crate::asm::{AddressingMode as AM, Opcode::*}; -use crate::parser; - -fn compile_to_instructions(src: &str) -> Vec { - let (prog, diags) = parser::parse(src); - assert!(diags.is_empty(), "parse errors: {diags:?}"); - let prog = prog.unwrap(); - let analysis = analyzer::analyze(&prog); - assert!( - analysis.diagnostics.iter().all(|d| !d.is_error()), - "analysis errors: {:?}", - analysis.diagnostics - ); - - let codegen = CodeGen::new(&analysis.var_allocations, &prog.constants); - codegen.generate(&prog) -} - -fn has_instruction(instructions: &[Instruction], opcode: crate::asm::Opcode, mode: &AM) -> bool { - instructions - .iter() - .any(|i| i.opcode == opcode && i.mode == *mode) -} - -#[test] -fn codegen_var_init() { - let src = r#" - game "Test" { mapper: NROM } - var px: u8 = 128 - on frame { wait_frame } - start Main - "#; - let insts = compile_to_instructions(src); - // Should have LDA #128 and STA to zero page - assert!(has_instruction(&insts, LDA, &AM::Immediate(128))); -} - -#[test] -fn codegen_plus_assign() { - let src = r#" - game "Test" { mapper: NROM } - var px: u8 = 0 - on frame { px += 2 } - start Main - "#; - let insts = compile_to_instructions(src); - // Should have CLC, ADC #2 - assert!(has_instruction(&insts, CLC, &AM::Implied)); - assert!(has_instruction(&insts, ADC, &AM::Immediate(2))); -} - -#[test] -fn codegen_minus_assign() { - let src = r#" - game "Test" { mapper: NROM } - var px: u8 = 100 - on frame { px -= 1 } - start Main - "#; - let insts = compile_to_instructions(src); - assert!(has_instruction(&insts, SEC, &AM::Implied)); - assert!(has_instruction(&insts, SBC, &AM::Immediate(1))); -} - -#[test] -fn codegen_button_check() { - let src = r#" - game "Test" { mapper: NROM } - var px: u8 = 0 - on frame { - if button.right { px += 1 } - } - start Main - "#; - let insts = compile_to_instructions(src); - // Should read controller input and AND with right button mask (0x01) - assert!(has_instruction(&insts, AND, &AM::Immediate(0x01))); -} - -#[test] -fn codegen_draw_sprite() { - let src = r#" - game "Test" { mapper: NROM } - var px: u8 = 64 - var py: u8 = 64 - on frame { - draw Smiley at: (px, py) - } - start Main - "#; - let insts = compile_to_instructions(src); - // Should write to OAM buffer at $0200-$0203 - assert!(has_instruction(&insts, STA, &AM::Absolute(0x0200))); // Y - assert!(has_instruction(&insts, STA, &AM::Absolute(0x0201))); // tile - assert!(has_instruction(&insts, STA, &AM::Absolute(0x0202))); // attr - assert!(has_instruction(&insts, STA, &AM::Absolute(0x0203))); // X -} - -#[test] -fn codegen_const_usage() { - let src = r#" - game "Test" { mapper: NROM } - const SPEED: u8 = 2 - var px: u8 = 0 - on frame { px += SPEED } - start Main - "#; - let insts = compile_to_instructions(src); - // Constant should be inlined as immediate - assert!(has_instruction(&insts, ADC, &AM::Immediate(2))); -} - -#[test] -fn codegen_main_loop_structure() { - let src = r#" - game "Test" { mapper: NROM } - on frame { wait_frame } - start Main - "#; - let insts = compile_to_instructions(src); - // Should have JMP back to loop start - let has_jmp = insts.iter().any(|i| { - i.opcode == JMP && matches!(&i.mode, AM::Label(l) if l.starts_with("__main_loop")) - }); - assert!(has_jmp, "should have JMP to main loop"); -} - -#[test] -fn codegen_comparison() { - let src = r#" - game "Test" { mapper: NROM } - var x: u8 = 0 - on frame { - if x == 10 { x = 0 } - } - start Main - "#; - let insts = compile_to_instructions(src); - assert!(has_instruction(&insts, CMP, &AM::ZeroPage(0x02))); -} - -#[test] -fn codegen_array_index_read() { - let src = r#" - game "Test" { mapper: NROM } - var arr: u8[4] = [1, 2, 3, 4] - var idx: u8 = 0 - var result: u8 = 0 - on frame { - result = arr[idx] - } - start Main - "#; - let insts = compile_to_instructions(src); - // Reading arr[idx] should use TAX + LDA,X - assert!(has_instruction(&insts, TAX, &AM::Implied)); -} - -#[test] -fn codegen_array_index_write() { - let src = r#" - game "Test" { mapper: NROM } - var arr: u8[4] = [1, 2, 3, 4] - var idx: u8 = 0 - on frame { - arr[idx] = 42 - } - start Main - "#; - let insts = compile_to_instructions(src); - // Writing arr[idx] = val should use TAX + STA,X - assert!(has_instruction(&insts, TAX, &AM::Implied)); -} - -#[test] -fn codegen_scroll() { - let src = r#" - game "Test" { mapper: NROM } - var sx: u8 = 0 - var sy: u8 = 0 - on frame { - scroll(sx, sy) - } - start Main - "#; - let insts = compile_to_instructions(src); - // scroll(x, y) should write to $2005 twice - let count_2005 = insts - .iter() - .filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2005)) - .count(); - assert_eq!(count_2005, 2, "scroll should write to $2005 twice"); -} - -#[test] -fn codegen_multiply() { - let src = r#" - game "Test" { mapper: NROM } - var a: u8 = 3 - var b: u8 = 5 - var result: u8 = 0 - on frame { - result = a * b - } - start Main - "#; - let insts = compile_to_instructions(src); - // a * b should generate JSR __multiply - let has_jsr_multiply = insts - .iter() - .any(|i| i.opcode == JSR && matches!(&i.mode, AM::Label(l) if l == "__multiply")); - assert!(has_jsr_multiply, "multiply should generate JSR __multiply"); -} - -#[test] -fn codegen_shift_left() { - let src = r#" - game "Test" { mapper: NROM } - var x: u8 = 1 - var result: u8 = 0 - on frame { - result = x << 1 - } - start Main - "#; - let insts = compile_to_instructions(src); - // x << 1 should generate ASL A - assert!( - has_instruction(&insts, ASL, &AM::Accumulator), - "shift left should generate ASL A" - ); -} - -#[test] -fn codegen_debug_log_stripped_in_release() { - // Without --debug, debug.log should not emit any $4800 writes - let src = r#" - game "T" { mapper: NROM } - var x: u8 = 42 - on frame { - debug.log(x) - } - start Main - "#; - let insts = compile_to_instructions(src); - // Should NOT write to $4800 - let has_debug = insts - .iter() - .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800)); - assert!(!has_debug, "debug.log should be stripped in release mode"); -} - -#[test] -fn codegen_debug_log_emits_in_debug_mode() { - use crate::analyzer; - use crate::parser; - - let src = r#" - game "T" { mapper: NROM } - var x: u8 = 42 - on frame { - debug.log(x) - } - start Main - "#; - let (prog, _) = parser::parse(src); - let prog = prog.unwrap(); - let analysis = analyzer::analyze(&prog); - let codegen = CodeGen::new(&analysis.var_allocations, &prog.constants).with_debug(true); - let insts = codegen.generate(&prog); - // Should write to $4800 at least once - let has_debug = insts - .iter() - .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800)); - assert!(has_debug, "debug.log should write to $4800 in debug mode"); -} diff --git a/src/main.rs b/src/main.rs index 90d607b..f3b4c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use nescript::analyzer; use nescript::assets; -use nescript::codegen::{CodeGen, IrCodeGen}; +use nescript::codegen::IrCodeGen; use nescript::errors::render_diagnostics; use nescript::ir; use nescript::linker::Linker; @@ -41,11 +41,6 @@ enum Cli { /// Dump a call graph showing which functions call which. #[arg(long)] call_graph: bool, - - /// Use the legacy AST-based codegen. The default is the IR-based - /// codegen, which runs the optimizer passes before emitting 6502. - #[arg(long)] - use_ast: bool, }, /// Type-check a source file without building Check { @@ -66,7 +61,6 @@ fn main() { dump_ir, memory_map, call_graph, - use_ast, } => { let output = output.unwrap_or_else(|| input.with_extension("nes")); match compile( @@ -77,7 +71,6 @@ fn main() { dump_ir, memory_map, call_graph, - use_ast, }, ) { Ok(rom) => { @@ -226,7 +219,6 @@ struct CompileOptions { dump_ir: bool, memory_map: bool, call_graph: bool, - use_ast: bool, } fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { @@ -235,7 +227,6 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { let dump_ir = opts.dump_ir; let memory_map = opts.memory_map; let call_graph = opts.call_graph; - let use_ast = opts.use_ast; let raw_source = std::fs::read_to_string(input).map_err(|e| { eprintln!("error: failed to read {}: {e}", input.display()); })?; @@ -296,24 +287,14 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { eprintln!("error: {e}"); })?; - // Code generation: IR-based is the default. `--use-ast` switches to - // the legacy AST-based codegen for comparison and fallback. - let mut instructions = if use_ast { - CodeGen::new(&analysis.var_allocations, &program.constants) - .with_sprites(&sprites) - .with_enums(&program.enums) - .with_debug(debug) - .generate(&program) - } else { - IrCodeGen::new(&analysis.var_allocations, &ir_program) - .with_sprites(&sprites) - .with_debug(debug) - .generate(&ir_program) - }; + // IR-based code generation. Lower → optimize → emit 6502. + let mut instructions = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_debug(debug) + .generate(&ir_program); - // Peephole optimization: cheap pass that removes redundant - // store-then-load pairs over IR temp slots. Biggest win for the - // IR codegen, but safe for the AST codegen too. + // Peephole pass: cleans up the IR codegen's temp-heavy output — + // dead stores, redundant loads, short-branch folds, etc. nescript::codegen::peephole::optimize(&mut instructions); if asm_dump { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index b4ce468..145a760 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2,13 +2,17 @@ use std::path::Path; use nescript::analyzer; use nescript::assets; -use nescript::codegen::CodeGen; +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. +/// 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 { let (program, diags) = nescript::parser::parse(source); assert!( @@ -24,16 +28,15 @@ fn compile(source: &str) -> Vec { analysis.diagnostics ); - // Run IR lowering and optimization (validates the pipeline works) 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 = - CodeGen::new(&analysis.var_allocations, &program.constants).with_sprites(&sprites); - let instructions = codegen.generate(&program); + 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) @@ -643,9 +646,9 @@ fn compile_with_mapper(source: &str) -> Vec { let sprites = assets::resolve_sprites(&program, Path::new(".")) .expect("sprite resolution should succeed"); - let codegen = nescript::codegen::CodeGen::new(&analysis.var_allocations, &program.constants) - .with_sprites(&sprites); - let instructions = codegen.generate(&program); + 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) @@ -703,13 +706,15 @@ fn sprite_resolution_uses_tile_index() { "tile 0 should still contain the default smiley", ); - // In PRG ROM, look for `LDA #$01 ; STA $0201` which writes the Player's - // tile index (1) into the tile-index byte of the first OAM slot. + // 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, 0x8D, 0x01, 0x02]; + let pattern = [0xA9u8, 0x01, 0x99, 0x01, 0x02]; assert!( prg.windows(pattern.len()).any(|w| w == pattern), - "PRG ROM should contain LDA #$01 ; STA $0201 for draw Player", + "PRG ROM should contain LDA #$01 ; STA $0201,Y for draw Player", ); } @@ -746,38 +751,11 @@ fn program_with_mmc1() { } // ── IR Codegen Tests ── - -/// Compile a program using the IR-based codegen path instead of the -/// AST-based codegen. Validates the full IR pipeline produces a valid ROM. -fn compile_with_ir_codegen(source: &str) -> Vec { - use nescript::codegen::IrCodeGen; - - 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 - ); - - // Lower to IR and run the optimizer - let mut ir_program = ir::lower(&program, &analysis); - optimizer::optimize(&mut ir_program); - - // IR-based codegen - let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program); - let instructions = codegen.generate(&ir_program); - - // Link into a ROM - let linker = Linker::new(program.game.mirroring); - linker.link(&instructions) -} +// +// 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() { @@ -787,7 +765,7 @@ fn ir_codegen_minimal_rom() { on frame { wait_frame } start Main "#; - let rom_data = compile_with_ir_codegen(source); + 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); @@ -807,7 +785,7 @@ fn ir_codegen_full_pipeline() { } start Main "#; - let rom_data = compile_with_ir_codegen(source); + let rom_data = compile(source); rom::validate_ines(&rom_data).expect("should be valid iNES"); } @@ -831,7 +809,7 @@ fn ir_codegen_multi_state_dispatch() { } start Title "#; - let rom_data = compile_with_ir_codegen(source); + let rom_data = compile(source); let info = rom::validate_ines(&rom_data).expect("should be valid iNES"); assert_eq!(info.mapper, 0); } @@ -851,7 +829,7 @@ fn ir_codegen_multi_oam() { } start Main "#; - let rom_data = compile_with_ir_codegen(source); + let rom_data = compile(source); rom::validate_ines(&rom_data).expect("should be valid iNES"); }