From d609b77cd77491551e0e04087c12543d60322a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:43:53 +0000 Subject: [PATCH] IR codegen: scroll, debug.log, debug.assert Adds Scroll / DebugLog / DebugAssert variants to IrOp, wires them into the IR lowering, optimizer liveness tracking, and IR codegen. Scroll emits two PPU $2005 writes; debug statements emit writes to $4800 when IrCodeGen::with_debug(true) is set, stripped otherwise. These were the last feature gaps versus the AST codegen path, so IR codegen is now a full replacement. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 --- src/codegen/ir_codegen.rs | 127 ++++++++++++++++++++++++++++++++++++++ src/ir/lowering.rs | 17 +++-- src/ir/mod.rs | 8 +++ src/main.rs | 1 + src/optimizer/mod.rs | 15 +++++ 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index d864658..31c2fae 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -34,6 +34,11 @@ const TEMP_BASE: u8 = 0x80; const ZP_FRAME_FLAG: u8 = 0x00; const ZP_CURRENT_STATE: u8 = 0x03; +/// Emulator debug output port. Writes to this address are logged by +/// Mesen / fceux when the debugger is attached. Used by `debug.log` +/// and `debug.assert` when compiled with `--debug`. +const DEBUG_PORT: u16 = 0x4800; + /// IR codegen that produces 6502 instructions from an `IrProgram`. pub struct IrCodeGen<'a> { instructions: Vec, @@ -54,6 +59,9 @@ pub struct IrCodeGen<'a> { /// True while generating code inside a state frame handler. /// When set, `Return` terminators emit `JMP __ir_main_loop` instead of `RTS`. in_frame_handler: bool, + /// When true, emit code for `debug.log` / `debug.assert`. + /// When false, these ops are stripped entirely. + debug_mode: bool, _allocations: &'a [VarAllocation], } @@ -79,10 +87,19 @@ impl<'a> IrCodeGen<'a> { function_names, next_oam_slot: 0, in_frame_handler: false, + debug_mode: false, _allocations: allocations, } } + /// Enable debug-mode code generation. When set, `debug.log` and + /// `debug.assert` emit runtime code; otherwise they are stripped. + #[must_use] + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug_mode = debug; + self + } + fn function_exists(&self, name: &str) -> bool { self.function_names.contains(name) } @@ -442,6 +459,35 @@ impl<'a> IrCodeGen<'a> { self.emit(JMP, AM::Label("__ir_main_loop".into())); } } + IrOp::Scroll(x, y) => { + // PPU scroll register $2005 takes two writes: X then Y + self.load_temp(*x); + self.emit(STA, AM::Absolute(0x2005)); + self.load_temp(*y); + self.emit(STA, AM::Absolute(0x2005)); + } + IrOp::DebugLog(args) => { + if self.debug_mode { + for arg in args { + self.load_temp(*arg); + self.emit(STA, AM::Absolute(DEBUG_PORT)); + } + } + // In release mode, stripped entirely + } + IrOp::DebugAssert(cond) => { + if self.debug_mode { + // Load cond; if nonzero (true) skip; else halt + self.load_temp(*cond); + let pass_label = format!("__ir_assert_pass_{}", self.instructions.len()); + 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); + } + } IrOp::SourceLoc(_) => { // No code for source location markers } @@ -728,4 +774,85 @@ mod more_tests { .any(|i| i.opcode == STA && i.mode == AM::ZeroPage(0x03)); assert!(has_store_state, "transition should write to current_state"); } + + #[test] + fn ir_codegen_scroll_writes_ppu_register() { + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + var sx: u8 = 0 + var sy: u8 = 0 + on frame { scroll(sx, sy) } + start Main + "#, + ); + // Both X and Y scroll values should be written to $2005 + let scroll_writes = insts + .iter() + .filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2005)) + .count(); + assert_eq!(scroll_writes, 2, "scroll should emit two STA $2005 writes"); + } + + fn lower_and_gen_debug(source: &str) -> Vec { + let (prog, _) = parser::parse(source); + let prog = prog.unwrap(); + let analysis = analyzer::analyze(&prog); + let ir_program = ir::lower(&prog, &analysis); + IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_debug(true) + .generate(&ir_program) + } + + #[test] + fn ir_codegen_debug_log_emits_in_debug_mode() { + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + var x: u8 = 42 + on frame { debug.log(x) } + start Main + "#, + ); + // Should write to the debug port $4800 + let writes_debug_port = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800)); + assert!(writes_debug_port, "debug.log should write to $4800"); + } + + #[test] + fn ir_codegen_debug_log_stripped_in_release() { + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + var x: u8 = 42 + on frame { debug.log(x) } + start Main + "#, + ); + // No debug port writes in release mode + let writes_debug_port = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800)); + assert!( + !writes_debug_port, + "debug.log should be stripped in release mode" + ); + } + + #[test] + fn ir_codegen_debug_assert_emits_in_debug_mode() { + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + var x: u8 = 42 + on frame { debug.assert(x == 42) } + start Main + "#, + ); + // Should emit a BRK for the fail path + let has_brk = insts.iter().any(|i| i.opcode == BRK); + assert!(has_brk, "debug.assert should emit BRK on failure path"); + } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 98f701e..54579a6 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -310,16 +310,21 @@ impl LoweringContext { let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); self.emit(IrOp::Call(None, name.clone(), arg_temps)); } - Statement::Scroll(_, _, _) => { - // TODO: implement scroll hardware writes + Statement::Scroll(x_expr, y_expr, _) => { + let x = self.lower_expr(x_expr); + let y = self.lower_expr(y_expr); + self.emit(IrOp::Scroll(x, y)); } Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => { // TODO: implement in asset pipeline } - Statement::DebugLog(_, _) | Statement::DebugAssert(_, _) => { - // Debug statements don't produce IR ops in release mode. - // In debug mode, the AST-based codegen handles them directly - // (IR codegen path for debug is a future enhancement). + Statement::DebugLog(args, _) => { + let temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); + self.emit(IrOp::DebugLog(temps)); + } + Statement::DebugAssert(cond, _) => { + let t = self.lower_expr(cond); + self.emit(IrOp::DebugAssert(t)); } } } diff --git a/src/ir/mod.rs b/src/ir/mod.rs index a5a6c70..f1c7295 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -129,6 +129,14 @@ pub enum IrOp { ReadInput(IrTemp, u8), WaitFrame, Transition(String), + /// Write PPU scroll registers (two writes to $2005: X then Y). + Scroll(IrTemp, IrTemp), + /// Debug: write a list of temps to the emulator debug port ($4800). + /// Stripped in release mode by the codegen. + DebugLog(Vec), + /// Debug: runtime assertion — if `cond` is zero, halt with debug marker. + /// Stripped in release mode by the codegen. + DebugAssert(IrTemp), // Source mapping SourceLoc(Span), diff --git a/src/main.rs b/src/main.rs index 0b1f7da..f32d07e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,6 +140,7 @@ fn compile(input: &PathBuf, debug: bool, asm_dump: bool, use_ir: bool) -> Result use nescript::codegen::IrCodeGen; IrCodeGen::new(&analysis.var_allocations, &ir_program) .with_sprites(&sprites) + .with_debug(debug) .generate(&ir_program) } else { CodeGen::new(&analysis.var_allocations, &program.constants) diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index d86242b..d4de3a6 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -379,6 +379,18 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { used.insert(*f); } } + IrOp::Scroll(x, y) => { + used.insert(*x); + used.insert(*y); + } + IrOp::DebugLog(args) => { + for a in args { + used.insert(*a); + } + } + IrOp::DebugAssert(cond) => { + used.insert(*cond); + } IrOp::ReadInput(_, _) | IrOp::WaitFrame | IrOp::Transition(_) | IrOp::SourceLoc(_) => {} } } @@ -412,6 +424,9 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::DrawSprite { .. } | IrOp::WaitFrame | IrOp::Transition(_) + | IrOp::Scroll(_, _) + | IrOp::DebugLog(_) + | IrOp::DebugAssert(_) | IrOp::SourceLoc(_) => None, } }