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)