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:
parent
f9198ac52c
commit
e4751df143
6 changed files with 566 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
37
src/main.rs
37
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<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue