From 45b2b2a279b3de91c9c0a3ae7383833524b91e7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:11:32 +0000 Subject: [PATCH] Add debug.log and debug.assert statements Parser: - debug.log(expr, ...) and debug.assert(cond) syntax - parse_debug_statement() handles both methods - New AST variants: Statement::DebugLog and Statement::DebugAssert Codegen: - CodeGen::with_debug(bool) to toggle debug instrumentation - DebugLog writes each expression to $4800 (Mesen debug port) - DebugAssert evaluates condition; on false, writes $FF to $4800 + BRK - Both are stripped entirely when debug_mode = false (release builds) CLI: - --debug flag now wired through to CodeGen - Without --debug, all debug statements produce zero bytes Analyzer: - Type-checks DebugAssert condition as bool - Walks DebugLog args for reads (used_vars tracking) IR lowering: - DebugLog/DebugAssert are no-ops (codegen handles them directly) 254 tests (5 new: 3 parser + 2 codegen). https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 --- src/analyzer/mod.rs | 17 +++++++++++++++ src/codegen/mod.rs | 38 ++++++++++++++++++++++++++++++++ src/codegen/tests.rs | 44 +++++++++++++++++++++++++++++++++++++ src/ir/lowering.rs | 5 +++++ src/main.rs | 7 +++--- src/parser/ast.rs | 6 +++++ src/parser/mod.rs | 33 ++++++++++++++++++++++++++++ src/parser/tests.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 199 insertions(+), 3 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 5391382..4b0b629 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -606,6 +606,15 @@ impl Analyzer { | Statement::Return(None, _) | Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {} + Statement::DebugLog(args, _) => { + for arg in args { + self.walk_expr_reads(arg); + } + } + Statement::DebugAssert(cond, _) => { + self.walk_expr_reads(cond); + self.check_expr_type(cond, &NesType::Bool); + } } } @@ -804,6 +813,14 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec) { collect_calls_expr(x, calls); collect_calls_expr(y, calls); } + Statement::DebugLog(args, _) => { + for arg in args { + collect_calls_expr(arg, calls); + } + } + Statement::DebugAssert(cond, _) => { + collect_calls_expr(cond, calls); + } Statement::Return(None, _) | Statement::Transition(_, _) | Statement::WaitFrame(_) diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index f5fabf7..d2ed047 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -13,6 +13,9 @@ 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 { @@ -20,6 +23,9 @@ pub struct CodeGen { 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 @@ -53,6 +59,7 @@ impl CodeGen { var_addrs, const_values, label_counter: 0, + debug_mode: false, frame_flag_addr: 0x00, input_addr: 0x01, state_indices: HashMap::new(), @@ -62,6 +69,14 @@ impl CodeGen { } } + /// 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] @@ -306,6 +321,29 @@ impl CodeGen { 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); + } + } } } diff --git a/src/codegen/tests.rs b/src/codegen/tests.rs index 46e282a..1146af2 100644 --- a/src/codegen/tests.rs +++ b/src/codegen/tests.rs @@ -232,3 +232,47 @@ fn codegen_shift_left() { "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/ir/lowering.rs b/src/ir/lowering.rs index c3f7e30..894856b 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -305,6 +305,11 @@ impl LoweringContext { 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). + } } } diff --git a/src/main.rs b/src/main.rs index bb0eb9e..b8979f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,7 @@ fn dump_asm(instructions: &[nescript::asm::Instruction]) { } } -fn compile(input: &PathBuf, _debug: bool, asm_dump: bool) -> Result, ()> { +fn compile(input: &PathBuf, debug: bool, asm_dump: bool) -> Result, ()> { let raw_source = std::fs::read_to_string(input).map_err(|e| { eprintln!("error: failed to read {}: {e}", input.display()); })?; @@ -130,8 +130,9 @@ fn compile(input: &PathBuf, _debug: bool, asm_dump: bool) -> Result, ()> })?; // Code generation (still AST-based for M2; IR codegen comes in M3) - let codegen = - CodeGen::new(&analysis.var_allocations, &program.constants).with_sprites(&sprites); + let codegen = CodeGen::new(&analysis.var_allocations, &program.constants) + .with_sprites(&sprites) + .with_debug(debug); let instructions = codegen.generate(&program); if asm_dump { diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 6f23c52..70778a7 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -239,6 +239,12 @@ pub enum Statement { LoadBackground(String, Span), SetPalette(String, Span), Scroll(Expr, Expr, Span), + /// debug.log(expr, ...) — writes values to the emulator debug port. + /// Stripped in release mode. + DebugLog(Vec, Span), + /// debug.assert(cond) — runtime check, halts on failure. + /// Stripped in release mode. + DebugAssert(Expr, Span), } #[derive(Debug, Clone)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0400290..82a6338 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -787,6 +787,7 @@ impl Parser { self.expect(&TokenKind::RParen)?; Ok(Statement::Scroll(x, y, span)) } + TokenKind::KwDebug => self.parse_debug_statement(), TokenKind::Ident(_) => self.parse_assign_or_call(), _ => Err(Diagnostic::error( ErrorCode::E0201, @@ -891,6 +892,38 @@ impl Parser { })) } + /// Parse debug.log(...) or debug.assert(...) + fn parse_debug_statement(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwDebug)?; + self.expect(&TokenKind::Dot)?; + let (method, _) = self.expect_ident()?; + self.expect(&TokenKind::LParen)?; + match method.as_str() { + "log" => { + let mut args = Vec::new(); + while *self.peek() != TokenKind::RParen && *self.peek() != TokenKind::Eof { + args.push(self.parse_expr()?); + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RParen)?; + Ok(Statement::DebugLog(args, start)) + } + "assert" => { + let cond = self.parse_expr()?; + self.expect(&TokenKind::RParen)?; + Ok(Statement::DebugAssert(cond, start)) + } + _ => Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown debug method '{method}' (expected 'log' or 'assert')"), + start, + )), + } + } + fn parse_assign_or_call(&mut self) -> Result { let start = self.current_span(); let (name, _) = self.expect_ident()?; diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 1a04b5c..e13dbbc 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -999,3 +999,55 @@ fn parse_shift_assign_operators() { let frame = prog.states[0].on_frame.as_ref().unwrap(); assert_eq!(frame.statements.len(), 2); } + +#[test] +fn parse_debug_log() { + let src = r#" + game "T" { mapper: NROM } + var x: u8 = 0 + on frame { + debug.log(x) + } + start Main + "#; + let prog = parse_ok(src); + let frame = prog.states[0].on_frame.as_ref().unwrap(); + match &frame.statements[0] { + Statement::DebugLog(args, _) => assert_eq!(args.len(), 1), + other => panic!("expected DebugLog, got {other:?}"), + } +} + +#[test] +fn parse_debug_log_multiple_args() { + let src = r#" + game "T" { mapper: NROM } + var x: u8 = 0 + var y: u8 = 0 + on frame { + debug.log(x, y, 42) + } + start Main + "#; + let prog = parse_ok(src); + let frame = prog.states[0].on_frame.as_ref().unwrap(); + match &frame.statements[0] { + Statement::DebugLog(args, _) => assert_eq!(args.len(), 3), + other => panic!("expected DebugLog, got {other:?}"), + } +} + +#[test] +fn parse_debug_assert() { + let src = r#" + game "T" { mapper: NROM } + var x: u8 = 0 + on frame { + debug.assert(x == 0) + } + start Main + "#; + let prog = parse_ok(src); + let frame = prog.states[0].on_frame.as_ref().unwrap(); + assert!(matches!(frame.statements[0], Statement::DebugAssert(..))); +}