mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
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
135 lines
6.1 KiB
Lua
135 lines
6.1 KiB
Lua
-- Mesen2 .dbg validation probe.
|
|
--
|
|
-- 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>
|
|
-- 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-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 fail(code)
|
|
emu.stop(code)
|
|
end
|
|
|
|
-- --- 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 },
|
|
}
|
|
|
|
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.
|