name: C/C++ CI on: # No `develop` in the push list: every change reaches develop through a PR that # already ran this workflow, so re-running it per merge mostly duplicated the PR # run. (Tests do not re-run on the merged tip; release.yml still compiles every # develop tip for the nightly.) To re-run on develop by hand, use # workflow_dispatch with the branch picker on develop. push: branches: [ master ] pull_request: branches: [ develop, master ] workflow_dispatch: # Cancel in-progress runs when a new commit lands on the same PR. # Pushes to master run to completion (no cancellation) so we always have a # green-or-red record on the release branch. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: core-plan: name: Production core routing and safety tests runs-on: ${{ contains(fromJSON(vars.VJ_TEMP_RUNNERS || '{"refs":[]}').refs, github.ref) && fromJSON(vars.VJ_TEMP_RUNNERS)['ubuntu-latest'] || 'ubuntu-latest' }} permissions: contents: read outputs: reuse: ${{ steps.plan.outputs.reuse }} matrix: ${{ steps.plan.outputs.matrix }} steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '22' - name: Ensure the workflow contract parser is available run: | if ! command -v ruby >/dev/null 2>&1; then sudo apt-get update sudo apt-get install -y --no-install-recommends ruby fi - name: Verify compiler, SDK and production ABI contracts run: ruby .github/scripts/tests/core-workflow-contract.rb - run: node --test .github/scripts/tests/release-packages.test.cjs .github/scripts/tests/release-cores.test.cjs - id: plan uses: actions/github-script@v9 with: script: | const { planCores } = require('./.github/scripts/release-cores.cjs'); await planCores({ github, context, core }); core-reuse: name: Verify and retain shared production cores needs: [core-plan] if: needs.core-plan.outputs.reuse == 'true' runs-on: ubuntu-latest timeout-minutes: 360 permissions: actions: read contents: read pull-requests: read steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '22' - name: Require all exact-head production jobs and artifacts id: verify uses: actions/github-script@v9 with: script: | const { waitForCores } = require('./.github/scripts/release-cores.cjs'); await waitForCores({ github, context, core }); - name: Download the unstripped production intermediates uses: actions/download-artifact@v8 with: github-token: ${{ github.token }} run-id: ${{ steps.verify.outputs.run_id }} pattern: ci-core-* path: shared-cores - name: Verify exact commit, production ABI and binary checksums env: CORE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | node -e 'require("./.github/scripts/release-cores.cjs").verifyDownloads("shared-cores", process.env.CORE_HEAD_SHA)' - name: Retain macOS x86_64 (Clang, cross) artifact uses: actions/upload-artifact@v7 with: name: 'macOS x86_64 (Clang, cross)' path: shared-cores/ci-core-macos-x86_64/virtualjaguar_libretro.dylib if-no-files-found: error overwrite: true - name: Retain Emscripten (WASM) artifact uses: actions/upload-artifact@v7 with: name: 'Emscripten (WASM)' path: shared-cores/ci-core-emscripten-wasm/virtualjaguar_libretro_emscripten.bc if-no-files-found: error overwrite: true - name: Retain Android arm64-v8a artifact uses: actions/upload-artifact@v7 with: name: 'Android arm64-v8a' path: shared-cores/ci-core-android-arm64-v8a/libretro.so if-no-files-found: error overwrite: true - name: Retain Android armeabi-v7a artifact uses: actions/upload-artifact@v7 with: name: 'Android armeabi-v7a' path: shared-cores/ci-core-android-armeabi-v7a/libretro.so if-no-files-found: error overwrite: true - name: Retain Android x86_64 artifact uses: actions/upload-artifact@v7 with: name: 'Android x86_64' path: shared-cores/ci-core-android-x86_64/libretro.so if-no-files-found: error overwrite: true - name: Retain Android x86 artifact uses: actions/upload-artifact@v7 with: name: 'Android x86' path: shared-cores/ci-core-android-x86/libretro.so if-no-files-found: error overwrite: true - name: Retain iOS arm64 artifact uses: actions/upload-artifact@v7 with: name: 'iOS arm64' path: shared-cores/ci-core-ios-arm64/virtualjaguar_libretro_ios.dylib if-no-files-found: error overwrite: true - name: Retain tvOS arm64 artifact uses: actions/upload-artifact@v7 with: name: 'tvOS arm64' path: shared-cores/ci-core-tvos-arm64/virtualjaguar_libretro_tvos.dylib if-no-files-found: error overwrite: true - name: Retain PS Vita artifact uses: actions/upload-artifact@v7 with: name: 'PS Vita' path: shared-cores/ci-core-vita/virtualjaguar_libretro_vita.a if-no-files-found: error overwrite: true - name: Retain Nintendo Switch artifact uses: actions/upload-artifact@v7 with: name: 'Nintendo Switch' path: shared-cores/ci-core-switch/virtualjaguar_libretro_libnx.a if-no-files-found: error overwrite: true build: needs: [core-plan] strategy: fail-fast: false matrix: config: ${{ fromJSON(needs.core-plan.outputs.matrix) }} name: build-${{ matrix.config.displayTargetName }} env: # Compile-only rows match Release's production configuration; native test mode switches stay unchanged. RELEASE_DEBUG_INFO: ${{ matrix.config.release_platform && '1' || '0' }} # Mac Studio self-hosted runner for macOS rows on trusted events only (push, # schedule, dispatch, same-repo PR); fork PRs always get the hosted runner. # Kill switch: repository variable VJ_STUDIO_RUNNER=off. See docs/agent/github.md. runs-on: ${{ matrix.config.os == 'macos-latest' && vars.VJ_STUDIO_RUNNER != 'off' && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","studio","vj"]') || matrix.config.os }} defaults: run: shell: ${{ matrix.config.shell || 'bash' }} steps: - uses: actions/checkout@v7 - name: Install multilib if: matrix.config.multilib run: | sudo dpkg --add-architecture i386 sudo apt-get update sudo apt-get install -y gcc-multilib g++-multilib - name: Set up MSYS2 if: runner.os == 'Windows' uses: msys2/setup-msys2@v2 with: msystem: ${{ matrix.config.msystem || 'MINGW64' }} update: false install: ${{ matrix.config.msys2_packages || 'mingw-w64-x86_64-gcc make' }} - name: Set up Emscripten if: matrix.config.emscripten uses: mymindstorm/setup-emsdk@v16 - name: Set up Android NDK if: matrix.config.android uses: nttld/setup-ndk@v1 id: setup-ndk with: ndk-version: r26d - name: Build if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.make_platform }} run: make -j4 CC="${{ matrix.config.cc }}" CXX="${{ matrix.config.cxx }}" ${{ matrix.config.make_extra || '' }} - name: Build (platform) if: matrix.config.make_platform run: make -j4 platform=${{ matrix.config.make_platform }} - name: Build (Emscripten) if: matrix.config.emscripten run: emmake make -j4 platform=emscripten - name: Build (Android NDK) if: matrix.config.android run: bash scripts/build-ndk-ci.sh "${{ steps.setup-ndk.outputs.ndk-path }}" "${{ matrix.config.android_abi }}" "${{ matrix.config.android_simd }}" # Mach-O symbol gating: production builds must export retro_* only. # Runs before the test step (which would relink with TEST_EXPORTS=1 # and widen the symbol set). - name: Verify Mach-O symbol gating (production) if: ${{ runner.os == 'macOS' && !matrix.config.emscripten && !matrix.config.android }} run: | LEAKS=$(nm -gU "${{ matrix.config.artifact }}" | grep -v ' _retro_' || true) if [ -n "$LEAKS" ]; then echo "::error::Production dylib leaked non-retro_* symbols:" echo "$LEAKS" exit 1 fi COUNT=$(nm -gU "${{ matrix.config.artifact }}" | wc -l | tr -d ' ') echo "==> Production exports: ${COUNT} (all retro_*)" # Tier-1 private ROM corpus (~22 MB) from a private S3-compatible bucket. # Without it `make test` runs green while SKIPPING the audio pair, the # clipping sentinels and every other check that needs a commercial ROM -- # a green run that tested none of the things that catch regressions. # # Cached on the manifest hash, so a hit costs no egress at all and only a # manifest change re-downloads. Secrets are absent on fork PRs by design; # the step is best-effort there and `make test` falls back to skipping, # exactly as it does today. - name: Cache Tier-1 ROMs if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} uses: actions/cache@v6 with: path: test/roms/tier1 key: vj-roms-tier1-${{ hashFiles('test/roms/tier1.manifest') }} - name: Fetch Tier-1 ROMs id: roms if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} continue-on-error: true env: # Two backends; rom-store.sh picks whichever is configured (GitHub # wins when VJ_ROM_GH_REPO is set). Set one group of secrets, not both. # # GitHub backend: VJ_ROM_GH_TOKEN must be a fine-grained PAT with # read-only Contents on the ROM repo. The built-in GITHUB_TOKEN cannot # be used -- it is scoped to THIS repository and cannot read another # private one, which fails as a 404 rather than a permission error. VJ_ROM_GH_REPO: ${{ secrets.VJ_ROM_GH_REPO }} GH_TOKEN: ${{ secrets.VJ_ROM_GH_TOKEN }} # S3 backend (Cloudflare R2 etc.) VJ_ROM_ENDPOINT: ${{ secrets.VJ_ROM_ENDPOINT }} VJ_ROM_BUCKET: ${{ secrets.VJ_ROM_BUCKET }} VJ_ROM_KEY: ${{ secrets.VJ_ROM_KEY }} VJ_ROM_SECRET: ${{ secrets.VJ_ROM_SECRET }} run: | set +e bash scripts/rom-store.sh fetch rc=$? if [ "$rc" -eq 0 ]; then echo "roms=1" >> "$GITHUB_OUTPUT" elif [ "$rc" -eq 77 ]; then echo "::notice::Tier-1 ROM store not configured -- ROM-gated checks will skip." echo "roms=0" >> "$GITHUB_OUTPUT" else # A configured store that FAILS is not the same as an absent one and # must be visible, even though this step does not block the build. echo "::warning::Tier-1 ROM fetch FAILED (rc=$rc) -- ROM-gated checks will skip." echo "roms=0" >> "$GITHUB_OUTPUT" fi exit 0 # Host/native toolchains only — skips cross-compile rows (e.g. aarch64 on x86 runner). - name: Run cheat engine unit tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} env: # Only set when the fetch actually produced a verified tree. Pointing # this at an empty directory would make find-rom.sh miss everything # and turn every ROM-gated check into a silent skip. ROMS_PRIVATE_ROOT: ${{ steps.roms.outputs.roms == '1' && 'test/roms/tier1' || '' }} run: make test CC="${{ matrix.config.cc }}" # Standalone libchdr + committed fixtures. No core dlopen, so this # also runs on Windows MSYS2 where `make test` is skipped. - name: CHD fixture unit tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} run: | # Compile with $CC directly. `make` of these targets after `make test` # flips the ABI stamp and deletes the core library. CC="${{ matrix.config.cc }}" INCFLAGS="-I. -Isrc -Isrc/core -Isrc/tom -Isrc/jerry -Isrc/cd -Isrc/bios -Isrc/m68000 -Ilibretro-common/include -Ideps/libchdr/include -Ideps/libchdr/deps/lzma-25.01/include -Ideps/libchdr/deps/miniz-3.1.1 -Ideps/libchdr/deps/zstd-1.5.7" LIBCHDR="-D_7ZIP_ST -DWANT_RAW_DATA_SECTOR=1 -DWANT_SUBCODE=1 -DVERIFY_BLOCK_CRC=1" $CC -O2 -Wall -std=c99 $INCFLAGS $LIBCHDR \ -o tools/jagcd/jagcd-chd-check tools/jagcd/jagcd-chd-check.c deps/libchdr/unity.c $CC -O2 -Wall -Wno-unused-function -Wno-unused-variable -std=c99 $INCFLAGS $LIBCHDR \ -o test/test_chd_unit test/test_chd_unit.c deps/libchdr/unity.c ./tools/jagcd/jagcd-chd-check test/roms/synth_jagcd.chd if ./tools/jagcd/jagcd-chd-check test/roms/synth_jagcd_nosession.chd; then echo "jagcd-chd-check: expected exit 1 on synth_jagcd_nosession.chd" exit 1 else rc=$? if [ "$rc" -ne 1 ]; then echo "jagcd-chd-check: unexpected $rc" exit "$rc" fi fi # exit 2 = virtual pregaps present (a warning, still a valid CHD). rc=0 ./tools/jagcd/jagcd-chd-check test/roms/synth_jagcd_vpregap.chd >/dev/null || rc=$? if [ "$rc" -ne 2 ]; then echo "jagcd-chd-check: expected exit 2 on synth_jagcd_vpregap.chd, got $rc" exit 1 fi ./test/test_chd_unit - name: Run SIMD blitter tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} run: | ARCH=$(uname -m) CC="${{ matrix.config.cc }}" # -DBLITTER_SIMD_ is not optional: blitter_simd.h inlines # the matching arch header, so an arch .c compiled without its # define pulls in the scalar ops and then redefines every one. case "$ARCH" in x86_64|i686|i386) SIMD_SRC=src/tom/blitter_simd_sse2.c; EXTRA="-msse2 -DBLITTER_SIMD_SSE2" ;; aarch64|arm64) SIMD_SRC=src/tom/blitter_simd_neon.c; EXTRA="-DBLITTER_SIMD_NEON" ;; *) SIMD_SRC=src/tom/blitter_simd_scalar.c; EXTRA="-DBLITTER_SIMD_SCALAR" ;; esac # -I libretro-common/include is required, not decorative: blitter_simd.h # does `#include `, and libretro-common owns that header. # This compile used to resolve it by accident through src/core, which # carried a stale duplicate copy; with that removed the include path has # to name the real location. Makefile.common always passed this -I, so # only these by-hand compiles were affected. echo "==> Testing ${SIMD_SRC}..." $CC -O2 -Wall ${EXTRA} -I src -I src/core -I src/tom -I libretro-common/include \ -o test_blitter_simd test/test_blitter_simd.c ${SIMD_SRC} ./test_blitter_simd echo "==> Cross-checking against scalar..." $CC -O2 -Wall -DBLITTER_SIMD_SCALAR -I src -I src/core -I src/tom -I libretro-common/include \ -o test_blitter_scalar test/test_blitter_simd.c src/tom/blitter_simd_scalar.c ./test_blitter_scalar echo "==> DSP 40-bit MAC accumulator regression (dsp_acc40.h)..." $CC -O2 -Wall -I src -I src/jerry -o test_dsp_mac40 test/test_dsp_mac40.c ./test_dsp_mac40 - name: Run memory map test if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: | CC="${{ matrix.config.cc }}" if [ "$(uname)" = "Linux" ]; then LDFLAGS="-ldl" else LDFLAGS="" fi $CC -O2 -Wall -o test/tools/test_memory_map test/tools/test_memory_map.c $LDFLAGS ./test/tools/test_memory_map ./${{ matrix.config.artifact }} - name: Run DSP instruction set tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: | CC="${{ matrix.config.cc }}" if [ "$(uname)" = "Linux" ]; then LDFLAGS="-ldl"; else LDFLAGS=""; fi $CC -O2 -Wall -o test/test_dsp_ops test/test_dsp_ops.c $LDFLAGS $CC -O2 -Wall -o test/test_dsp_unit test/test_dsp_unit.c $LDFLAGS ./test/test_dsp_ops ./test/test_dsp_unit - name: Run GPU instruction set tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: | CC="${{ matrix.config.cc }}" if [ "$(uname)" = "Linux" ]; then LDFLAGS="-ldl"; else LDFLAGS=""; fi $CC -O2 -Wall -o test/test_gpu_ops test/test_gpu_ops.c $LDFLAGS ./test/test_gpu_ops $CC -O2 -Wall -o test/test_op_gpu_object test/test_op_gpu_object.c $LDFLAGS ./test/test_op_gpu_object ./${{ matrix.config.artifact }} - name: Run 68K instruction set tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: | CC="${{ matrix.config.cc }}" if [ "$(uname)" = "Linux" ]; then LDFLAGS="-ldl"; else LDFLAGS=""; fi $CC -O2 -Wall -o test/test_m68k_ops test/test_m68k_ops.c $LDFLAGS ./test/test_m68k_ops - name: Run EEPROM lifecycle test if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: | CC="${{ matrix.config.cc }}" if [ "$(uname)" = "Linux" ]; then LDFLAGS="-ldl"; else LDFLAGS=""; fi INCFLAGS="-I. -Isrc -Isrc/core -Isrc/tom -Isrc/jerry -Ilibretro-common/include" $CC -O2 -Wall -o "$RUNNER_TEMP/gen_eeprom_test_rom" test/tools/gen_eeprom_test_rom.c "$RUNNER_TEMP/gen_eeprom_test_rom" "$RUNNER_TEMP/eeprom_lifecycle_test.j64" $CC -O2 -Wall -std=c99 $INCFLAGS \ -o test/test_eeprom_lifecycle test/test_eeprom_lifecycle.c \ test/harness/harness.c $LDFLAGS -lm ./test/test_eeprom_lifecycle ./${{ matrix.config.artifact }} "$RUNNER_TEMP/eeprom_lifecycle_test.j64" - name: Cache pinned rcheevos E2E build if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} uses: actions/cache@v6 with: path: build/rcheevos-static key: rcheevos-e2e-fd57e900-${{ runner.os }}-${{ matrix.config.cc }}-${{ matrix.config.displayTargetName }} - name: RetroAchievements rc_libretro E2E test if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} env: # Immutable pin (rcheevos v12.3.0); tarball via .../archive/${SHA}.tar.gz RCHEEVOS_REF: fd57e900758a9e3de343e7b0316b8a6059dce228 CC: ${{ matrix.config.cc }} run: bash test/tools/test_rcheevos_e2e.sh ./${{ matrix.config.artifact }} - name: Upload artifact uses: actions/upload-artifact@v7 with: name: ${{ matrix.config.displayTargetName }} path: ${{ matrix.config.artifact }} if-no-files-found: error # TEST_EXPORTS=1 -> production mode switch. TEST_EXPORTS adds # -DVJ_TRACE, so it changes object *content*, not just the export list; # the Makefile flushes every object on a mode change, and a stale one # fails the link with undefined vjtrace_* symbols. The build + `make # test` steps above already exercise plain -> TEST_EXPORTS=1; this is the # reverse, which is the direction that regressed (blit_memo.o was missing # from the flush list). Runs last so it cannot hand a production-ABI # library to the dlsym-based steps or to the uploaded artifact. - name: Verify TEST_EXPORTS -> production mode switch relinks if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross && runner.os != 'Windows' }} run: make -j4 CC="${{ matrix.config.cc }}" CXX="${{ matrix.config.cxx }}" ${{ matrix.config.make_extra || '' }} # Independent lightweight checks share one checkout and runner allocation. # !cancelled() keeps later checks running after a failure; any failure still fails the job. source-validation: name: Source validation (lists, EditorConfig, cppcheck, C89) runs-on: ${{ contains(fromJSON(vars.VJ_TEMP_RUNNERS || '{"refs":[]}').refs, github.ref) && fromJSON(vars.VJ_TEMP_RUNNERS)['ubuntu-latest'] || 'ubuntu-latest' }} steps: - uses: actions/checkout@v7 # src/core/version_fallback.h holds a deliberate copy of # CORE_BASE_VERSION for build systems that cannot run # scripts/gen-version-h.sh before compiling (the Provenance SwiftPM # manifest). Nothing rewrites it at build time on purpose -- the # generator runs at Makefile *parse* time, so a generator that edited a # tracked file would dirty the tree on every build and feed a false # "-dirty" into scripts/build-id.sh and the VJ_EXPECT_BUILD guard. # # So the copy is checked rather than fixed, the same way the # docs/cd-boot-matrix.md drift guard works: fail loudly, never quietly # correct. version-bump.yml updates both files on a release bump; this # catches a hand edit that touched only one. - name: CORE_BASE_VERSION matches the Makefile if: ${{ !cancelled() }} run: | set -euo pipefail mk=$(sed -n 's/^CORE_BASE_VERSION[[:space:]]*:*=[[:space:]]*\(.*\)/\1/p' Makefile | head -1) fb=$(sed -n 's/^#define CORE_BASE_VERSION "\(.*\)"$/\1/p' src/core/version_fallback.h | head -1) if [ -z "$mk" ]; then echo "could not read CORE_BASE_VERSION from Makefile"; exit 1; fi if [ -z "$fb" ]; then echo "could not read CORE_BASE_VERSION from src/core/version_fallback.h"; exit 1; fi if [ "$mk" != "$fb" ]; then echo "::error file=src/core/version_fallback.h::version_fallback.h says '$fb' but the Makefile says '$mk' -- update both." exit 1 fi echo "OK: both say $mk" # Package.swift repeats Makefile.common's source list, because SPM needs # the file list in the manifest and cannot read a Makefile. Same posture as # the check above: fail loudly, never auto-fix. The downstream Provenance # wrapper kept its own copy of this list and drifted by 27 files over seven # releases -- this is that lesson turned into a build failure. - name: Package.swift sources match Makefile.common if: ${{ !cancelled() }} run: sh scripts/check-package-sources.sh # Same posture for the msvc-check job's inline source list, which rots the # same way and worse: it is Windows-only, so drift shows up as a missing # header on two jobs while ~35 others stay green (issue #679, and PR #662 # is the case in point). - name: MSVC job sources match Makefile.common if: ${{ !cancelled() }} run: sh scripts/check-msvc-sources.sh - name: Install editorconfig-checker if: ${{ !cancelled() }} run: | VERSION=v3.0.3 curl -fsSL "https://github.com/editorconfig-checker/editorconfig-checker/releases/download/${VERSION}/ec-linux-amd64.tar.gz" \ | tar -xz -C /tmp sudo install -m 0755 /tmp/bin/ec-linux-amd64 /usr/local/bin/ec - name: Run editorconfig-checker if: ${{ !cancelled() }} run: ec -verbose - name: Install cppcheck if: ${{ !cancelled() }} run: sudo apt-get update && sudo apt-get install -y cppcheck - name: Run cppcheck if: ${{ !cancelled() }} run: | # Scan in-tree sources only. src/m68000 is machine-generated # UAE 68K, src/bios/jag*.c are bin2c hex tables, and # libretro-common is a vendored subtree -- all excluded. # # Suppressions live in cppcheck-suppressions.txt with rationale. # cppcheck 2.13 (Ubuntu 24.04) doesn't accept comment lines in # the suppressions file; strip them before passing. bash scripts/gen-version-h.sh # POSIX character class -- not GNU grep's \s extension. grep -vE '^[[:space:]]*(#|$)' cppcheck-suppressions.txt > /tmp/cppcheck-suppressions.txt cppcheck \ --enable=warning,performance,portability \ --suppress=missingIncludeSystem \ --suppress=unusedFunction \ --suppressions-list=/tmp/cppcheck-suppressions.txt \ --error-exitcode=1 \ -i src/m68000 -i src/bios -i libretro-common \ -I src -I src/core -I src/tom -I src/jerry -I src/cd \ -I src/bios -I src/m68000 -I libretro-common/include \ -DINLINE=inline -D__LIBRETRO__ \ --quiet \ src/ libretro.c - name: Check for declaration-after-statement if: ${{ !cancelled() }} run: | echo "==> Checking C89 compliance (catches MSVC C89 errors)..." scripts/c89-lint.sh - name: Verify .info display_version matches Makefile if: ${{ !cancelled() }} run: bash scripts/check-info-version.sh - name: Check for stdbool.h usage (use boolean.h instead) if: ${{ !cancelled() }} run: | echo "==> Checking for direct stdbool.h includes..." FAILED=0 for f in libretro.c $(git ls-files 'src/**/*.c' 'src/**/*.h'); do case "$f" in src/core/boolean.h) continue ;; esac if grep -n '#include.*' "$f" 2>/dev/null; then echo "::error file=$f::Use instead of (MSVC 2005/2010 compat)" FAILED=1 fi done if [ "$FAILED" = "1" ]; then exit 1 fi echo "==> No direct stdbool.h includes found" spm-build: name: SwiftPM build (macOS arm64) # Mac Studio self-hosted runner for macOS rows on trusted events only (push, # schedule, dispatch, same-repo PR); fork PRs always get the hosted runner. # Kill switch: repository variable VJ_STUDIO_RUNNER=off. See docs/agent/github.md. runs-on: ${{ vars.VJ_STUDIO_RUNNER != 'off' && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","studio","vj"]') || 'macos-latest' }} steps: - uses: actions/checkout@v7 # No Makefile target reads Package.swift, so without this job a manifest # that does not even parse would merge unnoticed and only break the Apple # frontend that consumes it. Building the package here is the only thing # in CI that exercises it. # # This also covers the two things the source-list diff cannot see: that the # arch guards resolve to exactly one blitter_simd_ops when all three # variants are compiled together, and that libretro.c still compiles with # no generated src/core/version.h (a fresh checkout has none -- the # committed src/core/version_fallback.h is what makes that work). - name: swift build run: swift build -v 2>&1 | tail -40 # The blind spot that let an `unsafeFlags` slip in: SwiftPM REFUSES to # resolve a VERSIONED dependency on a package whose targets carry unsafe # flags, but a package built as the ROOT (which `swift build` above does) # is exempt -- so the restriction is invisible from inside the repo. This # consumes the package the way a frontend actually would, by version tag, # which is the only way to see it. - name: a versioned consumer can build against this package run: | set -euo pipefail work=$(mktemp -d) # A tag on a throwaway clone; the real repo is untouched. # Clone under the repo name: SwiftPM derives package identity from the # URL's last path component, not from `name:` in the manifest. git clone --quiet --no-hardlinks . "$work/virtualjaguar-libretro" git -C "$work/virtualjaguar-libretro" -c user.email=ci@example.com -c user.name=CI tag -f 0.0.1 mkdir -p "$work/app/Sources/app" echo 'print("ok")' > "$work/app/Sources/app/main.swift" cat > "$work/app/Package.swift" <&1); then echo "$out" echo "::error::a versioned dependency on this package does not resolve." echo "::error::If this says 'contains unsafe build flags', remove the .unsafeFlags" echo "::error::from Package.swift -- they make the package unconsumable by any" echo "::error::frontend pinning a release tag, which is the point of shipping it." exit 1 fi echo "OK: builds as a versioned dependency" - name: exactly one SIMD vtable, and it is not the scalar one run: | set -euo pipefail # Layout-independent: Xcode 26's SwiftPM build system keeps objects under # .build/out/Intermediates.noindex/.../Objects-normal/arm64/blitter_simd_.o, # older SwiftPM under .build//debug/virtualjaguar.build/.../.c.o. # Find each object by name and fail loudly if one of the three is missing. total=0 for a in neon sse2 scalar; do o=$(find .build -type f \( -name "blitter_simd_$a.o" -o -name "blitter_simd_$a.c.o" \) | head -1) [ -n "$o" ] || { echo "could not locate blitter_simd_$a object under .build"; exit 1; } n=$(nm "$o" | grep -c '_blitter_simd_ops' || true) echo " blitter_simd_$a: $n" total=$((total + n)) done [ "$total" -eq 1 ] || { echo "::error::expected exactly 1 blitter_simd_ops definition, found $total"; exit 1; } echo "OK: exactly one vtable" msvc-check: name: MSVC ${{ matrix.arch }} compilation check runs-on: windows-latest strategy: fail-fast: false matrix: arch: [x64, x86] steps: - uses: actions/checkout@v7 - uses: ilammy/msvc-dev-cmd@v1 with: arch: ${{ matrix.arch }} - name: Generate src/core/version.h shell: bash run: bash scripts/gen-version-h.sh # Sources are .c, so cl.exe compiles them in C mode -- deliberately. # That is what the buildbot does, and it is what makes this job a # useful C89/MSVC check. Do not "fix" it to /TP. Do not add /Za # either: it breaks and the Windows SDK headers. # # Exactly one BLITTER_SIMD_ may be defined per translation # unit -- blitter_simd.h inlines the matching blitter_simd_.h, # and any arch .c compiled under a different (or absent) define # redefines every op. So the SIMD arches get one cl.exe run each, # mirroring how Makefile.common builds exactly one of them. - name: Compile all sources with cl.exe (scalar blitter SIMD) shell: cmd run: | cl.exe /c /W3 /O2 /DNDEBUG /D_CRT_SECURE_NO_DEPRECATE ^ /I. /Isrc /Isrc\core /Isrc\tom /Isrc\jerry /Isrc\cd /Isrc\bios /Isrc\m68000 /Isrc\debug /Ilibretro-common\include ^ /Ideps\libchdr\include /Ideps\libchdr\deps\lzma-25.01\include /Ideps\libchdr\deps\miniz-3.1.1 /Ideps\libchdr\deps\zstd-1.5.7 ^ /D__LIBRETRO__ /DINLINE="_inline" /DBLITTER_SIMD_SCALAR ^ /D_7ZIP_ST /DMINIZ_DEFLATE_APIS /DWANT_RAW_DATA_SECTOR=1 /DWANT_SUBCODE=1 /DVERIFY_BLOCK_CRC=1 ^ libretro.c ^ src\tom\blitter.c src\tom\blitter_compare.c src\tom\blitter_mmio.c src\tom\texdump.c src\tom\texreplace.c src\jerry\dac.c src\jerry\dsp.c src\core\file.c ^ src\tom\gpu.c src\core\jaguar.c src\jerry\jerry.c src\tom\tom.c src\tom\op.c ^ src\cd\cdintf.c src\cd\cdrom.c src\core\crc32.c src\core\biosdb.c src\core\event.c ^ src\jerry\eeprom.c src\core\filedb.c src\jerry\joystick.c src\core\settings.c ^ src\core\memtrack.c src\core\vjag_memory.c src\core\cheat.c ^ src\core\universalhdr.c src\jerry\wavetable.c src\jerry\voicemodem.c src\core\vj_frontend_ext.c src\debug\vjdebug.c ^ src\bios\jagbios.c ^ src\bios\jagbios_m.c ^ src\bios\jagcdbios.c src\bios\jagdevcdbios.c ^ src\m68000\m68kinterface.c ^ src\debug\gdbstub.c src\debug\gdbtarget.c src\debug\gdbsock.c ^ src\tom\blitter_simd_scalar.c ^ deps\libchdr\unity.c # The shape Makefile.common now selects for windows_msvc2015/2017 # x64 and x86: blitter.c inlines blitter_simd_sse2.h, so cl.exe # compiles intrinsics inside blitter.c itself -- not # just inside the lint-exempt blitter_simd_sse2.c. That is the whole # risk of enabling SSE2 for MSVC, and this step is what verifies it. # Objects go to a subdir so they don't collide with the run above. # # No trailing `echo` in either step: `shell: cmd` has no `set -e`, # so the step's exit code is the LAST command's. A closing echo # would return 0 over any cl.exe failure and make this whole job # green-but-inert. Let cl.exe be the last command. - name: Compile blitter with cl.exe (SSE2 blitter SIMD) shell: cmd run: | mkdir msvc-sse2 cl.exe /c /W3 /O2 /DNDEBUG /D_CRT_SECURE_NO_DEPRECATE ^ /I. /Isrc /Isrc\core /Isrc\tom /Isrc\jerry /Isrc\cd /Isrc\bios /Isrc\m68000 /Ilibretro-common\include ^ /D__LIBRETRO__ /DINLINE="_inline" /DBLITTER_SIMD_SSE2 ^ /Fomsvc-sse2\ ^ src\tom\blitter.c ^ src\tom\blitter_simd_sse2.c vita-build: needs: [core-plan] if: needs.core-plan.outputs.reuse != 'true' name: build-PS Vita runs-on: ubuntu-latest container: image: vitasdk/vitasdk:latest steps: - uses: actions/checkout@v7 - name: Build env: RELEASE_DEBUG_INFO: '1' run: make -j4 platform=vita - name: Upload artifact uses: actions/upload-artifact@v7 with: name: PS Vita path: virtualjaguar_libretro_vita.a if-no-files-found: error switch-build: needs: [core-plan] if: needs.core-plan.outputs.reuse != 'true' name: build-Nintendo Switch runs-on: ubuntu-latest container: image: devkitpro/devkita64:latest steps: - uses: actions/checkout@v7 - name: Build run: make -j4 platform=libnx env: DEVKITPRO: /opt/devkitpro RELEASE_DEBUG_INFO: '1' - name: Upload artifact uses: actions/upload-artifact@v7 with: name: Nintendo Switch path: virtualjaguar_libretro_libnx.a if-no-files-found: error coverage: name: Code coverage (gcov + codecov) runs-on: ubuntu-latest # Coverage runs are advisory -- they don't gate merges. The PR # delta is what reviewers care about; codecov posts that as a # comment automatically. continue-on-error: true steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install gcov + gcovr run: | sudo apt-get update sudo apt-get install -y gcc gcovr - name: make coverage run: | bash scripts/gen-version-h.sh make coverage CC="gcc" CXX="g++" - name: Upload to Codecov uses: codecov/codecov-action@v7 with: files: ./coverage.xml fail_ci_if_error: false # Token optional for public repos; if set in repo secrets it # avoids the anonymous-upload rate limit. token: ${{ secrets.CODECOV_TOKEN }} sanitizers: name: AddressSanitizer + UndefinedBehaviorSanitizer runs-on: ubuntu-latest # Initially advisory: lets the job report findings without blocking # PRs while we triage the existing UB the cppcheck job already # flagged (signed shifts in src/tom/{gpu,op}.c, etc.). Flip to # `false` to make sanitizer findings gate merges. continue-on-error: true steps: - uses: actions/checkout@v7 - name: Install clang run: sudo apt-get update && sudo apt-get install -y clang - name: Build with ASAN + UBSAN run: | bash scripts/gen-version-h.sh # -shared-libasan: when building a shared library with # -fsanitize, clang doesn't link the static asan/ubsan # runtime into the .so (it's expected to be loaded by the # host executable). --no-undefined in the SHARED rule then # rejects the runtime symbols. -shared-libasan switches to # the dynamic libclang_rt.asan-x86_64.so which the host # process picks up at dlopen time. SAN_FLAGS="-fsanitize=address,undefined -shared-libasan -fno-omit-frame-pointer -O1 -g -fsanitize-ignorelist=$PWD/.ubsan-ignorelist" make -j4 TEST_EXPORTS=1 \ CC="clang $SAN_FLAGS" \ CXX="clang++ $SAN_FLAGS" \ LD="clang $SAN_FLAGS" - name: Run test suite under sanitizers env: ASAN_OPTIONS: 'detect_leaks=1:abort_on_error=1:print_stacktrace=1' UBSAN_OPTIONS: 'print_stacktrace=1:halt_on_error=1:report_error_type=1' run: | SAN_FLAGS="-fsanitize=address,undefined -shared-libasan -fno-omit-frame-pointer -O1 -g -fsanitize-ignorelist=$PWD/.ubsan-ignorelist" # The shared-libasan path means the test binaries need to find # libclang_rt.asan at runtime; expose its directory. ASAN_LIB_DIR=$(clang -print-resource-dir)/lib/linux export LD_LIBRARY_PATH="$ASAN_LIB_DIR:${LD_LIBRARY_PATH:-}" make test TEST_EXPORTS=1 \ CC="clang $SAN_FLAGS" \ CXX="clang++ $SAN_FLAGS" \ LD="clang $SAN_FLAGS" clang-tidy: name: clang-tidy on changed files runs-on: ubuntu-latest # Advisory at first. The .clang-tidy check list is curated, but # the first pass over emulator C will surface noisy true positives # that need triage. Flip continue-on-error to false once the # baseline is calm. continue-on-error: true if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install clang-tidy + bear run: | sudo apt-get update sudo apt-get install -y clang-tidy bear - name: Generate compile_commands.json run: | bash scripts/gen-version-h.sh # `bear -- make` records every compile invocation into # compile_commands.json without affecting the build. bear -- make -j4 - name: Run clang-tidy on PR-changed files run: | BASE="${{ github.event.pull_request.base.sha }}" # --diff-filter=d drops deleted paths: clang-tidy cannot lint a # file that no longer exists and fails the whole job with # "no input files" / "expected exactly one compiler job". # # Every *_simd_.{c,h} fragment (blitter_simd_*, op_simd_*, # tom_scan_simd_*, and any future src/jerry ones) is excluded. # Only one arch is ever built, so the other arch .c files # aren't in compile_commands.json; and the .h files are # include-only fragments that cannot be linted standalone # (blitter's #error unless blitter_simd.h pulled them in; the # op/tom ones compile to nothing without their host .c's # capability macros and includes). They are covered instead by # c89-lint's per-arch pass, which compiles them the way the # build does. # # src/jerry/dsp_exec_untimed.h is the same kind of fragment: a # function body dsp.c includes twice, using dsp.c's statics. # Standalone it is nothing but undeclared identifiers. Its code # is still linted, through dsp.c, because HeaderFilterRegex # covers src/jerry. FILES=$(git diff --name-only --diff-filter=d "$BASE"...HEAD -- '*.c' '*.h' \ | grep -Ev '^(src/m68000/|src/bios/jag.*\.c$|libretro-common/|src/core/version\.h$|src/(tom|jerry)/[a-z0-9_]+_simd_(neon|sse2|scalar)\.(c|h)$)' \ | grep -v '^src/jerry/dsp_exec_untimed\.h$' \ | grep -E '^(src/|libretro\.c$)' || true) if [ -z "$FILES" ]; then echo "No relevant C files changed; skipping clang-tidy." exit 0 fi echo "==> Running clang-tidy on:" echo "$FILES" echo "$FILES" | xargs clang-tidy -p . --quiet --warnings-as-errors='*'