1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

debug: add debug.frame_overrun_count() and debug.frame_overran() builtins

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
This commit is contained in:
Claude 2026-04-15 01:57:45 +00:00
parent bf7819ca0f
commit d4e613fb7c
No known key found for this signature in database
9 changed files with 401 additions and 7 deletions

View file

@ -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<String>) {
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(_, _)

View file

@ -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:?}"
);
}

View file

@ -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`

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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<Expr>, 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,
}
}
}

View file

@ -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();

View file

@ -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]

View file

@ -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()),