mirror of
https://github.com/imjasonh/nescript
synced 2026-07-21 06:17:18 +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(_, _, _) => {}
|
Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::ButtonRead(_, _, _) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1650,6 +1682,13 @@ impl Analyzer {
|
||||||
Expr::ArrayLiteral(_, _) => Some(NesType::U8), // element type inferred from context
|
Expr::ArrayLiteral(_, _) => Some(NesType::U8), // element type inferred from context
|
||||||
Expr::Cast(_, target, _) => Some(target.clone()),
|
Expr::Cast(_, target, _) => Some(target.clone()),
|
||||||
Expr::StructLiteral(name, _, _) => Some(NesType::Struct(name.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, _, _) => {
|
Expr::Cast(inner, _, _) => {
|
||||||
collect_calls_expr(inner, calls);
|
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::IntLiteral(_, _)
|
||||||
| Expr::BoolLiteral(_, _)
|
| Expr::BoolLiteral(_, _)
|
||||||
| Expr::Ident(_, _)
|
| Expr::Ident(_, _)
|
||||||
|
|
|
||||||
|
|
@ -1735,3 +1735,74 @@ fn analyze_small_array_never_warns_w0108() {
|
||||||
result.diagnostics
|
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);
|
self.store_temp(*dest);
|
||||||
}
|
}
|
||||||
IrOp::WaitFrame => {
|
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());
|
let wait_label = format!("__ir_wait_{}", self.instructions.len());
|
||||||
self.emit_label(&wait_label);
|
self.emit_label(&wait_label);
|
||||||
self.emit(LDA, AM::ZeroPage(ZP_FRAME_FLAG));
|
self.emit(LDA, AM::ZeroPage(ZP_FRAME_FLAG));
|
||||||
self.emit(BEQ, AM::LabelRelative(wait_label));
|
self.emit(BEQ, AM::LabelRelative(wait_label));
|
||||||
self.emit(LDA, AM::Immediate(0));
|
self.emit(LDA, AM::Immediate(0));
|
||||||
self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG));
|
self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG));
|
||||||
|
if self.debug_mode {
|
||||||
|
self.emit(STA, AM::Absolute(0x07FE));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IrOp::Transition(name) => {
|
IrOp::Transition(name) => {
|
||||||
// Write the target state's index to current_state, then
|
// 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");
|
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]
|
#[test]
|
||||||
fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() {
|
fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() {
|
||||||
// Regression test for bug B. A `draw` inside a `while`
|
// 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)
|
// For now, just evaluate the inner expression (truncation/extension is a no-op on 8-bit)
|
||||||
self.lower_expr(inner)
|
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");
|
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]
|
#[test]
|
||||||
fn array_literal_global_init_is_captured() {
|
fn array_literal_global_init_is_captured() {
|
||||||
// Regression test: `var xs: u8[4] = [1, 2, 3, 4]` used to lose
|
// 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
|
/// parser bans them inside `if`/`while`/`for` conditions to
|
||||||
/// avoid ambiguity with the following block.
|
/// avoid ambiguity with the following block.
|
||||||
StructLiteral(String, Vec<(String, Expr)>, Span),
|
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 {
|
impl Expr {
|
||||||
|
|
@ -368,7 +376,8 @@ impl Expr {
|
||||||
| Self::ButtonRead(_, _, s)
|
| Self::ButtonRead(_, _, s)
|
||||||
| Self::ArrayLiteral(_, s)
|
| Self::ArrayLiteral(_, s)
|
||||||
| Self::Cast(_, _, s)
|
| Self::Cast(_, _, s)
|
||||||
| Self::StructLiteral(_, _, s) => *s,
|
| Self::StructLiteral(_, _, s)
|
||||||
|
| Self::DebugCall(_, _, s) => *s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2990,6 +2990,26 @@ impl Parser {
|
||||||
self.advance();
|
self.advance();
|
||||||
Ok(Expr::IntLiteral(v, span))
|
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) => {
|
TokenKind::BoolLiteral(v) => {
|
||||||
let span = self.current_span();
|
let span = self.current_span();
|
||||||
self.advance();
|
self.advance();
|
||||||
|
|
|
||||||
|
|
@ -1696,6 +1696,61 @@ fn parse_debug_assert() {
|
||||||
assert!(matches!(frame.statements[0], Statement::DebugAssert(..)));
|
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 ──
|
// ── Named colour palettes ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,24 @@ pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17;
|
||||||
/// handler whenever it fires while the previous frame's ready
|
/// handler whenever it fires while the previous frame's ready
|
||||||
/// flag is still set — which means the main loop didn't consume
|
/// flag is still set — which means the main loop didn't consume
|
||||||
/// it, so user code spent more than one vblank-to-vblank window
|
/// it, so user code spent more than one vblank-to-vblank window
|
||||||
/// processing the last frame. Read it with `peek(0x07FF)` in
|
/// processing the last frame. Read it with `peek(0x07FF)` or
|
||||||
/// user code to see how many overruns have happened since reset,
|
/// `debug.frame_overrun_count()` in user code to see how many
|
||||||
/// or watch the address in a Mesen memory viewer. Placed at the
|
/// overruns have happened since reset, or watch the address in
|
||||||
/// top of main RAM to minimise the chance of a collision with
|
/// a Mesen memory viewer. Placed at the top of main RAM to
|
||||||
/// analyzer-allocated variables (which grow from $0300 upward).
|
/// minimise the chance of a collision with analyzer-allocated
|
||||||
|
/// variables (which grow from $0300 upward).
|
||||||
pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF;
|
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 ──
|
// ── Extra channel state ──
|
||||||
//
|
//
|
||||||
// The pulse-1 sfx and pulse-2 music channels live in zero page
|
// 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,
|
INC,
|
||||||
AM::Absolute(DEBUG_FRAME_OVERRUN_ADDR),
|
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(
|
out.push(Instruction::new(
|
||||||
NOP,
|
NOP,
|
||||||
AM::Label("__debug_no_overrun".into()),
|
AM::Label("__debug_no_overrun".into()),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue