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:
parent
bf7819ca0f
commit
d4e613fb7c
9 changed files with 401 additions and 7 deletions
|
|
@ -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(_, _)
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue