diff --git a/docs/architecture.md b/docs/architecture.md index 583b5a2..7e84d94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ Each module has a `mod.rs` (implementation) and a co-located `tests.rs` with uni `mod.rs`, `opcodes.rs`, `inline_parser.rs`, `tests.rs`. The built-in assembler and the inline-asm parser. `opcodes.rs` defines the 6502 opcode table with addressing modes. `inline_parser.rs` parses the body of `asm { ... }` blocks so codegen can splice real instructions in-line. ### `linker/` -`mod.rs`, `tests.rs`. Assigns addresses to code and data segments, resolves label/symbol fixups, lays out banks for banked mappers (MMC1/UxROM/MMC3), and emits the final iNES byte stream via `rom::RomBuilder`. +`mod.rs`, `debug_symbols.rs`, `tests.rs`. Assigns addresses to code and data segments, resolves label/symbol fixups, lays out banks for banked mappers (MMC1/UxROM/MMC3), and emits the final iNES byte stream via `rom::RomBuilder`. `debug_symbols.rs` owns the three debug-info writers — `render_mlb` (Mesen `.mlb`), `render_source_map` (plain-text ROM→source line map), and `render_dbg` (ca65-compatible `.dbg` debug-info file for Mesen source-level debugging). ### `rom/` `mod.rs`, `tests.rs`. Builds the final iNES ROM file. Generates the 16-byte iNES header and places the NMI/RESET/IRQ vector table. diff --git a/docs/future-work.md b/docs/future-work.md index 1774817..f490170 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -148,7 +148,16 @@ writes when `--debug` is passed, and are stripped entirely in release builds. state-handler, and variable addresses (with PRG ROM offsets for code and CPU addresses for RAM). `--source-map ` consumes the `SourceLoc` IR op and writes a plain-text map of ` ` -entries for every lowered statement. Debug builds emit array bounds checks +entries for every lowered statement. **`--dbg ` writes a +ca65-compatible `.dbg` debug-info file** that Mesen / Mesen2 / fceuX pick +up automatically for source-level stepping, labelled variable inspection, +and symbol-based breakpoints. The file stitches together the linker's +label table, the `__src_` IR markers, and the analyzer's variable +allocations into the `file`/`mod`/`seg`/`scope`/`span`/`line`/`sym` +records documented at +. `ooffs` on the segment +record tracks the fixed bank's PRG-relative start, so banked ROMs +(MMC1/UxROM/MMC3) also map cleanly inside the debugger. Debug builds emit array bounds checks (CMP against size, BCC past a `JMP __debug_halt` wedge) and bump an overrun counter at `$07FF` in the NMI handler when the main loop didn't reach `wait_frame` before the next vblank. diff --git a/src/linker/debug_symbols.rs b/src/linker/debug_symbols.rs index 9ecb3c8..0655ea0 100644 --- a/src/linker/debug_symbols.rs +++ b/src/linker/debug_symbols.rs @@ -1,20 +1,22 @@ //! 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. +//! Produces Mesen-compatible `.mlb` label listings, plain-text source +//! maps, and ca65-compatible `.dbg` debug-info files 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 ` and [`render_source_map`] when the -//! user passes `--source-map `. The functions themselves are -//! pure string producers so that unit tests can round-trip them -//! without touching the filesystem. +//! passes `--symbols `, [`render_source_map`] when the user +//! passes `--source-map `, and [`render_dbg`] when the user +//! passes `--dbg `. 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 std::path::Path; use super::LinkedRom; use crate::analyzer::VarAllocation; @@ -183,6 +185,225 @@ fn byte_offset_to_line_col(source: &str, offset: usize) -> (u32, u32) { (line, col) } +/// Render a ca65-compatible `.dbg` debug-info file from a +/// [`LinkedRom`]. +/// +/// The `.dbg` format is the text-based side channel that `ld65` +/// emits alongside assembled binaries; source-level NES debuggers +/// (Mesen, Mesen2, `fceuX`) read it to let the user step by source +/// line, inspect labelled variables, and set breakpoints on +/// function names instead of raw addresses. `NEScript` isn't built +/// on top of ca65, but we synthesize a conformant file from the +/// same metadata the `.mlb` and `--source-map` writers use: the +/// linker's label table, the IR codegen's `__src_` markers, and +/// the analyzer's variable allocations. +/// +/// The emitted file contains: +/// +/// * One `file` record for `input_path` — the preprocessed `.ne` +/// source the compiler parsed. All `SourceLoc` spans have +/// `file_id=0` (the preprocessor concatenates includes into a +/// single buffer, see `parser::preprocess`), so one file record +/// is enough. +/// * One `mod` record naming the build. +/// * One `seg` record covering the fixed bank at `$C000-$FFFF`, +/// with `ooffs` set to the PRG-relative byte offset of the fixed +/// bank's first byte. Mesen uses `ooffs + start - $C000` to map +/// a CPU address inside the segment to a ROM file byte. +/// * One `scope` record for the segment. +/// * One `span` + one `line` record for every `__src_` marker +/// whose label resolved to a CPU address in the fixed bank. Each +/// span's size stretches from its own PRG offset to the next +/// sorted marker's PRG offset (and the last span runs to the end +/// of the fixed bank), so Mesen's source-line breakpoints cover +/// every byte the IR statement compiled into. +/// * One `sym` record per filtered code label (functions, state +/// handlers, hardware entry points, bank trampolines, main loop) +/// and one per user variable allocation. Code symbols carry +/// `seg=0`; variable symbols omit `seg` and use `addrsize=zeropage` +/// for zero-page addresses, `absolute` otherwise. +/// +/// `output_path` is recorded in the segment's `oname=` field so +/// Mesen can verify that the `.dbg` matches the ROM it was built +/// alongside. +#[must_use] +pub fn render_dbg( + linked: &LinkedRom, + source_locs: &[(String, Span)], + var_allocations: &[VarAllocation], + source: &str, + input_path: &Path, + output_path: &Path, +) -> String { + let base_cpu_addr: u16 = 0xC000; + let fixed_bank_size: u32 = 0x4000; + // PRG-relative byte offset of the first byte of the fixed bank. + // `fixed_bank_file_offset` is measured from the iNES file start; + // subtracting the 16-byte header gives the offset Mesen wants. + let ooffs: usize = linked.fixed_bank_file_offset.saturating_sub(16); + + // -------- spans + lines from source_locs -------- + // + // Resolve each `__src_` marker to its PRG offset (relative + // to the start of the fixed bank) and collect with the span it + // came from. Labels without a matching entry in `linked.labels` + // are dropped — they can arise if the peephole pass folded + // the marker away — and so are any addresses outside the + // fixed bank window. + let mut src_entries: Vec<(u32, &Span)> = Vec::new(); + for (label, span) in source_locs { + let Some(&cpu_addr) = linked.labels.get(label) else { + continue; + }; + if cpu_addr < base_cpu_addr { + continue; + } + let bank_offset = u32::from(cpu_addr - base_cpu_addr); + src_entries.push((bank_offset, span)); + } + // Sort by bank offset ascending; on ties, older entries win. + src_entries.sort_by_key(|(off, _)| *off); + src_entries.dedup_by_key(|(off, _)| *off); + + // Compute each span's size as the distance to the next marker. + // The last marker stretches to the end of the fixed bank. Spans + // of width 0 would be invalid, so we clamp to 1. + let mut spans: Vec<(u32, u32)> = Vec::with_capacity(src_entries.len()); + for (i, (off, _span)) in src_entries.iter().enumerate() { + let next_off = src_entries.get(i + 1).map_or(fixed_bank_size, |(n, _)| *n); + let size = next_off.saturating_sub(*off).max(1); + spans.push((*off, size)); + } + + // -------- symbol list -------- + // + // Code labels from `linked.labels`, filtered through the same + // user-friendly renamer the `.mlb` writer uses so internal + // scaffolding (per-block skip labels, source-loc markers) + // stays out of the debugger's symbol browser. + let sorted_labels: BTreeMap<&String, &u16> = linked.labels.iter().collect(); + let mut code_syms: Vec<(String, u16)> = Vec::new(); + for (label, &&cpu_addr) in &sorted_labels { + if cpu_addr < base_cpu_addr { + continue; + } + let Some(display_name) = mlb_symbol_name(label) else { + continue; + }; + code_syms.push((display_name, cpu_addr)); + } + + let mut var_syms: Vec<&VarAllocation> = var_allocations.iter().collect(); + var_syms.sort_by_key(|a| a.address); + + let file_count = 1usize; + let mod_count = 1usize; + let scope_count = 1usize; + let seg_count = 1usize; + let line_count = src_entries.len(); + let span_count = spans.len(); + let sym_count = code_syms.len() + var_syms.len(); + + let mut out = String::new(); + let _ = writeln!(out, "version\tmajor=2,minor=0"); + let _ = writeln!( + out, + "info\tcsym=0,file={file_count},lib=0,line={line_count},mod={mod_count},scope={scope_count},seg={seg_count},span={span_count},sym={sym_count},type=0" + ); + + // --- file --- + let source_bytes = source.len(); + let file_name = escape_string(&input_path.display().to_string()); + let _ = writeln!( + out, + "file\tid=0,name=\"{file_name}\",size={source_bytes},mtime=0x00000000,mod=0" + ); + + // --- mod --- + let mod_name = escape_string( + input_path + .file_stem() + .map_or_else( + || "program".to_string(), + |s| s.to_string_lossy().into_owned(), + ) + .as_str(), + ); + let _ = writeln!(out, "mod\tid=0,name=\"{mod_name}\",file=0"); + + // --- seg --- + let seg_oname = escape_string(&output_path.display().to_string()); + let _ = writeln!( + out, + "seg\tid=0,name=\"CODE\",start=0x{base_cpu_addr:04X},size=0x{fixed_bank_size:04X},addrsize=absolute,type=ro,oname=\"{seg_oname}\",ooffs={ooffs}" + ); + + // --- scope (one default per module, covers the fixed bank) --- + let _ = writeln!( + out, + "scope\tid=0,name=\"\",mod=0,size=0x{fixed_bank_size:04X}" + ); + + // --- spans --- + for (i, (off, size)) in spans.iter().enumerate() { + let _ = writeln!(out, "span\tid={i},seg=0,start={off},size={size},type=0"); + } + + // --- lines --- + for (i, (_, span)) in src_entries.iter().enumerate() { + let (line_num, _col) = byte_offset_to_line_col(source, span.start as usize); + let _ = writeln!(out, "line\tid={i},file=0,line={line_num},span={i}"); + } + + // --- sym --- + let mut sym_id: usize = 0; + for (name, addr) in &code_syms { + let safe_name = escape_string(name); + let _ = writeln!( + out, + "sym\tid={sym_id},name=\"{safe_name}\",addrsize=absolute,size=1,scope=0,def=0,ref=0,val=0x{addr:04X},seg=0,type=lab" + ); + sym_id += 1; + } + for v in &var_syms { + let safe_name = escape_string(&v.name); + // Zero page lives at $0000-$00FF and deserves the + // `zeropage` addrsize so debuggers render accesses with + // one-byte operands. + let addrsize = if v.address < 0x0100 { + "zeropage" + } else { + "absolute" + }; + let _ = writeln!( + out, + "sym\tid={sym_id},name=\"{safe_name}\",addrsize={addrsize},size={size},scope=0,def=0,ref=0,val=0x{addr:04X},type=lab", + addr = v.address, + size = v.size, + ); + sym_id += 1; + } + + out +} + +/// Escape a string for inclusion inside a `.dbg` quoted value. The +/// ca65 format uses C-style escapes, so a literal backslash or +/// double quote needs to be doubled with a backslash. Other ASCII +/// bytes pass through — the file is intentionally plain-ASCII so +/// third-party parsers don't have to handle UTF-8. +fn escape_string(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + _ => out.push(ch), + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -281,4 +502,183 @@ mod tests { assert!(lines[0].starts_with("0010")); assert!(lines[1].starts_with("0020")); } + + #[test] + fn dbg_emits_required_record_skeleton() { + // Minimal case: one source-loc marker at $C010 (bank + // offset 16) and two labels (__reset + one user function). + // We expect one each of version / info / file / mod / + // seg / scope, one span, one line, plus one sym per + // filtered code label and one per variable. + let source = "line one\nline two\n"; + let linked = make_linked(&[ + ("__reset", 0xC000), + ("__ir_fn_Main_frame", 0xC010), + ("__src_0", 0xC010), + ]); + let vars = vec![ + VarAllocation { + name: "score".into(), + address: 0x0010, + size: 1, + }, + VarAllocation { + name: "buf".into(), + address: 0x0300, + size: 4, + }, + ]; + let out = render_dbg( + &linked, + &[("__src_0".to_string(), Span::new(0, 9, 17))], + &vars, + source, + Path::new("demo.ne"), + Path::new("demo.nes"), + ); + assert!( + out.starts_with("version\tmajor=2,minor=0\n"), + "header: {out}" + ); + assert!( + out.contains("info\tcsym=0,file=1,lib=0,line=1,mod=1,scope=1,seg=1,span=1,sym=4,type=0"), + "info record should tally to 1 line, 1 span, 4 syms (__reset + Main_frame + 2 vars); got:\n{out}" + ); + assert!(out.contains("file\tid=0,name=\"demo.ne\"")); + assert!(out.contains("mod\tid=0,name=\"demo\",file=0")); + assert!(out.contains( + "seg\tid=0,name=\"CODE\",start=0xC000,size=0x4000,addrsize=absolute,type=ro,oname=\"demo.nes\",ooffs=0" + )); + assert!(out.contains("scope\tid=0,name=\"\",mod=0,size=0x4000")); + // Single span: bank offset 16, stretches to end of bank + // (0x4000 - 16 = 16368 bytes). + assert!( + out.contains("span\tid=0,seg=0,start=16,size=16368,type=0"), + "span record missing or wrong size in:\n{out}" + ); + // Line record: byte offset 9 in source = line 2. + assert!(out.contains("line\tid=0,file=0,line=2,span=0")); + // Code syms emitted as labels against seg 0. + assert!(out.contains( + "name=\"reset\",addrsize=absolute,size=1,scope=0,def=0,ref=0,val=0xC000,seg=0,type=lab" + )); + assert!(out.contains("name=\"Main_frame\"")); + // Variable syms: zero-page addrsize for $10, absolute for + // $0300. No seg field on var syms. + assert!( + out.contains( + "name=\"score\",addrsize=zeropage,size=1,scope=0,def=0,ref=0,val=0x0010,type=lab" + ), + "zero-page var should use addrsize=zeropage:\n{out}" + ); + assert!( + out.contains( + "name=\"buf\",addrsize=absolute,size=4,scope=0,def=0,ref=0,val=0x0300,type=lab" + ), + "RAM var should use addrsize=absolute:\n{out}" + ); + } + + #[test] + fn dbg_span_sizes_stretch_between_adjacent_markers() { + // Two source-loc markers at bank offsets 0x100 and 0x140: + // the first span should be 0x40 bytes wide (distance to + // the next marker), and the second should stretch to the + // end of the fixed bank (0x4000 - 0x140 = 0x3EC0). + let source = "a\nb\nc\n"; + let linked = make_linked(&[("__src_0", 0xC100), ("__src_1", 0xC140)]); + let out = render_dbg( + &linked, + &[ + ("__src_0".to_string(), Span::new(0, 0, 1)), + ("__src_1".to_string(), Span::new(0, 2, 3)), + ], + &[], + source, + Path::new("a.ne"), + Path::new("a.nes"), + ); + assert!( + out.contains("span\tid=0,seg=0,start=256,size=64,type=0"), + "first span should run to the next marker; got:\n{out}" + ); + assert!( + out.contains("span\tid=1,seg=0,start=320,size=16064,type=0"), + "last span should stretch to end of fixed bank; got:\n{out}" + ); + } + + #[test] + fn dbg_skips_source_loc_without_resolved_address() { + // A `__src_` entry whose label didn't survive to the + // final link (e.g. peephole folded it away) should be + // dropped silently instead of polluting the output with a + // span at offset 0. + let source = "a\n"; + let linked = make_linked(&[("__src_0", 0xC010)]); + let out = render_dbg( + &linked, + &[ + ("__src_0".to_string(), Span::new(0, 0, 1)), + ("__src_missing".to_string(), Span::new(0, 0, 1)), + ], + &[], + source, + Path::new("a.ne"), + Path::new("a.nes"), + ); + assert!( + out.contains("info\tcsym=0,file=1,lib=0,line=1,"), + "only the resolved __src_ marker should count; got:\n{out}" + ); + } + + #[test] + fn dbg_ooffs_reflects_banked_rom_layout() { + // On UxROM/MMC1 the fixed bank sits past the switchable + // banks, so `fixed_bank_file_offset` is post-header + // (16 + 16 KB per switchable bank). The segment record's + // `ooffs` should use the PRG-relative value so Mesen + // locates the fixed bank inside the ROM file correctly. + let mut labels = HashMap::new(); + labels.insert("__reset".to_string(), 0xC000); + let linked = LinkedRom { + rom: Vec::new(), + labels, + fixed_bank_file_offset: 16 + 16_384 * 3, // 3 switchable 16 KB banks + }; + let out = render_dbg( + &linked, + &[], + &[], + "a\n", + Path::new("a.ne"), + Path::new("a.nes"), + ); + assert!( + out.contains(&format!("ooffs={}", 16_384 * 3)), + "banked layout should move ooffs past the switchable banks; got:\n{out}" + ); + } + + #[test] + fn dbg_escapes_quotes_and_backslashes_in_paths() { + // A path with a literal backslash or double quote must be + // escaped in the `name=\"...\"` field; otherwise a + // Windows-style path like `C:\games\demo.ne` would break + // the record delimiter. + let linked = make_linked(&[]); + let out = render_dbg( + &linked, + &[], + &[], + "a\n", + Path::new("demo\"tricky\\path.ne"), + Path::new("demo.nes"), + ); + assert!( + out.contains("name=\"demo\\\"tricky\\\\path.ne\""), + "quotes + backslashes should be escaped in file record; got:\n{out}" + ); + } } diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 5c9bbe2..0be67d0 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -2,7 +2,7 @@ mod debug_symbols; #[cfg(test)] mod tests; -pub use debug_symbols::{render_mlb, render_source_map}; +pub use debug_symbols::{render_dbg, render_mlb, render_source_map}; use std::collections::HashMap; diff --git a/src/main.rs b/src/main.rs index 9235db6..962d429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use nescript::analyzer; use nescript::assets::{BackgroundData, PaletteData}; use nescript::errors::render_diagnostics; -use nescript::linker::{render_mlb, render_source_map, LinkedRom}; +use nescript::linker::{render_dbg, render_mlb, render_source_map, LinkedRom}; use nescript::pipeline::{compile_source, CompileError, CompileOptions as PipelineOptions}; #[derive(Parser)] @@ -63,6 +63,16 @@ enum Cli { /// reverse-mapping a crash address back to the source. #[arg(long, value_name = "PATH")] source_map: Option, + + /// Write a ca65-compatible debug-info file (`.dbg`) next + /// to the ROM. The format is the same one `ld65` emits, + /// so Mesen / Mesen2 / fceuX pick it up automatically and + /// enable source-level stepping, labelled variable + /// inspection, and symbol-based breakpoints. Implies + /// `--source-map`-style `__src_` marker emission so + /// line records have something to point at. + #[arg(long, value_name = "PATH")] + dbg: Option, }, /// Type-check a source file without building Check { @@ -86,10 +96,12 @@ fn main() { no_opt, symbols, source_map, + dbg, } => { let output = output.unwrap_or_else(|| input.with_extension("nes")); match compile( &input, + &output, &CompileOptions { debug, asm_dump, @@ -99,6 +111,7 @@ fn main() { no_opt, symbols: symbols.clone(), source_map: source_map.clone(), + dbg: dbg.clone(), }, ) { Ok(rom) => { @@ -334,9 +347,10 @@ struct CompileOptions { no_opt: bool, symbols: Option, source_map: Option, + dbg: Option, } -fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { +fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result, ()> { // File I/O + preprocessing lives here so the pipeline module // itself doesn't need to touch `std::fs`. That keeps the // pipeline usable from a future WASM host that routes asset @@ -356,10 +370,14 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { // needs a new feature (new flag, new output, whatever), the // change lands in `pipeline::compile_source` and every // caller picks it up automatically. + // + // `--dbg` reuses the same `__src_` markers that + // `--source-map` emits, so either flag flips on source-loc + // emission in the codegen. let pipeline_opts = PipelineOptions { debug: opts.debug, no_opt: opts.no_opt, - emit_source_map: opts.source_map.is_some(), + emit_source_map: opts.source_map.is_some() || opts.dbg.is_some(), }; let out = compile_source(&source, source_dir, &pipeline_opts).map_err(|e| match e { CompileError::Parse(diags) => { @@ -426,6 +444,19 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { eprintln!("error: failed to write source map {}: {e}", path.display()); })?; } + if let Some(path) = opts.dbg.as_ref() { + let dbg = render_dbg( + &out.link_result, + &out.source_locs, + &out.analysis.var_allocations, + &source, + input, + output, + ); + std::fs::write(path, dbg).map_err(|e| { + eprintln!("error: failed to write dbg file {}: {e}", path.display()); + })?; + } Ok(out.rom) } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c8260d5..6d17916 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2129,6 +2129,22 @@ fn compile_with_debug_artifacts(source: &str, debug: bool) -> (Vec, String, (out.rom, mlb, map) } +/// Parse a single `key=value` out of a ca65 `.dbg` record. Records +/// are tab-separated kind/fields (`span\tid=0,seg=0,start=...`), +/// so we strip the leading kind and then scan the comma-separated +/// body for the requested key. +fn dbg_field<'a>(rec: &'a str, key: &str) -> Option<&'a str> { + let (_, body) = rec.split_once('\t')?; + for kv in body.split(',') { + if let Some(rest) = kv.strip_prefix(key) { + if let Some(v) = rest.strip_prefix('=') { + return Some(v); + } + } + } + None +} + #[test] fn symbol_export_lists_user_functions_states_and_vars() { // Compile a small program that exercises the symbol-export @@ -2187,6 +2203,100 @@ fn symbol_export_lists_user_functions_states_and_vars() { } } +#[test] +fn dbg_file_reflects_source_lines_symbols_and_segment() { + // End-to-end check that `render_dbg` stitches together the + // linker's label table, the IR codegen's source-loc markers, + // and the analyzer's variable allocations into a valid ca65 + // `.dbg` file. Mesen and related debuggers consume this + // format; the important invariants are that line records + // point into spans, spans point into the CODE segment, and + // sym records include user-named functions + variables. + use nescript::pipeline::{compile_source, CompileOptions}; + let source = r#" + game "DbgCheck" { mapper: NROM } + var score: u8 = 0 + fun bump() -> u8 { return score + 1 } + state Main { + on frame { + score = bump() + wait_frame + } + } + start Main + "#; + let opts = CompileOptions { + debug: false, + no_opt: false, + emit_source_map: true, + }; + let out = compile_source(source, Path::new("."), &opts).expect("compile"); + let dbg = nescript::linker::render_dbg( + &out.link_result, + &out.source_locs, + &out.analysis.var_allocations, + source, + Path::new("dbgcheck.ne"), + Path::new("dbgcheck.nes"), + ); + + // Header + required record kinds. + assert!(dbg.starts_with("version\tmajor=2,minor=0\n")); + assert!(dbg.lines().any(|l| l.starts_with("info\t"))); + assert!(dbg.lines().any(|l| l.starts_with("file\tid=0"))); + assert!(dbg.lines().any(|l| l.starts_with( + "seg\tid=0,name=\"CODE\",start=0xC000,size=0x4000,addrsize=absolute,type=ro" + ))); + assert!(dbg.contains("oname=\"dbgcheck.nes\"")); + + // Every line record must reference a span that actually exists. + let span_ids: std::collections::HashSet<&str> = dbg + .lines() + .filter(|l| l.starts_with("span\t")) + .filter_map(|l| dbg_field(l, "id")) + .collect(); + assert!( + !span_ids.is_empty(), + "at least one span record should be emitted; got:\n{dbg}" + ); + for line in dbg.lines().filter(|l| l.starts_with("line\t")) { + let span_id = dbg_field(line, "span").unwrap_or(""); + assert!( + span_ids.contains(span_id), + "line record references unknown span id {span_id}:\n {line}" + ); + } + + // User function + state handler + variable all appear as syms. + assert!(dbg.contains("name=\"bump\"")); + assert!(dbg.contains("name=\"Main_frame\"")); + assert!( + dbg.contains("name=\"score\",addrsize=zeropage"), + "zero-page user var should carry addrsize=zeropage:\n{dbg}" + ); + + // The info record's counts must match what we emitted. + let info_line = dbg + .lines() + .find(|l| l.starts_with("info\t")) + .expect("info record"); + let span_count = dbg.lines().filter(|l| l.starts_with("span\t")).count(); + let line_count = dbg.lines().filter(|l| l.starts_with("line\t")).count(); + let sym_count = dbg.lines().filter(|l| l.starts_with("sym\t")).count(); + assert!( + info_line.contains(&format!("span={span_count}")), + "info.span mismatches body:\n {info_line}" + ); + assert!( + info_line.contains(&format!("line={line_count}")), + "info.line mismatches body:\n {info_line}" + ); + assert!( + info_line.contains(&format!("sym={sym_count}")), + "info.sym mismatches body:\n {info_line}" + ); +} + #[test] fn source_map_covers_every_lowered_statement() { let source = r#"