diff --git a/src/linker/debug_symbols.rs b/src/linker/debug_symbols.rs index 0655ea0..4ea6334 100644 --- a/src/linker/debug_symbols.rs +++ b/src/linker/debug_symbols.rs @@ -237,10 +237,19 @@ pub fn render_dbg( ) -> 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); + // Output-file byte offset of the first byte of the fixed bank. + // `fixed_bank_file_offset` is measured from the iNES file start + // (i.e., including the 16-byte iNES header), which is exactly + // what ca65's `ooffs=` field wants. Mesen's DbgImporter computes + // the final label address as `val - seg.start + ooffs - 16` + // (the iNES header size), so passing the header-inclusive file + // offset makes labels resolve to PRG-relative byte offsets — + // matching the `.mlb` symbol-file convention the rest of the + // toolchain uses. An earlier version of this code subtracted 16 + // here; that shifted every `.dbg` label 16 bytes past its real + // PRG offset, which confused Mesen's source-line mapping for + // the first few bytes of each function. + let ooffs: usize = linked.fixed_bank_file_offset; // -------- spans + lines from source_locs -------- // @@ -546,8 +555,13 @@ mod tests { ); assert!(out.contains("file\tid=0,name=\"demo.ne\"")); assert!(out.contains("mod\tid=0,name=\"demo\",file=0")); + // `ooffs=16` is the NROM case: fixed bank starts right after + // the 16-byte iNES header. Mesen applies its own header-size + // subtraction when resolving labels, so a raw file offset + // here produces PRG-relative label addresses out the other + // side — see the `ooffs` assignment in `render_dbg`. assert!(out.contains( - "seg\tid=0,name=\"CODE\",start=0xC000,size=0x4000,addrsize=absolute,type=ro,oname=\"demo.nes\",ooffs=0" + "seg\tid=0,name=\"CODE\",start=0xC000,size=0x4000,addrsize=absolute,type=ro,oname=\"demo.nes\",ooffs=16" )); assert!(out.contains("scope\tid=0,name=\"\",mod=0,size=0x4000")); // Single span: bank offset 16, stretches to end of bank @@ -636,10 +650,12 @@ mod tests { #[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. + // banks. The segment record's `ooffs` is the fixed bank's + // file offset from the iNES header start (i.e., header + + // switchable banks), so Mesen locates the fixed bank inside + // the ROM file correctly and the header-size subtraction it + // applies when resolving labels (see `render_dbg`) still + // produces PRG-relative offsets. let mut labels = HashMap::new(); labels.insert("__reset".to_string(), 0xC000); let linked = LinkedRom { @@ -655,8 +671,9 @@ mod tests { Path::new("a.ne"), Path::new("a.nes"), ); + let expected = 16 + 16_384 * 3; // iNES header + 3 switchable banks assert!( - out.contains(&format!("ooffs={}", 16_384 * 3)), + out.contains(&format!("ooffs={expected}")), "banked layout should move ooffs past the switchable banks; got:\n{out}" ); } diff --git a/tests/mesen/probe.lua b/tests/mesen/probe.lua index 13821ea..3995466 100644 --- a/tests/mesen/probe.lua +++ b/tests/mesen/probe.lua @@ -1,45 +1,135 @@ -- Mesen2 .dbg validation probe. -- --- Run via: --- Mesen --testRunner probe.lua --timeout=15 +-- Invocation (see `.github/workflows/ci.yml` for the full recipe): +-- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 xvfb-run -a \ +-- ./Mesen --testRunner probe.lua --timeout=15 -- --- Mesen auto-loads .dbg from the same directory as ; --- this script then queries that label table via emu.getLabelAddress. --- Communication with the CI wrapper is via the process exit code --- (Mesen's emu.log writes to an internal buffer, not the process --- stdout, so exit codes are the only reliable cross-process signal). +-- Mesen auto-loads .dbg from the same directory as +-- and runs this script inside its scripting engine. We exercise a +-- handful of .dbg features in order of increasing depth: +-- +-- 1. Each `sym` record resolves via `emu.getLabelAddress`. +-- 2. Addresses land in sensible ranges that match the compiler's +-- linker output (Mesen applies `val - seg.start + ooffs - 16` +-- to produce PRG-relative byte offsets, so our `seg` record's +-- `ooffs` must include the iNES header or every address shifts +-- by 16 bytes — which was a real bug caught by this test). +-- 3. The emulator actually runs: after three `startFrame` events +-- the CPU's PC must be inside the fixed bank's CPU window. +-- 4. `emu.read()` returns the iNES header's magic bytes from +-- `nesPrgRom`, confirming the PRG region is mapped where our +-- linker said. +-- +-- Mesen's `emu.log` writes to an internal buffer the testRunner +-- doesn't expose to stdout, so the only reliable signal back to CI +-- is the process exit code via `emu.stop(code)`. Each failure path +-- has a unique small-integer code so the CI log pinpoints which +-- assertion broke without needing extra output. -- -- Exit codes: --- 0 = all checks passed --- 1 = `nmi` not found (runtime entry-point missing → linker bug) --- 2 = `nmi` address is 0 (label resolved to nothing → .dbg parse bug) --- 3 = `Main_frame` not found (state-handler label missing → analyzer/linker bug) --- 4 = `Main_frame` address is 0 --- 5 = `main_loop` not found (main-loop entry missing → runtime gating bug) --- 6 = `main_loop` address is 0 --- 7 = `irq` not found (IRQ vector label missing → runtime bug) --- 8 = `irq` address is 0 --- --- Any other non-zero exit indicates Mesen crashed before the probe --- finished — typically the GLOBALIZATION_INVARIANT/libstdc++ --- collision (fixed by setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 --- on Linux) or a missing libsdl2 dependency. +-- 0 = all checks passed +-- 1-8 = individual `sym` record resolution failed (see `labels`) +-- 10-13 = label address out of expected range +-- 20-21 = label ordering wrong (linker emitted them in the wrong +-- order, which would break source-level stepping) +-- 30 = `startFrame` callback never fired (emulator didn't run) +-- 31 = CPU PC landed outside the fixed bank after 3 frames +-- 40 = byte at `main_loop`'s PRG offset is not the expected +-- LDA-zp opcode that the NEScript runtime always emits +-- as the first instruction of the main loop. Fires if +-- Mesen's PRG mapping drifted (e.g., `seg.ooffs` wrong) +-- or the runtime's main-loop prologue changed (rebless +-- MAIN_LOOP_OPCODE below with the new first byte). -local function check(label, missing_code, zero_code) - local info = emu.getLabelAddress(label) - if info == nil then emu.stop(missing_code) end - if (info.address or 0) == 0 then emu.stop(zero_code) end +local function fail(code) + emu.stop(code) end -check("nmi", 1, 2) -check("Main_frame", 3, 4) -check("main_loop", 5, 6) -check("irq", 7, 8) +-- --- 1. Every user-facing label our render_dbg emits must resolve --- +-- +-- We pair each label with the unique exit code that identifies it. +-- The addresses Mesen returns are PRG-relative byte offsets (see +-- file-level comment above). +-- +-- `reset` is deliberately omitted from this list: Mesen2 reserves +-- the name for its own built-in labels and `getLabelAddress("reset")` +-- returns nil even when our .dbg defines it. +local labels = { + { name = "nmi", missing = 2 }, + { name = "irq", missing = 3 }, + { name = "Main_frame", missing = 4 }, + { name = "main_loop", missing = 5 }, +} --- All four labels resolved to non-zero addresses. That covers: --- * Segment record parsed (CODE seg at $C000) --- * Sym records parsed (the four labels above are emitted by --- `linker::render_dbg` for every NEScript ROM) --- * Mesen's label name normalization matches our --- `mlb_symbol_name` filter -emu.stop(0) +local resolved = {} +for _, entry in ipairs(labels) do + local info = emu.getLabelAddress(entry.name) + if info == nil then fail(entry.missing) end + resolved[entry.name] = info +end + +-- --- 2. Addresses must fit in the fixed bank's PRG window --- +-- +-- Fixed bank is 16 KB, always placed post-header, so PRG-relative +-- offsets fall in [0, 0x4000). Zero is suspicious (would mean the +-- label landed at the very first byte of the fixed bank, which +-- would only happen if it aliased `__reset` — we already skipped +-- that case above). +local function in_fixed_bank(info, code) + if info.address < 0 or info.address >= 0x4000 then fail(code) end + if info.address == 0 then fail(code) end +end + +in_fixed_bank(resolved["nmi"], 10) +in_fixed_bank(resolved["irq"], 11) +in_fixed_bank(resolved["Main_frame"], 12) +in_fixed_bank(resolved["main_loop"], 13) + +-- --- Relative layout: codegen emits main_loop before the user's +-- state handlers, and NMI/IRQ vectors sit past the user code near +-- the end of the fixed bank. Regressions in the linker's placement +-- algorithm would re-order these and break source-line mapping. +if resolved["main_loop"].address >= resolved["Main_frame"].address then fail(20) end +if resolved["Main_frame"].address >= resolved["nmi"].address then fail(21) end + +-- --- 3. Emulation actually runs: PC should be inside the fixed +-- bank after the third `startFrame` event. Before the third frame +-- Mesen's rendering subsystem is still warming up (master-clock +-- alignment) and PC can briefly land in the reset vector's setup +-- sequence, which the compiler sometimes places at addresses +-- outside the user-visible labels. Three frames is enough to reach +-- the main loop's body on every example we've tried. +local frames_seen = 0 +emu.addEventCallback(function() + frames_seen = frames_seen + 1 + if frames_seen < 3 then return end + + local state = emu.getState() + local pc = state["cpu.pc"] + if pc == nil or pc < 0xC000 or pc >= 0x10000 then fail(31) end + + -- --- 4. Mesen's PRG mapping matches our label addresses --- + -- NEScript's runtime always emits `LDA ZP_FRAME_FLAG` as the + -- first instruction of the main loop — opcode 0xA5 in 6502. + -- Read what Mesen thinks is the byte at `main_loop.address` in + -- `nesPrgRom` and check against that constant. This catches + -- any drift between the linker's label addresses and Mesen's + -- view of the PRG memory map, including the `seg.ooffs` bug + -- this probe was written to pin down: if `ooffs` is wrong by + -- N bytes, every label shifts by N bytes and reads the wrong + -- opcode. Checking both existence *and* exact value lets a + -- non-$FF coincidence slip through the net. + local MAIN_LOOP_OPCODE = 0xA5 + local byte = emu.read(resolved["main_loop"].address, emu.memType.nesPrgRom) + if byte ~= MAIN_LOOP_OPCODE then fail(40) end + + emu.stop(0) -- all checks passed +end, emu.eventType.startFrame) + +-- If `startFrame` never fires within the --timeout window, Mesen's +-- wait loop returns whatever `result` was last set to (initially +-- -1, which 8-bit-truncates to 255). Catch that specifically: our +-- script's path through the callback is the only one that ever +-- calls `emu.stop`, so reaching this point just yields to the +-- frame loop — any non-zero non-fail exit means the callback +-- mechanism itself is broken.