1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +00:00

debug: add symbol export, source maps, bounds checks, overrun counter

Implements four items from docs/future-work.md's "Debug instrumentation"
section so debugging on real ROMs is no longer a guessing game:

1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now
   returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct
   from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs`
   renders that plus the analyzer's var allocations into a Mesen-
   compatible label listing (function entry points get `P:` entries
   at PRG-relative offsets; user vars get `R:` entries).

2. Source maps via `--source-map <path>`. IR lowering now emits a
   `SourceLoc(span)` op before every statement; the codegen turns each
   one into a `__src_<N>` label-definition pseudo-op and records the
   span in a side table. Source-marker emission is opt-in
   (`with_source_map(true)`) because labels become peephole block
   boundaries — leaving the markers off preserves byte-identical
   release ROMs.

3. Array bounds checking under `--debug`. Every `ArrayLoad` /
   `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:`
   guard, and the codegen emits one shared `__debug_halt` trap at the
   end of the fixed bank (writes $BC to the debug port then wedges in
   a tight `JMP $`). Release builds skip the whole thing.

4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a
   `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of
   the handler and increments a counter at `$07FF`
   (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning
   the main loop didn't reach `wait_frame` before the next vblank.
   User code can read the counter via `peek(0x07FF)`. This is the
   abbreviated form the future-work doc suggested: a bump-a-counter
   hook rather than a full cycle-budget tracker, which would need a
   new builtin. The codegen emits a `__debug_mode` marker label in
   debug mode so the linker can select the overrun-aware NMI variant.

Release ROMs for every committed example are byte-identical before
and after this change (verified with `git diff examples/` after a
full rebuild). All 512 lib tests and 71 integration tests pass;
`cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
Claude 2026-04-14 02:39:36 +00:00
parent 33351f8b32
commit b575921c8e
No known key found for this signature in database
9 changed files with 1081 additions and 23 deletions

View file

@ -108,6 +108,38 @@ pub struct IrCodeGen<'a> {
/// resulting `__ppu_update_used` marker label to decide whether
/// to splice the in-NMI palette/nametable update helper.
ppu_update_used: bool,
/// Source-location markers produced from [`IrOp::SourceLoc`].
/// Each entry is a `(label_name, span)` pair — the codegen
/// emits a unique label-definition pseudo-op at the current
/// instruction index, and the CLI later resolves each label's
/// CPU address through the linker's output map to produce a
/// `.map` file. Empty if the IR didn't contain any source
/// markers or if `emit_source_locs` is false.
source_locs: Vec<(String, crate::lexer::Span)>,
/// Next unused index for the monotonic `__src_<N>` label
/// counter. Bumped every time a new marker is emitted.
next_source_loc: u32,
/// When true, each [`IrOp::SourceLoc`] is lowered to a
/// label-definition pseudo-op and recorded in `source_locs`.
/// When false (the default), `SourceLoc` is silently dropped
/// so release-mode codegen output is byte-identical to the
/// pre-source-map behaviour — turning this on *does* affect
/// the peephole pass's block boundaries and shifts labels in
/// the final ROM. Enabled by the CLI when `--source-map` is
/// passed.
emit_source_locs: bool,
/// Byte size of each named global / local variable. Keyed by
/// IR `VarId`, mirrors [`Self::var_addrs`]. Used by the
/// debug-mode array bounds checker to emit an `idx >= size`
/// guard on every `ArrayLoad` / `ArrayStore`. Missing entries
/// mean "unknown size" and skip the check.
var_sizes: HashMap<VarId, u16>,
/// True once a bounds-check trip was emitted; the linker-side
/// helper (a `JMP $` infinite loop at `__debug_halt`) is
/// emitted once at the end of `generate()` so multiple
/// failing checks all land on the same debug marker. Skipped
/// entirely in release builds.
bounds_halt_used: bool,
allocations: &'a [VarAllocation],
}
@ -117,9 +149,11 @@ impl<'a> IrCodeGen<'a> {
// Globals in IR are in the same order as in the analyzer, so we
// can align them by name.
let mut var_addrs = HashMap::new();
let mut var_sizes = HashMap::new();
for global in &ir.globals {
if let Some(alloc) = allocations.iter().find(|a| a.name == global.name) {
var_addrs.insert(global.var_id, alloc.address);
var_sizes.insert(global.var_id, alloc.size);
}
}
// Map each function's parameter VarIds to the zero-page
@ -152,9 +186,11 @@ impl<'a> IrCodeGen<'a> {
if i < func.param_count {
if i < 4 {
var_addrs.insert(local.var_id, 0x04 + i as u16);
var_sizes.insert(local.var_id, local.size);
}
} else {
var_addrs.insert(local.var_id, local_ram_next);
var_sizes.insert(local.var_id, local.size);
local_ram_next += local.size.max(1);
}
}
@ -176,10 +212,37 @@ impl<'a> IrCodeGen<'a> {
debug_mode: false,
audio_used: false,
ppu_update_used: false,
source_locs: Vec::new(),
next_source_loc: 0,
emit_source_locs: false,
var_sizes,
bounds_halt_used: false,
allocations,
}
}
/// Enable source-location marker emission. When set, each
/// [`IrOp::SourceLoc`] lowers to a uniquely-named
/// label-definition pseudo-op and is recorded in
/// [`Self::source_locs`]. Off by default so release builds
/// produce byte-identical ROMs regardless of the IR lowering
/// stage's marker output.
#[must_use]
pub fn with_source_map(mut self, enabled: bool) -> Self {
self.emit_source_locs = enabled;
self
}
/// Source-location markers emitted during codegen. Populated
/// once [`Self::generate`] has run; each entry pairs a
/// `__src_<N>` label name with the span it came from. The CLI
/// uses this plus the linker's label map to write a source-map
/// file under `--source-map`.
#[must_use]
pub fn source_locs(&self) -> &[(String, crate::lexer::Span)] {
&self.source_locs
}
/// Enable debug-mode code generation. When set, `debug.log` and
/// `debug.assert` emit runtime code; otherwise they are stripped.
#[must_use]
@ -299,6 +362,52 @@ impl<'a> IrCodeGen<'a> {
self.emit(STA, AM::ZeroPage(addr));
}
/// Emit a debug-only array bounds check. Assumes A holds the
/// candidate index; emits `CMP #len; BCS __debug_halt` where
/// `len` is the declared byte size of the variable. For u8
/// arrays `size` is the element count (correct bound); for u16
/// arrays the codegen doesn't yet scale the index by element
/// width, so we use the raw byte size as the bound — that's
/// correct for the `ZeroPageX`/`AbsoluteX` lowering the current
/// codegen actually produces, and it's what a future lowering
/// fix would want the debug check to match anyway.
///
/// Release builds emit nothing. Also a no-op when the size
/// isn't known (e.g. a local we couldn't match up against an
/// allocation); missing metadata degrades silently to the
/// old unchecked behaviour.
fn emit_bounds_check(&mut self, var: VarId) {
if !self.debug_mode {
return;
}
let Some(&size) = self.var_sizes.get(&var) else {
return;
};
if size == 0 {
return;
}
// Anything >= 256 would overflow the u8 immediate; skip
// the check rather than emit a bogus compare. A proper
// 16-bit bounds check would need a two-byte compare
// against the high byte too.
let Ok(size_u8) = u8::try_from(size) else {
return;
};
// Use a short BCC over an unconditional JMP instead of a
// plain `BCS __debug_halt`. A single BCS can only span 127
// bytes, and `__debug_halt` is emitted at the very end of
// the fixed bank — many check sites are far enough away
// that the short-branch fixup would panic at link time.
// BCC-over-JMP keeps the hot path at two branches (well
// under 8 cycles) and the failure path at a 3-byte JMP.
let skip_label = format!("__ir_bc_ok_{}", self.instructions.len());
self.emit(CMP, AM::Immediate(size_u8));
self.emit(BCC, AM::LabelRelative(skip_label.clone()));
self.emit(JMP, AM::Label("__debug_halt".to_string()));
self.emit_label(&skip_label);
self.bounds_halt_used = true;
}
/// Emit a runtime-variable shift loop: loads `src` into A, then
/// `amt` iterations of `shift_op` (`ASL` / `LSR`), storing into
/// `dest`. An iteration count of zero is handled by a leading
@ -336,12 +445,23 @@ impl<'a> IrCodeGen<'a> {
/// 3. Main dispatch loop (wait vblank, then `JMP` to state's frame handler)
/// 4. State frame handlers (each ends with `JMP` to main loop)
/// 5. User function bodies (end with `RTS`)
pub fn generate(mut self, ir: &IrProgram) -> Vec<Instruction> {
pub fn generate(&mut self, ir: &IrProgram) -> Vec<Instruction> {
// Populate state indices
for (i, name) in ir.states.iter().enumerate() {
self.state_indices.insert(name.clone(), i as u8);
}
// Emit a `__debug_mode` marker label whenever debug
// codegen is on. The linker looks for this label to decide
// whether to splice the debug variant of the NMI handler
// (which adds a frame-overrun counter). The label itself
// emits zero bytes — it's just a tripwire the linker can
// check by name, mirroring the `__audio_used` /
// `__ppu_update_used` marker pattern already in use.
if self.debug_mode {
self.emit_label("__debug_mode");
}
// 1. Variable initializers
//
// Scalars write a single byte from `init_value`. Array
@ -488,7 +608,25 @@ impl<'a> IrCodeGen<'a> {
self.gen_scanline_reload(&scanline_groups);
}
self.instructions
// Debug-mode halt routine for failed array bounds checks.
// Every `emit_bounds_check` that ran writes a
// `BCS __debug_halt` which lands here on out-of-range
// indices. The routine is just `JMP __debug_halt` — an
// infinite loop that the debugger sees as a deterministic
// wedge on the offending address. Release builds never set
// `bounds_halt_used`, so this whole block compiles to zero
// bytes under `cargo run --release -- build`.
if self.bounds_halt_used {
self.emit_label("__debug_halt");
// Write a recognizable sentinel to the emulator debug
// port before wedging, so the log shows a bounds-check
// failure as a distinct event from a plain halt.
self.emit(LDA, AM::Immediate(0xBC));
self.emit(STA, AM::Absolute(DEBUG_PORT));
self.emit(JMP, AM::Label("__debug_halt".to_string()));
}
std::mem::take(&mut self.instructions)
}
fn gen_function(&mut self, func: &IrFunction) {
@ -694,6 +832,7 @@ impl<'a> IrCodeGen<'a> {
IrOp::ArrayLoad(dest, var, idx) => {
if let Some(&base_addr) = self.var_addrs.get(var) {
self.load_temp(*idx);
self.emit_bounds_check(*var);
self.emit(TAX, AM::Implied);
if base_addr < 0x100 {
self.emit(LDA, AM::ZeroPageX(base_addr as u8));
@ -706,6 +845,7 @@ impl<'a> IrCodeGen<'a> {
IrOp::ArrayStore(var, idx, val) => {
if let Some(&base_addr) = self.var_addrs.get(var) {
self.load_temp(*idx);
self.emit_bounds_check(*var);
self.emit(TAX, AM::Implied);
self.load_temp(*val);
if base_addr < 0x100 {
@ -1012,8 +1152,20 @@ impl<'a> IrCodeGen<'a> {
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::GtEq),
IrOp::SourceLoc(_) => {
// No code for source location markers
IrOp::SourceLoc(span) => {
// Emit a uniquely-named label-definition pseudo-op
// at the current codegen position — but only when
// source-map emission is enabled. Labels introduce
// peephole block boundaries, so unconditionally
// emitting them would shift release-mode ROM bytes
// (and break the golden-diff contract). Off by
// default; the CLI flips it on under `--source-map`.
if self.emit_source_locs {
let name = format!("__src_{}", self.next_source_loc);
self.next_source_loc += 1;
self.emit_label(&name);
self.source_locs.push((name, *span));
}
}
}
}
@ -2622,4 +2774,147 @@ mod more_tests {
"u8 += should emit exactly one ADC; got {adc_count}"
);
}
#[test]
fn ir_codegen_debug_mode_emits_marker_label() {
// The codegen drops a `__debug_mode` label whenever debug
// mode is on. The linker reads that label to decide
// whether to splice the frame-overrun-aware NMI handler,
// so the marker is load-bearing even though it carries no
// bytes itself.
let insts = lower_and_gen_debug(
r#"
game "T" { mapper: NROM }
on frame { wait_frame }
start Main
"#,
);
let has_marker = insts
.iter()
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__debug_mode"));
assert!(has_marker, "debug mode should emit __debug_mode marker");
}
#[test]
fn ir_codegen_release_mode_has_no_debug_marker() {
let insts = lower_and_gen(
r#"
game "T" { mapper: NROM }
on frame { wait_frame }
start Main
"#,
);
let has_marker = insts
.iter()
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__debug_mode"));
assert!(
!has_marker,
"release mode must not emit __debug_mode; doing so would force the debug NMI"
);
}
#[test]
fn ir_codegen_bounds_check_in_debug_mode_emits_halt_jump() {
// Debug-mode array access should emit a CMP + BCC + JMP
// __debug_halt guard, and the codegen should define
// `__debug_halt` as a terminal infinite loop. We only
// check for the presence of the halt label and a JMP
// targeting it; the actual CMP comes with an immediate
// whose value depends on the array length. Verified for
// `xs[i]` on a `u8[4]` array → the immediate should be 4.
let insts = lower_and_gen_debug(
r#"
game "T" { mapper: NROM }
var xs: u8[4] = [10, 20, 30, 40]
on frame {
var i: u8 = 2
var v: u8 = xs[i]
wait_frame
}
start Main
"#,
);
// Label defined at the halt site.
let has_halt_label = insts
.iter()
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__debug_halt") && i.opcode == NOP);
assert!(has_halt_label, "debug mode should emit __debug_halt label");
// JMP __debug_halt from the bounds-check fail path.
let has_jmp_halt = insts
.iter()
.any(|i| i.opcode == JMP && matches!(&i.mode, AM::Label(l) if l == "__debug_halt"));
assert!(
has_jmp_halt,
"debug-mode bounds check should JMP to __debug_halt on failure"
);
// The CMP #4 compares against the array length.
let has_cmp_four = insts
.iter()
.any(|i| i.opcode == CMP && i.mode == AM::Immediate(4));
assert!(
has_cmp_four,
"bounds check against a `u8[4]` array should CMP against 4"
);
}
#[test]
fn ir_codegen_bounds_check_stripped_in_release() {
let insts = lower_and_gen(
r#"
game "T" { mapper: NROM }
var xs: u8[4] = [10, 20, 30, 40]
on frame {
var i: u8 = 2
var v: u8 = xs[i]
wait_frame
}
start Main
"#,
);
let has_halt_label = insts
.iter()
.any(|i| matches!(&i.mode, AM::Label(l) if l == "__debug_halt"));
assert!(
!has_halt_label,
"release builds must not emit the bounds-check halt routine"
);
}
#[test]
fn ir_codegen_source_map_opt_in_emits_src_labels() {
// With `with_source_map(true)` the codegen should emit
// a `__src_<N>` label and record the span for each
// lowered statement. Without the opt-in, release-mode
// ROMs must stay byte-identical (no `__src_` labels).
let (prog, _) = parser::parse(
r#"
game "T" { mapper: NROM }
on frame { wait_frame }
start Main
"#,
);
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let ir_program = ir::lower(&prog, &analysis);
let mut codegen =
IrCodeGen::new(&analysis.var_allocations, &ir_program).with_source_map(true);
let insts = codegen.generate(&ir_program);
let src_labels: Vec<_> = insts
.iter()
.filter_map(|i| match &i.mode {
AM::Label(l) if l.starts_with("__src_") && i.opcode == NOP => Some(l.clone()),
_ => None,
})
.collect();
assert!(
!src_labels.is_empty(),
"source-map-enabled codegen should emit at least one __src_ label"
);
let recorded = codegen.source_locs();
assert_eq!(
src_labels.len(),
recorded.len(),
"every emitted __src_ label should have a matching source_locs entry"
);
}
}

View file

@ -388,6 +388,14 @@ impl LoweringContext {
}
fn lower_statement(&mut self, stmt: &Statement) {
// Emit a source-location marker before every statement we
// lower. The codegen turns these into label-definition
// pseudo-ops (`__src_<file>_<byte>_<line>_<col>`), which
// the linker then reports back to the CLI so it can emit a
// source map. Release builds don't need the map, but we
// still leave the markers in — they lower to zero bytes in
// codegen, so there's no ROM cost.
self.emit(IrOp::SourceLoc(stmt.span()));
match stmt {
Statement::VarDecl(var) => {
let var_id = self.get_or_create_var(&var.name);

284
src/linker/debug_symbols.rs Normal file
View file

@ -0,0 +1,284 @@
//! Debug-symbol file writers.
//!
//! Produces Mesen-compatible `.mlb` label listings and plain-text
//! source maps from a [`LinkedRom`]. These helpers are owned by the
//! linker because they're the only place in the compiler that can
//! observe the final CPU address of each label — and the CPU-to-ROM
//! offset math needs the `fixed_bank_file_offset` the linker hands
//! back.
//!
//! Callers: `src/main.rs` invokes [`render_mlb`] when the user
//! passes `--symbols <path>` and [`render_source_map`] when the
//! user passes `--source-map <path>`. The functions themselves are
//! pure string producers so that unit tests can round-trip them
//! without touching the filesystem.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use super::LinkedRom;
use crate::analyzer::VarAllocation;
use crate::lexer::Span;
/// Render a Mesen-compatible `.mlb` symbol file from a
/// [`LinkedRom`].
///
/// Each line has the form `<type>:<hex-address>:<label>`. The type
/// byte follows Mesen's convention: `P` for PRG ROM offsets and
/// `R` for RAM (zero page and internal RAM share this namespace
/// because zero page is just the low 256 bytes of RAM on the NES).
///
/// Function and state-handler labels are emitted as `P` entries
/// with the address converted from a CPU address in `$C000-$FFFF`
/// into a PRG-relative ROM offset via
/// `linked.fixed_bank_file_offset`. User variables come from the
/// analyzer's `var_allocations` list and are emitted as `R`
/// entries at their assigned RAM addresses.
///
/// Internal labels (anything that doesn't look like a user
/// function or a well-known entry point) are skipped so the
/// resulting file focuses on the symbols a debugger user actually
/// cares about. Output is sorted deterministically so the file is
/// diff-friendly and so tests can assert against exact strings.
#[must_use]
pub fn render_mlb(linked: &LinkedRom, var_allocations: &[VarAllocation]) -> String {
let mut out = String::new();
let sorted: BTreeMap<&String, &u16> = linked.labels.iter().collect();
let base_cpu_addr: u16 = 0xC000;
for (label, &&cpu_addr) in &sorted {
// Only translate labels that sit inside the fixed bank's
// CPU window. In practice every label in `linked.labels`
// already lives here (the assembler works on a single
// bank at a time), but we guard anyway so a future
// multi-bank label dump doesn't silently emit garbage
// offsets.
if cpu_addr < base_cpu_addr {
continue;
}
let Some(display_name) = mlb_symbol_name(label) else {
continue;
};
let rom_offset = linked.fixed_bank_file_offset + (cpu_addr - base_cpu_addr) as usize;
// Mesen uses ROM file offsets *relative to the start of
// the PRG region* (i.e. subtract the 16-byte header).
// This keeps `.mlb` files portable between NES 2.0 and
// iNES 1 headers.
let prg_offset = rom_offset.saturating_sub(16);
let _ = writeln!(out, "P:{prg_offset:04X}:{display_name}");
}
// Variables — emit in address order so the file is easy to
// eyeball and diff. Duplicate names (e.g. struct fields under
// two synthetic entries) are rare; when they do occur we keep
// the first encounter.
let mut vars: Vec<&VarAllocation> = var_allocations.iter().collect();
vars.sort_by_key(|a| a.address);
for var in vars {
let _ = writeln!(out, "R:{:04X}:{}", var.address, var.name);
}
out
}
/// Determine whether a label should appear in the `.mlb` symbol
/// table, and if so under what name. Returns `None` for labels
/// that are internal bookkeeping (branch/skip stubs, temporary
/// jump targets) and wouldn't help a user navigate the ROM.
fn mlb_symbol_name(label: &str) -> Option<String> {
// Function/state entry labels — strip the `__ir_fn_` prefix so
// Mesen displays the user-facing name. `__ir_fn_Main_frame`
// becomes `Main_frame`, for example.
if let Some(rest) = label.strip_prefix("__ir_fn_") {
return Some(rest.to_string());
}
// Bank trampolines and the main loop entry are useful entry
// points for a reverse-engineering session.
if label == "__ir_main_loop" {
return Some("main_loop".to_string());
}
if let Some(rest) = label.strip_prefix("__tramp_") {
return Some(format!("tramp_{rest}"));
}
// Hardware entry points. These are always present and
// useful.
if matches!(label, "__reset" | "__nmi" | "__irq" | "__irq_user") {
return Some(label.trim_start_matches('_').to_string());
}
// Source-map markers are written to their own file via
// `render_source_map`, not here.
if label.starts_with("__src_") {
return None;
}
// Everything else — per-block labels, fallthrough skips,
// compiler-private helpers — gets filtered out to keep the
// symbol file short and navigable.
None
}
/// Render a plain-text source map from a [`LinkedRom`].
///
/// Each line has the form `<rom_offset_hex> <file_id> <line> <col>`.
/// Entries come from the `__src_<N>` marker labels the IR
/// codegen emits for every [`crate::ir::IrOp::SourceLoc`] — one
/// per lowered source statement — paired against their original
/// spans via the codegen's `source_locs` side table passed in as
/// `source_locs`.
///
/// `source` is the preprocessed source text. We translate each
/// span's byte offset into a `(line, col)` pair by scanning
/// through the source once. Files included via `include` share
/// the same file id in the preprocessed text, so a single scan
/// covers every entry.
///
/// The output is sorted by ROM offset so the file is diff-
/// friendly and so downstream tools can binary-search by address.
#[must_use]
pub fn render_source_map(
linked: &LinkedRom,
source_locs: &[(String, Span)],
source: &str,
) -> String {
let mut entries: Vec<(usize, u16, u32, u32)> = Vec::new();
let base_cpu_addr: u16 = 0xC000;
for (label, span) in source_locs {
let Some(&cpu_addr) = linked.labels.get(label) else {
continue;
};
if cpu_addr < base_cpu_addr {
continue;
}
let rom_offset = linked.fixed_bank_file_offset + (cpu_addr - base_cpu_addr) as usize;
let prg_offset = rom_offset.saturating_sub(16);
let (line, col) = byte_offset_to_line_col(source, span.start as usize);
entries.push((prg_offset, span.file_id, line, col));
}
entries.sort_unstable();
let mut out = String::new();
for (offset, file_id, line, col) in entries {
let _ = writeln!(out, "{offset:04X} {file_id} {line} {col}");
}
out
}
/// Convert a byte offset into `source` into a 1-based
/// `(line, column)` pair. Used by the source-map emitter to
/// translate the compact byte-offset spans carried inside
/// [`crate::lexer::Span`] into human-readable positions. Offsets
/// past the end of the source clamp to the last line.
fn byte_offset_to_line_col(source: &str, offset: usize) -> (u32, u32) {
let bytes = source.as_bytes();
let limit = offset.min(bytes.len());
let mut line: u32 = 1;
let mut col: u32 = 1;
for &b in &bytes[..limit] {
if b == b'\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_linked(labels: &[(&str, u16)]) -> LinkedRom {
let mut map = HashMap::new();
for (name, addr) in labels {
map.insert((*name).to_string(), *addr);
}
LinkedRom {
rom: Vec::new(),
labels: map,
fixed_bank_file_offset: 16,
}
}
#[test]
fn mlb_skips_internal_labels_and_keeps_entry_points() {
let linked = make_linked(&[
("__reset", 0xC010),
("__nmi", 0xC020),
("__ir_fn_Main_frame", 0xC100),
("__ir_fn_helper", 0xC200),
("__ir_skip_17", 0xC108), // internal, should not appear
("__ir_main_loop", 0xC080),
]);
let vars = vec![
VarAllocation {
name: "score".into(),
address: 0x0010,
size: 1,
},
VarAllocation {
name: "enemies".into(),
address: 0x0300,
size: 4,
},
];
let out = render_mlb(&linked, &vars);
assert!(out.contains("Main_frame"), "should strip __ir_fn_ prefix");
assert!(out.contains("helper"));
assert!(out.contains("main_loop"));
assert!(out.contains("reset"));
assert!(out.contains("nmi"));
assert!(
!out.contains("__ir_skip_17"),
"internal skip labels should not leak into the .mlb file"
);
// Var entries use the `R:` prefix and the raw RAM address.
assert!(out.contains("R:0010:score"));
assert!(out.contains("R:0300:enemies"));
}
#[test]
fn mlb_uses_prg_relative_offsets() {
// A label at CPU $C010 should land at PRG offset 0x0010
// — the fixed bank's first byte sits at ROM file offset
// 16 (post-header) and the .mlb format strips that
// header back off.
let linked = make_linked(&[("__ir_fn_foo", 0xC010)]);
let out = render_mlb(&linked, &[]);
assert!(
out.contains("P:0010:foo"),
"PRG-relative offset should be 0x0010, got:\n{out}"
);
}
#[test]
fn source_map_resolves_spans_to_line_and_column() {
let source = "line one\nsecond line\nthird\n";
// Byte offset 9 is the start of "second", which is line
// 2 column 1.
let span = Span::new(0, 9, 15);
let linked = make_linked(&[("__src_0", 0xC000)]);
let out = render_source_map(&linked, &[("__src_0".to_string(), span)], source);
// PRG offset 0 (fixed_bank_file_offset=16, then subtract
// 16 for PRG-relative) file_id 0 line 2 col 1.
assert_eq!(out.trim(), "0000 0 2 1", "got:\n{out}");
}
#[test]
fn source_map_output_is_sorted_by_offset() {
let source = "a\nb\nc\n";
let linked = make_linked(&[("__src_0", 0xC020), ("__src_1", 0xC010)]);
let out = render_source_map(
&linked,
&[
("__src_0".to_string(), Span::new(0, 0, 1)),
("__src_1".to_string(), Span::new(0, 2, 3)),
],
source,
);
let lines: Vec<_> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with("0010"));
assert!(lines[1].starts_with("0020"));
}
}

View file

@ -1,6 +1,11 @@
mod debug_symbols;
#[cfg(test)]
mod tests;
pub use debug_symbols::{render_mlb, render_source_map};
use std::collections::HashMap;
use crate::asm;
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
use crate::assets::{BackgroundData, MusicData, PaletteData, SfxData};
@ -8,6 +13,31 @@ use crate::parser::ast::{HeaderFormat, Mapper, Mirroring};
use crate::rom::RomBuilder;
use crate::runtime;
/// Detailed result of a link pass. In addition to the final iNES
/// ROM bytes this carries the assembler's symbol table — each label
/// defined anywhere in the assembled fixed bank mapped to its CPU
/// address — and the byte offset at which the fixed bank starts
/// inside the PRG ROM region of the file.
///
/// The CLI uses this metadata to emit Mesen-compatible `.mlb`
/// symbol files and source-to-ROM maps (via the `--symbols` /
/// `--source-map` flags). Callers that only care about the ROM
/// bytes can read `.rom` and discard the rest.
#[derive(Debug, Clone)]
pub struct LinkedRom {
/// Final iNES ROM bytes (header + PRG banks + CHR).
pub rom: Vec<u8>,
/// Every label defined in the fixed bank, mapped to its CPU
/// address in the $C000-$FFFF window. Populated by the
/// 6502 assembler's label pass.
pub labels: HashMap<String, u16>,
/// Byte offset of the fixed bank's first byte inside `rom`.
/// For NROM this is `16` (just past the 16-byte iNES header).
/// For banked mappers each switchable bank shifts it by 16 KB,
/// so the fixed bank starts at `16 + 16_384 * switchable_bank_count`.
pub fixed_bank_file_offset: usize,
}
/// Link compiled code into a complete NES ROM.
pub struct Linker {
mirroring: Mirroring,
@ -213,6 +243,34 @@ impl Linker {
backgrounds: &[BackgroundData],
switchable_banks: &[PrgBank],
) -> Vec<u8> {
self.link_banked_with_ppu_detailed(
user_code,
sprites,
sfx,
music,
palettes,
backgrounds,
switchable_banks,
)
.rom
}
/// Like [`Linker::link_banked_with_ppu`] but returns the full
/// [`LinkedRom`] record, carrying the assembler label table and
/// the PRG offset of the fixed bank alongside the ROM bytes.
/// This is the entry point used by the CLI when emitting a
/// `.mlb` symbol file or a source-map file.
#[allow(clippy::too_many_arguments)]
pub fn link_banked_with_ppu_detailed(
&self,
user_code: &[Instruction],
sprites: &[SpriteData],
sfx: &[SfxData],
music: &[MusicData],
palettes: &[PaletteData],
backgrounds: &[BackgroundData],
switchable_banks: &[PrgBank],
) -> LinkedRom {
assert!(
switchable_banks.is_empty() || self.mapper != Mapper::NROM,
"NROM does not support switchable PRG banks (got {} banks)",
@ -239,7 +297,7 @@ impl Linker {
palettes: &[PaletteData],
backgrounds: &[BackgroundData],
switchable_banks: &[PrgBank],
) -> Vec<u8> {
) -> LinkedRom {
// ROM layout.
//
// NROM: a single 16 KB PRG bank mapped at $C000-$FFFF.
@ -429,7 +487,11 @@ impl Linker {
// The audio tick JSR is emitted by `gen_nmi` itself, after
// the register and scratch-slot saves, so it can freely
// clobber A/X/Y and $02/$03 without corrupting user state.
all_instructions.extend(runtime::gen_nmi(has_ppu_updates, has_audio));
// The codegen emits a `__debug_mode` marker whenever
// `--debug` is active; that tells the runtime to splice
// in the extra frame-overrun check at the top of NMI.
let debug_mode = has_label(user_code, "__debug_mode");
all_instructions.extend(runtime::gen_nmi(has_ppu_updates, has_audio, debug_mode));
// IRQ handler
all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into())));
@ -508,7 +570,17 @@ impl Linker {
}
builder.set_chr(chr);
builder.build()
let rom = builder.build();
// The fixed bank sits after the iNES header (16 bytes) and
// any switchable banks (16 KB each). Callers use this to
// translate CPU addresses from `labels` into ROM file
// offsets when emitting Mesen `.mlb` files.
let fixed_bank_file_offset = 16 + switchable_banks.len() * 16_384;
LinkedRom {
rom,
labels: result.labels,
fixed_bank_file_offset,
}
}
/// Generate instructions to load the default palette into the PPU.

View file

@ -590,3 +590,38 @@ fn palette_load_writes_to_ppu() {
"should write all 32 palette bytes"
);
}
#[test]
fn link_banked_with_ppu_detailed_exposes_label_table() {
// The detailed variant carries the assembler's symbol table so
// the CLI can emit a `.mlb` file. Round-trip a minimal program
// through the linker and verify the classic runtime labels
// (`__reset`, `__nmi`, `__ir_main_loop`) show up with CPU
// addresses in the $C000-$FFFF fixed-bank window.
let lnk = Linker::new(Mirroring::Horizontal);
let user_code = vec![
Instruction::new(NOP, AM::Label("__ir_main_loop".into())),
Instruction::new(JMP, AM::Label("__ir_main_loop".into())),
];
let result = lnk.link_banked_with_ppu_detailed(&user_code, &[], &[], &[], &[], &[], &[]);
assert!(
result.labels.contains_key("__reset"),
"LinkedRom should surface the reset label"
);
assert!(
result.labels.contains_key("__nmi"),
"LinkedRom should surface the nmi label"
);
assert!(
result.labels.contains_key("__ir_main_loop"),
"LinkedRom should surface user-code labels"
);
let main_addr = result.labels["__ir_main_loop"];
assert!(
(0xC000..=0xFFFF).contains(&main_addr),
"fixed-bank label should sit inside the $C000-$FFFF window, got {main_addr:#06X}"
);
// NROM has no switchable banks, so the fixed bank starts right
// after the 16-byte iNES header.
assert_eq!(result.fixed_bank_file_offset, 16);
}

View file

@ -6,7 +6,7 @@ use nescript::assets;
use nescript::codegen::IrCodeGen;
use nescript::errors::render_diagnostics;
use nescript::ir;
use nescript::linker::{Linker, PrgBank};
use nescript::linker::{render_mlb, render_source_map, Linker, PrgBank};
use nescript::optimizer;
use nescript::parser::ast::BankType;
@ -49,6 +49,22 @@ enum Cli {
/// lives in `src/optimizer/`.
#[arg(long)]
no_opt: bool,
/// Write a Mesen-compatible symbol file (`.mlb`) next to the
/// ROM. Contains one `<type>:<address>:<label>` entry per
/// function, state handler, and user variable. Enables
/// symbol-level debugging in Mesen / fceux without manual
/// address lookups.
#[arg(long, value_name = "PATH")]
symbols: Option<PathBuf>,
/// Write a plain-text source map (`.map`) next to the ROM.
/// Each line has the form `<rom_offset_hex> <file_id>
/// <line> <col>` and records the position of every IR-level
/// statement in the assembled fixed bank. Useful for
/// reverse-mapping a crash address back to the source.
#[arg(long, value_name = "PATH")]
source_map: Option<PathBuf>,
},
/// Type-check a source file without building
Check {
@ -70,6 +86,8 @@ fn main() {
memory_map,
call_graph,
no_opt,
symbols,
source_map,
} => {
let output = output.unwrap_or_else(|| input.with_extension("nes"));
match compile(
@ -81,6 +99,8 @@ fn main() {
memory_map,
call_graph,
no_opt,
symbols: symbols.clone(),
source_map: source_map.clone(),
},
) {
Ok(rom) => {
@ -230,6 +250,8 @@ struct CompileOptions {
memory_map: bool,
call_graph: bool,
no_opt: bool,
symbols: Option<PathBuf>,
source_map: Option<PathBuf>,
}
fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
@ -239,6 +261,8 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
let memory_map = opts.memory_map;
let call_graph = opts.call_graph;
let no_opt = opts.no_opt;
let symbols_path = opts.symbols.as_ref();
let source_map_path = opts.source_map.as_ref();
let raw_source = std::fs::read_to_string(input).map_err(|e| {
eprintln!("error: failed to read {}: {e}", input.display());
})?;
@ -321,11 +345,23 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
let backgrounds = assets::resolve_backgrounds(&program);
// IR-based code generation. Lower → optimize → emit 6502.
let mut instructions = IrCodeGen::new(&analysis.var_allocations, &ir_program)
//
// We hold on to the codegen after `generate()` because it
// carries the source-location marker list — one entry per
// `SourceLoc` IR op — which the CLI reads to emit a source
// map. Dropping the codegen before then would throw that
// metadata away. Source-marker emission is opt-in (the label
// pseudo-ops shift peephole block boundaries, which would
// flip release-mode ROM bytes if it was always on) — so we
// only enable it when the user actually asked for a source
// map on the command line.
let emit_source_map = source_map_path.is_some();
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
.with_sprites(&sprites)
.with_audio(&sfx, &music)
.with_debug(debug)
.generate(&ir_program);
.with_source_map(emit_source_map);
let mut instructions = codegen.generate(&ir_program);
// Peephole pass: cleans up the IR codegen's temp-heavy output —
// dead stores, redundant loads, short-branch folds, etc.
@ -357,7 +393,7 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
.filter(|b| b.bank_type == BankType::Prg)
.map(|b| PrgBank::empty(&b.name))
.collect();
let rom = linker.link_banked_with_ppu(
let link_result = linker.link_banked_with_ppu_detailed(
&instructions,
&sprites,
&sfx,
@ -367,7 +403,20 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
&switchable_banks,
);
Ok(rom)
if let Some(path) = symbols_path {
let mlb = render_mlb(&link_result, &analysis.var_allocations);
std::fs::write(path, mlb).map_err(|e| {
eprintln!("error: failed to write symbol file {}: {e}", path.display());
})?;
}
if let Some(path) = source_map_path {
let map = render_source_map(&link_result, codegen.source_locs(), &source);
std::fs::write(path, map).map_err(|e| {
eprintln!("error: failed to write source map {}: {e}", path.display());
})?;
}
Ok(link_result.rom)
}
fn check(input: &PathBuf) -> Result<(), ()> {

View file

@ -80,6 +80,22 @@ pub const ZP_PENDING_BG_TILES_HI: u8 = 0x15;
pub const ZP_PENDING_BG_ATTRS_LO: u8 = 0x16;
pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17;
// ── Debug instrumentation ──
//
// These slots are only touched by debug-mode ROMs. In release
// builds the analyzer is free to allocate over them.
/// Debug-mode frame-overrun counter. Incremented by the NMI
/// 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).
pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF;
/// Generate the NES hardware initialization sequence.
/// This runs at RESET and sets up the hardware before user code.
pub fn gen_init() -> Vec<Instruction> {
@ -209,8 +225,17 @@ pub fn gen_enable_rendering(show_background: bool) -> Vec<Instruction> {
/// save/restore window used to silently clobber `ZP_CURRENT_STATE`
/// whenever a music note was played (the tick's period-table
/// lookup stashes the table's high byte into $03).
///
/// `debug_mode` enables frame-overrun detection: before touching
/// the frame-ready flag, the handler checks whether it's already
/// set — if it is, the previous frame's main-loop work never
/// finished (i.e. the program ran over its vblank budget) and
/// the handler bumps the counter at
/// [`DEBUG_FRAME_OVERRUN_ADDR`]. Release-mode ROMs never call
/// this with `debug_mode=true`, so the counter slot stays free
/// for user allocation.
#[must_use]
pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool) -> Vec<Instruction> {
pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec<Instruction> {
let mut out = Vec::new();
// Save registers
@ -275,6 +300,31 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool) -> Vec<Instruction> {
AM::LabelRelative("__read_input".into()),
));
// Debug frame-overrun check. The frame flag is "set on NMI,
// cleared by wait_frame". If we see it set at the top of a
// new NMI, the main loop never reached its wait_frame since
// the previous vblank — i.e. the frame overran. Bump a
// counter at `DEBUG_FRAME_OVERRUN_ADDR` in that case so user
// code can `peek(0x07FF)` to see how many overruns have
// happened. The check is gated on `debug_mode` so release
// builds emit nothing here.
if debug_mode {
// Read the previous flag. If zero, skip the bump.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_FRAME_FLAG)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__debug_no_overrun".into()),
));
out.push(Instruction::new(
INC,
AM::Absolute(DEBUG_FRAME_OVERRUN_ADDR),
));
out.push(Instruction::new(
NOP,
AM::Label("__debug_no_overrun".into()),
));
}
// Set frame-ready flag
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_FRAME_FLAG)));

View file

@ -70,7 +70,7 @@ fn init_assembles_without_error() {
#[test]
fn nmi_saves_and_restores_registers() {
let nmi = gen_nmi(false, false);
let nmi = gen_nmi(false, false, false);
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
@ -86,7 +86,7 @@ fn nmi_saves_and_restores_registers() {
#[test]
fn nmi_triggers_oam_dma() {
let nmi = gen_nmi(false, false);
let nmi = gen_nmi(false, false, false);
let has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
@ -95,7 +95,7 @@ fn nmi_triggers_oam_dma() {
#[test]
fn nmi_reads_controller() {
let nmi = gen_nmi(false, false);
let nmi = gen_nmi(false, false, false);
// Should write strobe to $4016
let has_strobe = nmi
.iter()
@ -105,7 +105,7 @@ fn nmi_reads_controller() {
#[test]
fn nmi_sets_frame_flag() {
let nmi = gen_nmi(false, false);
let nmi = gen_nmi(false, false, false);
let has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
@ -114,7 +114,7 @@ fn nmi_sets_frame_flag() {
#[test]
fn nmi_assembles_without_error() {
let nmi = gen_nmi(false, false);
let nmi = gen_nmi(false, false, false);
let result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(
@ -124,6 +124,33 @@ fn nmi_assembles_without_error() {
);
}
#[test]
fn nmi_debug_mode_bumps_overrun_counter() {
// With `debug_mode = true`, the NMI handler must include an
// `INC $07FF` (the frame-overrun counter at
// `DEBUG_FRAME_OVERRUN_ADDR`) guarded by a BEQ that skips the
// bump when the frame flag was clear. Without `debug_mode`,
// neither the `INC` nor the guard label appear so release
// builds keep the top byte of RAM free for user allocation.
let nmi = gen_nmi(false, false, true);
let has_inc = nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
has_inc,
"debug-mode NMI should INC the overrun counter at $07FF"
);
let release_nmi = gen_nmi(false, false, false);
let has_inc_release = release_nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
!has_inc_release,
"release NMI must not touch the debug overrun slot"
);
}
#[test]
fn irq_handler_is_just_rti() {
let irq = gen_irq();