From d075bc2c43a8489d47654c489dc6c4a4363499b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:03:23 +0000 Subject: [PATCH] ci: add headless Mesen2 .dbg validation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run Mesen2's `--testRunner` mode in CI, point it at a NEScript-built ROM + auto-loaded .dbg, and assert via Lua that the four entry-point labels (`nmi`, `irq`, `Main_frame`, `main_loop`) we promised to emit actually resolve. Failures encode which assertion broke into the exit code so CI can report it without stdout (Mesen's emu.log is internal). The setup needed three workarounds, each documented inline in the workflow: * `touch settings.json` to skip Mesen's first-run GUI wizard, which blocks the --testRunner dispatch in Program.cs:74. Contents don't matter — Configuration.Deserialize falls back to defaults on parse error. * `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` to keep .NET from loading system libstdc++ via libicuuc. On Ubuntu 24.04 the dual libstdc++ presence (system + MesenCore.so's bundled static copy) crashes MesenCore's static regex initialiser with std::bad_cast before any user code runs. * `xvfb-run` because Mesen's Avalonia UI calls XOpenDisplay before --testRunner is dispatched. This is a separate workflow file from ci.yml because it depends on the 40 MB Mesen2 binary download + xvfb + sdl2, none of which the existing jobs need. Cached by Mesen version so reruns are fast. https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC --- .github/workflows/mesen.yml | 120 ++++++++++++++++++++++++++++++++++++ tests/mesen/probe.lua | 45 ++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 .github/workflows/mesen.yml create mode 100644 tests/mesen/probe.lua diff --git a/.github/workflows/mesen.yml b/.github/workflows/mesen.yml new file mode 100644 index 0000000..86ae518 --- /dev/null +++ b/.github/workflows/mesen.yml @@ -0,0 +1,120 @@ +name: Mesen .dbg Smoke Test + +# Validates that the `.dbg` debug-info file `nescript build --dbg` emits +# is actually consumable by Mesen2 — the same emulator NES developers +# use for source-level debugging. The job: +# 1. Builds the compiler. +# 2. Builds an example ROM with `--dbg`. +# 3. Launches the Mesen2 release binary in `--testRunner` mode. +# 4. Mesen auto-loads the co-located .dbg, runs `tests/mesen/probe.lua`, +# and exits with a status code the script picks. +# 5. CI fails if any code label we promised is missing or unresolved. +# +# This is a separate workflow from `ci.yml` because it depends on the +# Mesen2 binary download (40 MB) + xvfb + a couple of Mesen-specific +# environment workarounds that aren't worth threading through the main +# CI matrix. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + # Pin the Mesen2 release we test against. Bump this when the upstream + # binary's behaviour changes in a way that affects our probe (new + # label-name normalisation, .dbg format additions, etc.). See + # https://github.com/SourMesen/Mesen2/releases for the source URLs. + MESEN_VERSION: "2.1.1" + +jobs: + mesen-dbg: + name: Mesen2 .dbg validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Install Mesen runtime deps + # libsdl2-2.0-0: Mesen links to it for audio/video even though + # we're running headless. Without it MesenCore.so fails to + # load with `cannot open shared object file: libSDL2-2.0.so.0`. + # xvfb: Mesen's Avalonia UI initialises an X11 connection at + # startup before checking `--testRunner`, so even the headless + # path needs *some* display. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libsdl2-2.0-0 \ + xvfb + + - name: Cache Mesen2 binary + id: mesen-cache + uses: actions/cache@v4 + with: + path: mesen2 + key: mesen2-${{ env.MESEN_VERSION }}-linux-x64 + + - name: Download Mesen2 + if: steps.mesen-cache.outputs.cache-hit != 'true' + run: | + mkdir -p mesen2 + cd mesen2 + curl -sL -o Mesen.zip \ + "https://github.com/SourMesen/Mesen2/releases/download/${MESEN_VERSION}/Mesen_${MESEN_VERSION}_Linux_x64.zip" + unzip -q Mesen.zip + chmod +x Mesen + rm Mesen.zip + + - name: Build NEScript + run: cargo build --release + + - name: Build test ROM with .dbg + # `hello_sprite` is the smallest example with a state handler, + # so its .dbg exercises every record kind render_dbg emits + # (file, mod, seg, scope, span, line, sym) without a long + # compile. + run: | + ./target/release/nescript build examples/hello_sprite.ne \ + --dbg /tmp/hello.dbg \ + --output /tmp/hello.nes + + - name: Validate .dbg via Mesen2 testRunner + # Three workarounds are baked into this single command — see the + # comments inside for *why* each is needed. Removing any of them + # turns this into a multi-hour debugging exercise (it took + # several hours of investigation to find the GLOBALIZATION + # workaround in particular; see commit message of the change + # that added this workflow). + run: | + # 1. `touch settings.json`: Mesen's first-run check + # (`Program.cs:56`) launches the GUI setup wizard + # *before* the `--testRunner` dispatch when no settings + # file exists. The wizard never closes in headless CI, + # blocking forever. The file's *contents* don't matter: + # Configuration.Deserialize catches any JSON parse error + # and falls back to defaults, so a 0-byte file is fine. + touch mesen2/settings.json + + # 2. `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`: .NET's + # globalization layer pulls in libicuuc, which loads + # system libstdc++. MesenCore.so is built with a + # statically-bundled libstdc++; on Ubuntu 24.04 the + # interposition causes a `std::bad_cast` from a static + # regex initialiser in `Base6502Assembler.cpp` long + # before any user code runs. Invariant mode skips ICU + # entirely, which means system libstdc++ is never + # loaded and MesenCore's bundled copy wins. + # + # 3. `xvfb-run -a`: Mesen's Avalonia UI runs `XOpenDisplay` + # during AppBuilder setup before the `--testRunner` + # dispatch in `Program.cs:74`. xvfb-run gives it a + # throwaway X server; -a auto-allocates a display number. + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \ + xvfb-run -a ./mesen2/Mesen \ + --testRunner /tmp/hello.nes tests/mesen/probe.lua \ + --timeout=15 diff --git a/tests/mesen/probe.lua b/tests/mesen/probe.lua new file mode 100644 index 0000000..13821ea --- /dev/null +++ b/tests/mesen/probe.lua @@ -0,0 +1,45 @@ +-- Mesen2 .dbg validation probe. +-- +-- Run via: +-- 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). +-- +-- 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. + +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 +end + +check("nmi", 1, 2) +check("Main_frame", 3, 4) +check("main_loop", 5, 6) +check("irq", 7, 8) + +-- 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)