From d4e613fb7cae0b16e22908d65f2606f68361e23c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:57:45 +0000 Subject: [PATCH] debug: add `debug.frame_overrun_count()` and `debug.frame_overran()` builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame-overrun counter at $07FF was previously only readable via `peek(0x07FF)`, which forces every program that wants to guard against missed frames to know the magic address. This adds two named query expressions: - `debug.frame_overrun_count()` — cumulative miss count since reset - `debug.frame_overran()` — sticky bit cleared by the next wait_frame, so `debug.assert(not debug.frame_overran())` catches a miss in the previous window without waiting for the counter to roll over. The sticky bit lives at $07FE alongside the existing counter and is set inside the same NMI-time overrun branch. Release builds emit none of the runtime side: the NMI handler still skips both writes, the codegen `wait_frame` only clears $07FE in debug mode, and committed example ROMs stay byte-identical. The new expression form parses through `parse_primary`'s `KwDebug` arm, so the existing `debug.log(...)` / `debug.assert(...)` *statement* parser stays untouched. The analyzer rejects unknown methods with E0201 and stray arguments with E0203 so typos don't silently compile to a zero load. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB --- src/analyzer/mod.rs | 48 ++++++++++++++++++++++++ src/analyzer/tests.rs | 71 +++++++++++++++++++++++++++++++++++ src/codegen/ir_codegen.rs | 79 ++++++++++++++++++++++++++++++++++++++- src/ir/lowering.rs | 23 ++++++++++++ src/ir/tests.rs | 70 ++++++++++++++++++++++++++++++++++ src/parser/ast.rs | 11 +++++- src/parser/mod.rs | 20 ++++++++++ src/parser/tests.rs | 55 +++++++++++++++++++++++++++ src/runtime/mod.rs | 31 ++++++++++++--- 9 files changed, 401 insertions(+), 7 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 27640fa..4ec4961 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -719,6 +719,38 @@ impl Analyzer { )); } } + Expr::DebugCall(method, args, span) => { + // Only the no-argument query methods are recognised + // today. Anything else is an error so a typo gets + // caught at compile time rather than silently + // returning zero. Argument expressions are walked + // for completeness even though no current method + // accepts any. + match method.as_str() { + "frame_overrun_count" | "frame_overran" => { + if !args.is_empty() { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!("`debug.{method}` takes no arguments, got {}", args.len()), + *span, + )); + } + } + _ => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "unknown debug method '{method}' \ + (expected 'frame_overrun_count' or 'frame_overran')" + ), + *span, + )); + } + } + for arg in args { + self.walk_expr_reads(arg); + } + } Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::ButtonRead(_, _, _) => {} } } @@ -1650,6 +1682,13 @@ impl Analyzer { Expr::ArrayLiteral(_, _) => Some(NesType::U8), // element type inferred from context Expr::Cast(_, target, _) => Some(target.clone()), Expr::StructLiteral(name, _, _) => Some(NesType::Struct(name.clone())), + // Both `debug.frame_overrun_count()` and + // `debug.frame_overran()` return a single byte, so they + // type-check as u8 even though the latter is conceptually + // a flag (0 / 1). Treating it as u8 lets it work in + // `debug.assert(!debug.frame_overran())` where the + // analyzer's bool-leniency rule for `!` already kicks in. + Expr::DebugCall(_, _, _) => Some(NesType::U8), } } } @@ -1925,6 +1964,15 @@ fn collect_calls_expr(expr: &Expr, calls: &mut Vec) { Expr::Cast(inner, _, _) => { collect_calls_expr(inner, calls); } + Expr::DebugCall(_, args, _) => { + // Debug calls aren't user-defined functions, so we + // don't add them to the call graph — but their + // argument expressions may still mention real calls + // we should track. + for arg in args { + collect_calls_expr(arg, calls); + } + } Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::Ident(_, _) diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index be41870..eb4d2cb 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -1735,3 +1735,74 @@ fn analyze_small_array_never_warns_w0108() { result.diagnostics ); } + +#[test] +fn analyze_debug_frame_overrun_count_ok() { + // The known-good debug expression methods type-check as u8 and + // can be assigned into a u8 variable without diagnostics. + analyze_ok( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overrun_count() + wait_frame + } + start Main + "#, + ); +} + +#[test] +fn analyze_debug_frame_overran_in_assert_ok() { + analyze_ok( + r#" + game "T" { mapper: NROM } + on frame { + debug.assert(not debug.frame_overran()) + wait_frame + } + start Main + "#, + ); +} + +#[test] +fn analyze_debug_unknown_method_errors() { + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.bogus() + wait_frame + } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for unknown debug method, got: {errors:?}" + ); +} + +#[test] +fn analyze_debug_frame_overrun_count_with_args_errors() { + // The query methods take no arguments — passing one is an + // arity error, not a silent "unused arg" warning. + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overrun_count(42) + wait_frame + } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0203), + "expected E0203 for arg count mismatch, got: {errors:?}" + ); +} diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 79d1501..44adfe6 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -1093,13 +1093,20 @@ impl<'a> IrCodeGen<'a> { self.store_temp(*dest); } IrOp::WaitFrame => { - // Poll frame flag at $00 until nonzero, then clear it + // Poll frame flag at $00 until nonzero, then clear it. + // In debug mode, also clear the per-frame "did the + // previous frame overrun" sticky bit so user code + // sees a fresh value next NMI. The cumulative + // counter at $07FF is intentionally left alone. let wait_label = format!("__ir_wait_{}", self.instructions.len()); self.emit_label(&wait_label); self.emit(LDA, AM::ZeroPage(ZP_FRAME_FLAG)); self.emit(BEQ, AM::LabelRelative(wait_label)); self.emit(LDA, AM::Immediate(0)); self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG)); + if self.debug_mode { + self.emit(STA, AM::Absolute(0x07FE)); + } } IrOp::Transition(name) => { // Write the target state's index to current_state, then @@ -2611,6 +2618,76 @@ mod more_tests { assert!(has_brk, "debug.assert should emit BRK on failure path"); } + #[test] + fn ir_codegen_wait_frame_clears_overrun_flag_in_debug_mode() { + // The per-frame frame-overrun sticky bit at $07FE is set + // by the NMI handler when an overrun is detected and + // cleared by `wait_frame` on the way out so user code sees + // a fresh value next NMI. The clear is gated on debug + // mode — release builds must not touch $07FE so existing + // ROMs stay byte-identical. + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + on frame { wait_frame } + start Main + "#, + ); + let clears_flag = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x07FE)); + assert!( + clears_flag, + "debug-mode wait_frame should clear the per-frame overrun flag at $07FE" + ); + } + + #[test] + fn ir_codegen_wait_frame_release_does_not_touch_overrun_flag() { + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + on frame { wait_frame } + start Main + "#, + ); + let touches_flag = insts + .iter() + .any(|i| (i.opcode == STA || i.opcode == LDA) && i.mode == AM::Absolute(0x07FE)); + assert!( + !touches_flag, + "release-mode wait_frame must not touch $07FE" + ); + } + + #[test] + fn ir_codegen_debug_frame_overrun_count_loads_07ff_in_debug_mode() { + // The expression form should compile to an absolute load + // from the canonical runtime address. We use debug mode + // because that's the only configuration where the address + // is meaningful — but the load itself is the same in + // release builds, where it just reads the zero-initialized + // RAM byte and falls through. + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overrun_count() + wait_frame + } + start Main + "#, + ); + let reads_counter = insts + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07FF)); + assert!( + reads_counter, + "debug.frame_overrun_count() should LDA $07FF" + ); + } + #[test] fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() { // Regression test for bug B. A `draw` inside a `while` diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index f715170..2a4313e 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -1090,6 +1090,29 @@ impl LoweringContext { // For now, just evaluate the inner expression (truncation/extension is a no-op on 8-bit) self.lower_expr(inner) } + Expr::DebugCall(method, _args, _) => { + // The analyzer already validated the method name and + // argument count, so we can dispatch on the method + // name directly. Both currently-supported methods + // map to a Peek of a runtime address: the codegen + // strips the read out and substitutes a constant + // zero in release builds, so the builtin disappears + // from non-debug ROMs. + let t = self.fresh_temp(); + let addr: u16 = match method.as_str() { + "frame_overrun_count" => 0x07FF, + "frame_overran" => 0x07FE, + // Should be unreachable post-analyzer, but emit + // a zero rather than panicking so a parser test + // that bypasses the analyzer still produces IR. + _ => { + self.emit(IrOp::LoadImm(t, 0)); + return t; + } + }; + self.emit(IrOp::Peek(t, addr)); + t + } } } diff --git a/src/ir/tests.rs b/src/ir/tests.rs index 35c5bb6..b2154eb 100644 --- a/src/ir/tests.rs +++ b/src/ir/tests.rs @@ -313,6 +313,76 @@ fn lower_wait_frame() { assert!(has_wait, "should emit WaitFrame op"); } +#[test] +fn lower_debug_frame_overrun_count_emits_peek() { + // `debug.frame_overrun_count()` lowers to a Peek of the + // canonical $07FF runtime address. The release-mode codegen + // gating happens later — at the IR level we always emit the + // Peek so the optimizer/codegen has a single uniform shape. + let ir = lower_ok( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overrun_count() + wait_frame + } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .unwrap(); + let peek_addr = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .find_map(|op| match op { + IrOp::Peek(_, addr) => Some(*addr), + _ => None, + }); + assert_eq!( + peek_addr, + Some(0x07FF), + "expected Peek($07FF) for frame_overrun_count" + ); +} + +#[test] +fn lower_debug_frame_overran_emits_peek_07fe() { + let ir = lower_ok( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overran() + wait_frame + } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .unwrap(); + let peek_addr = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .find_map(|op| match op { + IrOp::Peek(_, addr) => Some(*addr), + _ => None, + }); + assert_eq!( + peek_addr, + Some(0x07FE), + "expected Peek($07FE) for frame_overran" + ); +} + #[test] fn array_literal_global_init_is_captured() { // Regression test: `var xs: u8[4] = [1, 2, 3, 4]` used to lose diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 096e1e5..df14876 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -352,6 +352,14 @@ pub enum Expr { /// parser bans them inside `if`/`while`/`for` conditions to /// avoid ambiguity with the following block. StructLiteral(String, Vec<(String, Expr)>, Span), + /// `debug.METHOD(args)` expression form. Today only the + /// no-argument query methods (`frame_overrun_count`, + /// `frame_overran`) are accepted; other names are rejected by + /// the analyzer. Lowering inspects [`crate::ir::lowering`] and + /// emits either a Peek of the corresponding runtime address (in + /// debug mode) or a constant zero (in release mode), so the + /// builtin compiles to nothing in release builds. + DebugCall(String, Vec, Span), } impl Expr { @@ -368,7 +376,8 @@ impl Expr { | Self::ButtonRead(_, _, s) | Self::ArrayLiteral(_, s) | Self::Cast(_, _, s) - | Self::StructLiteral(_, _, s) => *s, + | Self::StructLiteral(_, _, s) + | Self::DebugCall(_, _, s) => *s, } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4829f92..1bc64d1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2990,6 +2990,26 @@ impl Parser { self.advance(); Ok(Expr::IntLiteral(v, span)) } + TokenKind::KwDebug => { + // `debug.METHOD(args)` expression form. The + // statement form is parsed separately by + // parse_debug_statement; here we only accept the + // value-returning methods. + let span = self.current_span(); + self.advance(); + self.expect(&TokenKind::Dot)?; + let (method, _) = self.expect_ident()?; + self.expect(&TokenKind::LParen)?; + 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(Expr::DebugCall(method, args, span)) + } TokenKind::BoolLiteral(v) => { let span = self.current_span(); self.advance(); diff --git a/src/parser/tests.rs b/src/parser/tests.rs index cf2ecfa..43e5de1 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -1696,6 +1696,61 @@ fn parse_debug_assert() { assert!(matches!(frame.statements[0], Statement::DebugAssert(..))); } +#[test] +fn parse_debug_frame_overrun_count_expression() { + // `debug.frame_overrun_count()` is an *expression* — distinct + // from the `debug.log` / `debug.assert` *statements*. It should + // parse to an Expr::DebugCall on the RHS of an assignment. + let src = r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.frame_overrun_count() + } + start Main + "#; + let prog = parse_ok(src); + let frame = prog.states[0].on_frame.as_ref().unwrap(); + let stmt = &frame.statements[0]; + let Statement::Assign(_, _, rhs, _) = stmt else { + panic!("expected assign, got {stmt:?}"); + }; + match rhs { + Expr::DebugCall(method, args, _) => { + assert_eq!(method, "frame_overrun_count"); + assert!(args.is_empty()); + } + other => panic!("expected DebugCall expression, got {other:?}"), + } +} + +#[test] +fn parse_debug_frame_overran_in_assert() { + // The flag-style query nests inside `debug.assert(...)` — i.e. + // a debug-statement form whose argument is itself a + // debug-expression form. This exercises both parser paths in + // a single program. + let src = r#" + game "T" { mapper: NROM } + on frame { + debug.assert(not debug.frame_overran()) + } + start Main + "#; + let prog = parse_ok(src); + let frame = prog.states[0].on_frame.as_ref().unwrap(); + let Statement::DebugAssert(cond, _) = &frame.statements[0] else { + panic!("expected DebugAssert"); + }; + let Expr::UnaryOp(UnaryOp::Not, inner, _) = cond else { + panic!("expected not cond"); + }; + let Expr::DebugCall(method, _, _) = inner.as_ref() else { + panic!("expected DebugCall inside not"); + }; + assert_eq!(method, "frame_overran"); +} + // ── Named colour palettes ── #[test] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index a40b380..7e66abb 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -89,13 +89,24 @@ pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17; /// handler whenever it fires while the previous frame's ready /// flag is still set — which means the main loop didn't consume /// it, so user code spent more than one vblank-to-vblank window -/// processing the last frame. Read it with `peek(0x07FF)` in -/// user code to see how many overruns have happened since reset, -/// or watch the address in a Mesen memory viewer. Placed at the -/// top of main RAM to minimise the chance of a collision with -/// analyzer-allocated variables (which grow from $0300 upward). +/// processing the last frame. Read it with `peek(0x07FF)` or +/// `debug.frame_overrun_count()` in user code to see how many +/// overruns have happened since reset, or watch the address in +/// a Mesen memory viewer. Placed at the top of main RAM to +/// minimise the chance of a collision with analyzer-allocated +/// variables (which grow from $0300 upward). pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF; +/// Debug-mode "did the previous frame overrun" sticky bit. Set +/// to 1 by the NMI handler at the same time as it bumps +/// [`DEBUG_FRAME_OVERRUN_ADDR`], and cleared to 0 by `wait_frame` +/// once the main loop catches up. Exposed to user code as +/// `debug.frame_overran()` — a per-frame "did this frame finish +/// in time" predicate suited for `debug.assert(!debug.frame_overran())` +/// guards. Lives one byte below the cumulative counter so the +/// two can be inspected together in a Mesen memory viewer. +pub const DEBUG_FRAME_OVERRUN_FLAG_ADDR: u16 = 0x07FE; + // ── Extra channel state ── // // The pulse-1 sfx and pulse-2 music channels live in zero page @@ -344,6 +355,16 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec< INC, AM::Absolute(DEBUG_FRAME_OVERRUN_ADDR), )); + // Set the per-frame sticky bit. It stays set until the + // next `wait_frame` clears it, so a single + // `debug.assert(!debug.frame_overran())` guard at the top + // of `on frame { ... }` catches any miss in the previous + // window. + out.push(Instruction::new(LDA, AM::Immediate(0x01))); + out.push(Instruction::new( + STA, + AM::Absolute(DEBUG_FRAME_OVERRUN_FLAG_ADDR), + )); out.push(Instruction::new( NOP, AM::Label("__debug_no_overrun".into()),