mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
Move the Mesen2 .dbg validation job from its own workflow file into the existing ci.yml so all CI lives in one place. Same job content and same workarounds (settings.json shim, GLOBALIZATION_INVARIANT, xvfb-run) — only the file location changed. The MESEN_VERSION env var moves up to the workflow-level env block. https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
307 lines
12 KiB
YAML
307 lines
12 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
RUSTFLAGS: "-D warnings"
|
|
# Pin the Mesen2 release the `mesen-dbg` job tests against. Bump
|
|
# this when the upstream binary's behaviour changes in a way that
|
|
# affects the 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:
|
|
check:
|
|
name: Check
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
- run: cargo check --all-targets
|
|
|
|
fmt:
|
|
name: Format
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: rustfmt
|
|
- run: cargo fmt --all -- --check
|
|
|
|
clippy:
|
|
name: Clippy
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: clippy
|
|
- uses: Swatinem/rust-cache@v2
|
|
- run: cargo clippy --all-targets -- -D warnings
|
|
|
|
test:
|
|
name: Test
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
- run: cargo test --all-targets
|
|
|
|
examples:
|
|
name: Build Examples
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
- run: cargo build --release
|
|
- name: Verify committed ROMs match their .ne sources
|
|
# Every example has a committed .nes file under examples/. The
|
|
# compiler is deterministic (verified: same input → byte-identical
|
|
# output), so rebuilding each example into a tmp directory and
|
|
# diffing against the committed ROM catches "someone edited a .ne
|
|
# file but forgot to regenerate the ROM" at PR time.
|
|
run: |
|
|
mkdir -p /tmp/reprotest
|
|
fail=0
|
|
for f in examples/*.ne; do
|
|
name=$(basename "$f" .ne)
|
|
./target/release/nescript build "$f" --output "/tmp/reprotest/$name.nes"
|
|
if ! cmp -s "examples/$name.nes" "/tmp/reprotest/$name.nes"; then
|
|
echo "::error file=examples/$name.ne::committed examples/$name.nes is stale; rerun \`cargo run --release -- build examples/$name.ne\` and commit the new ROM"
|
|
fail=1
|
|
fi
|
|
done
|
|
if [ $fail -ne 0 ]; then
|
|
exit 1
|
|
fi
|
|
- name: Verify ROMs are valid iNES
|
|
run: |
|
|
for f in examples/*.nes; do
|
|
echo "==> Checking $f"
|
|
# iNES magic: first 4 bytes must be "NES\x1a"
|
|
header=$(od -A n -t x1 -N 4 "$f" | tr -d ' ')
|
|
if [ "$header" != "4e45531a" ]; then
|
|
echo "FAIL: $f is not a valid iNES ROM (header: $header)"
|
|
exit 1
|
|
fi
|
|
size=$(stat -c%s "$f")
|
|
echo " OK: $size bytes"
|
|
done
|
|
|
|
emulator:
|
|
name: Emulator Smoke Test
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
- name: Install Chrome runtime deps for puppeteer
|
|
# puppeteer ships its own Chrome for Testing binary via the
|
|
# package's postinstall hook, but the runner image is missing
|
|
# a handful of shared libs that Chrome needs to launch.
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends \
|
|
libnss3 \
|
|
libatk-bridge2.0-0 \
|
|
libatk1.0-0 \
|
|
libatspi2.0-0 \
|
|
libcups2t64 \
|
|
libdbus-1-3 \
|
|
libdrm2 \
|
|
libgbm1 \
|
|
libpango-1.0-0 \
|
|
libxcomposite1 \
|
|
libxdamage1 \
|
|
libxfixes3 \
|
|
libxkbcommon0 \
|
|
libxrandr2 \
|
|
libasound2t64
|
|
- name: Build compiler
|
|
run: cargo build --release
|
|
- name: Compile all .ne examples
|
|
# Overwrites the committed examples/*.nes in the CI workspace
|
|
# (the workspace is ephemeral; the committed ROMs aren't
|
|
# touched). This is load-bearing: the goldens must be diffed
|
|
# against what *this* compiler produces, not against the ROMs
|
|
# an earlier commit happened to ship, otherwise a broken
|
|
# compiler change can still pass the emulator job as long as
|
|
# the contributor forgot to rebuild the ROMs. The `examples`
|
|
# job catches that second case via its reproducibility diff.
|
|
run: |
|
|
for f in examples/*.ne; do
|
|
echo "==> Compiling $f"
|
|
./target/release/nescript build "$f"
|
|
done
|
|
- name: Install emulator harness dependencies
|
|
working-directory: tests/emulator
|
|
run: npm ci
|
|
- name: Diff each ROM's framebuffer against its golden
|
|
working-directory: tests/emulator
|
|
run: node run_examples.mjs
|
|
- name: Verify docs/platformer.gif is up to date
|
|
# The README embeds docs/platformer.gif as the project demo.
|
|
# gifenc + jsnes are deterministic, so rebuilding the gif from
|
|
# the current compiler should byte-match the committed copy.
|
|
# If it doesn't, the contributor changed the compiler / source
|
|
# / harness in a way that affects the observable gameplay of
|
|
# platformer.ne but forgot to regenerate the gif — the fix is
|
|
# the exact command printed in the error.
|
|
working-directory: tests/emulator
|
|
run: |
|
|
node record_gif.mjs platformer 360 2 /tmp/platformer.gif
|
|
if ! cmp -s ../../docs/platformer.gif /tmp/platformer.gif; then
|
|
echo "::error file=docs/platformer.gif::committed docs/platformer.gif is stale; rerun \`node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif\` and commit the new gif"
|
|
exit 1
|
|
fi
|
|
- name: Verify docs/war.gif is up to date
|
|
# Same contract as docs/platformer.gif: the README embeds
|
|
# docs/war.gif as the second project demo, gifenc + jsnes are
|
|
# deterministic, so any change to the compiler / runtime /
|
|
# harness / war source that affects the first ~6 seconds of
|
|
# the war.ne gameplay must be followed by regenerating the
|
|
# gif. The 5th positional arg (`4`) is the warmup; war's
|
|
# title menu is the gif thumbnail so we don't skip past it.
|
|
working-directory: tests/emulator
|
|
run: |
|
|
node record_gif.mjs war 360 2 /tmp/war.gif 4
|
|
if ! cmp -s ../../docs/war.gif /tmp/war.gif; then
|
|
echo "::error file=docs/war.gif::committed docs/war.gif is stale; rerun \`node tests/emulator/record_gif.mjs war 360 2 docs/war.gif 4\` and commit the new gif"
|
|
exit 1
|
|
fi
|
|
- name: Verify docs/pong.gif is up to date
|
|
# Same contract as the platformer and war gifs: the README
|
|
# embeds docs/pong.gif as the third project demo. Pong's
|
|
# title screen (CPU VS CPU / 1 PLAYER / 2 PLAYERS menu) is
|
|
# the gif thumbnail, so warmup = 4 keeps it in frame before
|
|
# the autopilot advances to gameplay.
|
|
working-directory: tests/emulator
|
|
run: |
|
|
node record_gif.mjs pong 360 2 /tmp/pong.gif 4
|
|
if ! cmp -s ../../docs/pong.gif /tmp/pong.gif; then
|
|
echo "::error file=docs/pong.gif::committed docs/pong.gif is stale; rerun \`node tests/emulator/record_gif.mjs pong 360 2 docs/pong.gif 4\` and commit the new gif"
|
|
exit 1
|
|
fi
|
|
- name: Upload actual + diff PNGs on failure
|
|
if: failure()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: emulator-diff
|
|
# `actual/` holds the run's output and red-highlighted diff
|
|
# PNGs for any ROM that didn't match its golden. `report.json`
|
|
# has the per-example pass/fail/diff counts. `goldens/` is
|
|
# included too so reviewers can compare the three side by side
|
|
# without cloning the repo.
|
|
path: |
|
|
tests/emulator/actual/
|
|
tests/emulator/goldens/
|
|
tests/emulator/report.json
|
|
|
|
mesen-dbg:
|
|
# 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. Builds the compiler,
|
|
# builds an example ROM with `--dbg`, then launches the Mesen2
|
|
# release binary in `--testRunner` mode. Mesen auto-loads the
|
|
# co-located .dbg, runs `tests/mesen/probe.lua`, and exits with a
|
|
# status code the script picks. CI fails if any code label we
|
|
# promised to emit is missing or unresolved.
|
|
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 the commit
|
|
# message for d075bc2).
|
|
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
|