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