1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00

linker+ci: fix .dbg seg.ooffs to include iNES header + deepen probe

The `seg.ooffs` field in our ca65 .dbg output was off by 16 — it was
emitting the PRG-relative fixed-bank offset when ca65's convention
(and Mesen's DbgImporter.cs:301 math:
`Address = val - seg.start + ooffs - headerSize`) expects the raw
output-file offset, *including* the iNES header. The practical
consequence: every label Mesen resolved via the .dbg was 16 bytes
short of its true PRG offset, which silently corrupted source-line
mapping for the first bytes of each function.

Fix is a one-liner — drop the `saturating_sub(16)` and feed
`linked.fixed_bank_file_offset` straight into the ooffs field. Unit
tests in debug_symbols.rs updated to assert the new values (ooffs=16
for NROM, 16+16K*N for banked).

The Mesen probe (`tests/mesen/probe.lua`) is expanded in the same
change, because the sabotage test that caught this bug is also the
cleanest demonstration the probe is working:
 * checks all four entry-point labels resolve and land inside the
   fixed bank's CPU window ($C000-$FFFF);
 * asserts the linker's relative ordering (main_loop < Main_frame
   < nmi);
 * registers a startFrame callback, waits three frames, and verifies
   PC is still in the fixed bank + that `emu.read(main_loop.address,
   nesPrgRom)` returns 0xA5 (the LDA-zp opcode the runtime always
   places as main_loop's first instruction). The 0xA5 constant is
   what catches the ooffs regression — a less-specific "not 0xFF"
   check coincidentally passed even with ooffs=0 because the shifted
   address still landed on real code.

Verified locally by running the probe against hello_sprite's ROM
with four different `.dbg` mutations and confirming each triggers
the expected exit code.

https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
This commit is contained in:
Claude 2026-04-17 01:27:50 +00:00
parent 90e01bd197
commit a5f508a678
No known key found for this signature in database
2 changed files with 153 additions and 46 deletions

View file

@ -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}"
);
}

View file

@ -1,45 +1,135 @@
-- Mesen2 .dbg validation probe.
--
-- Run via:
-- Mesen --testRunner <rom.nes> probe.lua --timeout=15
-- Invocation (see `.github/workflows/ci.yml` for the full recipe):
-- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 xvfb-run -a \
-- ./Mesen --testRunner <rom.nes> probe.lua --timeout=15
--
-- Mesen auto-loads <rom>.dbg from the same directory as <rom.nes>;
-- 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 <rom>.dbg from the same directory as <rom.nes>
-- 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.