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

linker: add ca65-compatible --dbg output for source-level debugging

Emit a `.dbg` debug-info file in the same format `ld65` produces, so
Mesen / Mesen2 / fceuX pick it up automatically and enable source-line
stepping, labelled variable inspection, and symbol-based breakpoints
without manual address lookups. Closes #23.

The new `render_dbg` helper stitches together metadata the compiler
already surfaces (linker label table, IR codegen `__src_<N>` markers,
analyzer variable allocations) into the file/mod/seg/scope/span/line/sym
records documented at https://cc65.github.io/doc/debugfile.html. Each
source-loc marker becomes a span that stretches to the next marker
(so breakpoints cover every byte the statement compiled into) plus a
line record pointing into it; `seg.ooffs` tracks the fixed bank's
PRG-relative start so banked MMC1/UxROM/MMC3 ROMs map cleanly too.

Reuses the `.mlb` symbol-name filter so internal skip/block labels
stay out of the debugger's symbol browser. `--dbg` implies the same
`__src_` marker emission as `--source-map` but leaves release builds
byte-identical when neither flag is passed.

https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
This commit is contained in:
Claude 2026-04-16 22:39:08 +00:00
parent f9198ac52c
commit e4751df143
No known key found for this signature in database
6 changed files with 566 additions and 16 deletions

View file

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

View file

@ -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 <path>` consumes the `SourceLoc` IR
op and writes a plain-text map of `<rom_offset> <file_id> <line> <col>`
entries for every lowered statement. Debug builds emit array bounds checks
entries for every lowered statement. **`--dbg <path>` 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_<N>` IR markers, and the analyzer's variable
allocations into the `file`/`mod`/`seg`/`scope`/`span`/`line`/`sym`
records documented at
<https://cc65.github.io/doc/debugfile.html>. `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.

View file

@ -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 <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.
//! passes `--symbols <path>`, [`render_source_map`] when the user
//! passes `--source-map <path>`, and [`render_dbg`] when the user
//! passes `--dbg <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 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_<N>` 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_<N>` 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_<N>` 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_<N>` 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}"
);
}
}

View file

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

View file

@ -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<PathBuf>,
/// 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_<N>` marker emission so
/// line records have something to point at.
#[arg(long, value_name = "PATH")]
dbg: Option<PathBuf>,
},
/// 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<PathBuf>,
source_map: Option<PathBuf>,
dbg: Option<PathBuf>,
}
fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
// 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<Vec<u8>, ()> {
// 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_<N>` 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<Vec<u8>, ()> {
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)
}

View file

@ -2129,6 +2129,22 @@ fn compile_with_debug_artifacts(source: &str, debug: bool) -> (Vec<u8>, 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#"