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:
parent
33351f8b32
commit
b575921c8e
9 changed files with 1081 additions and 23 deletions
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
284
src/linker/debug_symbols.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
59
src/main.rs
59
src/main.rs
|
|
@ -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<(), ()> {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue